feat: URL based filter for posts (#1177)

This commit is contained in:
Diego Beraldin 2024-07-28 20:02:56 +02:00 committed by GitHub
parent 40d53b0f05
commit fed21d7fe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 657 additions and 85 deletions

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@ -13,11 +14,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
@Composable
fun SectionSelector(
modifier: Modifier = Modifier,
draggable: Boolean = true,
scrollable: Boolean = false,
titles: List<String> = emptyList(),
currentSection: Int,
onSectionSelected: (Int) -> Unit,
@ -29,43 +32,72 @@ fun SectionSelector(
isTowardsStart = delta > 0
}
}
TabRow(
modifier = modifier,
selectedTabIndex = currentSection,
tabs = {
titles.forEachIndexed { i, title ->
Tab(
modifier =
Modifier.then(
if (draggable) {
Modifier.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
onDragStopped = {
if (isTowardsStart) {
onSectionSelected((currentSection - 1).coerceAtLeast(0))
} else {
onSectionSelected((currentSection + 1).coerceAtMost(titles.lastIndex))
}
},
)
} else {
Modifier
},
),
selected = i == currentSection,
text = {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
val draggableModifier =
if (draggable) {
Modifier.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
onDragStopped = {
if (isTowardsStart) {
onSectionSelected((currentSection - 1).coerceAtLeast(0))
} else {
onSectionSelected(
(currentSection + 1).coerceAtMost(
titles.lastIndex,
),
)
},
onClick = {
onSectionSelected(i)
},
)
}
},
)
}
},
)
} else {
Modifier
}
if (scrollable) {
ScrollableTabRow(
modifier = modifier,
selectedTabIndex = currentSection,
edgePadding = Spacing.xs,
tabs = {
titles.forEachIndexed { i, title ->
Tab(
modifier = draggableModifier,
selected = i == currentSection,
text = {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
},
onClick = {
onSectionSelected(i)
},
)
}
},
)
} else {
TabRow(
modifier = modifier,
selectedTabIndex = currentSection,
tabs = {
titles.forEachIndexed { i, title ->
Tab(
modifier = draggableModifier,
selected = i == currentSection,
text = {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
},
onClick = {
onSectionSelected(i)
},
)
}
},
)
}
}

View File

@ -436,4 +436,6 @@ internal open class DefaultStrings : Strings {
override val messageAuthIssueSegue1 = "force refresh"
override val messageAuthIssueSegue2 = "log in again"
override val messageAuthIssueSegue3 = "clear the application data"
override val settingsManageBanSectionDomains = "Domains"
override val settingsManageBanDomainPlaceholder = "Substring of URL to exclude"
}

View File

@ -450,4 +450,6 @@ internal val ItStrings =
override val messageAuthIssueSegue1 = "forzare l\'aggiornamento"
override val messageAuthIssueSegue2 = "effettuare nuovamente l\'accesso"
override val messageAuthIssueSegue3 = " cancellare i dati dell\'applicazione"
override val settingsManageBanSectionDomains = "Domini"
override val settingsManageBanDomainPlaceholder = "Sottostringa URL da escludere"
}

View File

@ -433,6 +433,8 @@ interface Strings {
val messageAuthIssueSegue1: String
val messageAuthIssueSegue2: String
val messageAuthIssueSegue3: String
val settingsManageBanSectionDomains: String
val settingsManageBanDomainPlaceholder: String
}
object Locales {

View File

@ -29,7 +29,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(BottomNavItemsRepository.DEFAULT_ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.items", emptyList())
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@ -42,7 +42,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.items", emptyList())
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@ -56,7 +56,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(BottomNavItemsRepository.DEFAULT_ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.$accountId.items", emptyList())
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@ -70,7 +70,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.$accountId.items", emptyList())
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@ -81,13 +81,13 @@ class DefaultBottomNavItemsRepositoryTest {
val accountId = 2L
every {
keyStore.get(
"BottomNavItemsRepository.$otherAccountId.items",
"$KEY_PREFIX.$otherAccountId.items",
any<List<String>>(),
)
} returns ITEMS_IDS
every {
keyStore.get(
"BottomNavItemsRepository.$accountId.items",
"$KEY_PREFIX.$accountId.items",
any<List<String>>(),
)
} returns emptyList()
@ -96,7 +96,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(BottomNavItemsRepository.DEFAULT_ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.$accountId.items", emptyList())
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@ -106,13 +106,13 @@ class DefaultBottomNavItemsRepositoryTest {
val otherAccountId = 1
every {
keyStore.get(
"BottomNavItemsRepository.$otherAccountId.items",
"$KEY_PREFIX.$otherAccountId.items",
any<List<String>>(),
)
} returns ITEMS_IDS
every {
keyStore.get(
"BottomNavItemsRepository.items",
"$KEY_PREFIX.items",
any<List<String>>(),
)
} returns emptyList()
@ -121,7 +121,7 @@ class DefaultBottomNavItemsRepositoryTest {
assertEquals(BottomNavItemsRepository.DEFAULT_ITEMS, res)
coVerify {
keyStore.get("BottomNavItemsRepository.items", emptyList())
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@ -131,7 +131,7 @@ class DefaultBottomNavItemsRepositoryTest {
sut.update(accountId = null, items = ITEMS)
coVerify {
keyStore.save("BottomNavItemsRepository.items", ITEMS_IDS)
keyStore.save("$KEY_PREFIX.items", ITEMS_IDS)
}
}
@ -143,7 +143,7 @@ class DefaultBottomNavItemsRepositoryTest {
sut.update(accountId = accountId, items = ITEMS)
coVerify {
keyStore.save("BottomNavItemsRepository.1.items", ITEMS_IDS)
keyStore.save("$KEY_PREFIX.$accountId.items", ITEMS_IDS)
}
}
@ -157,5 +157,6 @@ class DefaultBottomNavItemsRepositoryTest {
TabNavigationSection.Inbox,
TabNavigationSection.Settings,
)
private const val KEY_PREFIX = "BottomNavItemsRepository"
}
}

View File

@ -0,0 +1,159 @@
package com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository
import com.github.diegoberaldin.raccoonforlemmy.core.preferences.TemporaryKeyStore
import com.github.diegoberaldin.raccoonforlemmy.core.testutils.DispatcherTestRule
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
class DefaultDomainBlocklistRepositoryTest {
@get:Rule
val dispatcherRule = DispatcherTestRule()
private val keyStore = mockk<TemporaryKeyStore>(relaxUnitFun = true)
private val sut =
DefaultDomainBlocklistRepository(
keyStore = keyStore,
)
@Test
fun givenNoData_whenGetForAnonymousUser_thenResultAndInteractionsIsAsExpected() =
runTest {
every { keyStore.get(any(), any<List<String>>()) } returns emptyList()
val res = sut.get(null)
assertEquals(emptyList(), res)
coVerify {
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@Test
fun givenData_whenGetForAnonymousUser_thenResultAndInteractionsIsAsExpected() =
runTest {
val fakeList = listOf("example.org")
every { keyStore.get(any(), any<List<String>>()) } returns fakeList
val res = sut.get(null)
assertEquals(fakeList, res)
coVerify {
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@Test
fun givenNoData_whenGetForLoggedUser_thenResultAndInteractionsIsAsExpected() =
runTest {
val accountId = 1L
every { keyStore.get(any(), any<List<String>>()) } returns emptyList()
val res = sut.get(accountId)
assertEquals(emptyList(), res)
coVerify {
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@Test
fun givenData_whenGetForLoggedUser_thenResultAndInteractionsIsAsExpected() =
runTest {
val accountId = 1L
val fakeList = listOf("example.org")
every { keyStore.get(any(), any<List<String>>()) } returns fakeList
val res = sut.get(accountId)
assertEquals(fakeList, res)
coVerify {
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@Test
fun givenDataForOtherUser_whenGetForLoggedAccount_thenResultAndInteractionsIsAsExpected() =
runTest {
val otherAccountId = 1L
val accountId = 2L
val fakeList = listOf("example.org")
every {
keyStore.get(
"$KEY_PREFIX.$otherAccountId.items",
any<List<String>>(),
)
} returns fakeList
every {
keyStore.get(
"$KEY_PREFIX.$accountId.items",
any<List<String>>(),
)
} returns emptyList()
val res = sut.get(accountId)
assertEquals(emptyList(), res)
coVerify {
keyStore.get("$KEY_PREFIX.$accountId.items", emptyList())
}
}
@Test
fun givenDataForOtherUser_whenGetForAnonymousAccount_thenResultAndInteractionsIsAsExpected() =
runTest {
val otherAccountId = 1
val fakeList = listOf("example.org")
every {
keyStore.get(
"$KEY_PREFIX.$otherAccountId.items",
any<List<String>>(),
)
} returns fakeList
every {
keyStore.get(
"$KEY_PREFIX.items",
any<List<String>>(),
)
} returns emptyList()
val res = sut.get(null)
assertEquals(emptyList(), res)
coVerify {
keyStore.get("$KEY_PREFIX.items", emptyList())
}
}
@Test
fun whenUpdateAnonymousUser_thenInteractionsAreAsExpected() =
runTest {
val fakeList = listOf("example.org")
sut.update(accountId = null, items = fakeList)
coVerify {
keyStore.save("$KEY_PREFIX.items", fakeList)
}
}
@Test
fun whenUpdateLoggedUser_thenInteractionsAreAsExpected() =
runTest {
val accountId = 1L
val fakeList = listOf("example.org")
sut.update(accountId = accountId, items = fakeList)
coVerify {
keyStore.save("$KEY_PREFIX.$accountId.items", fakeList)
}
}
companion object {
private const val KEY_PREFIX = "DomainBlocklistRepository"
}
}

View File

@ -8,11 +8,13 @@ import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.Comm
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultAccountRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultCommunityPreferredLanguageRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultCommunitySortRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultDomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultDraftRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultFavoriteCommunityRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultInstanceSelectionRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultMultiCommunityRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DefaultSettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DraftRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.FavoriteCommunityRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.InstanceSelectionRepository
@ -84,4 +86,9 @@ val corePersistenceModule =
settingsRepository = get(),
)
}
single<DomainBlocklistRepository> {
DefaultDomainBlocklistRepository(
keyStore = get(),
)
}
}

View File

@ -0,0 +1,35 @@
package com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository
import com.github.diegoberaldin.raccoonforlemmy.core.preferences.TemporaryKeyStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
internal class DefaultDomainBlocklistRepository(
private val keyStore: TemporaryKeyStore,
) : DomainBlocklistRepository {
override suspend fun get(accountId: Long?): List<String> =
withContext(Dispatchers.IO) {
val key = getKey(accountId)
val res = keyStore.get(key, emptyList())
res.filter { it.isNotEmpty() }
}
override suspend fun update(
accountId: Long?,
items: List<String>,
) = withContext(Dispatchers.IO) {
val key = getKey(accountId)
keyStore.save(key, items)
}
private fun getKey(accountId: Long?): String =
buildString {
append("DomainBlocklistRepository")
if (accountId != null) {
append(".")
append(accountId)
}
append(".items")
}
}

View File

@ -0,0 +1,10 @@
package com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository
interface DomainBlocklistRepository {
suspend fun get(accountId: Long?): List<String>
suspend fun update(
accountId: Long?,
items: List<String>,
)
}

View File

@ -40,6 +40,7 @@ kotlin {
implementation(projects.core.notifications)
implementation(projects.core.utils)
implementation(projects.core.persistence)
implementation(projects.domain.identity)
implementation(projects.domain.lemmy.data)

View File

@ -2,6 +2,8 @@ package com.diegoberaldin.raccoonforlemmy.domain.lemmy.pagination
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenterEvent
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.AccountRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.core.testutils.DispatcherTestRule
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
@ -45,16 +47,26 @@ class DefaultPostPaginationManagerTest {
val slot = slot<KClass<NotificationCenterEvent>>()
every { subscribe(capture(slot)) } answers { MutableSharedFlow() }
}
private val accountRepository =
mockk<AccountRepository>(relaxUnitFun = true) {
coEvery { getActive() } returns null
}
private val domainBlocklistRepository =
mockk<DomainBlocklistRepository>(relaxUnitFun = true) {
coEvery { get(accountId = any()) } returns emptyList<String>()
}
private val sut =
DefaultPostPaginationManager(
identityRepository = identityRepository,
accountRepository = accountRepository,
postRepository = postRepository,
communityRepository = communityRepository,
userRepository = userRepository,
multiCommunityPaginator = multiCommunityPaginator,
notificationCenter = notificationCenter,
dispatcher = dispatcherTestRule.dispatcher,
domainBlocklistRepository = domainBlocklistRepository,
)
@Test

View File

@ -2,6 +2,8 @@ package com.diegoberaldin.raccoonforlemmy.domain.lemmy.pagination
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenterEvent
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.AccountRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.SearchResult
@ -21,10 +23,12 @@ import kotlinx.coroutines.withContext
internal class DefaultPostPaginationManager(
private val identityRepository: IdentityRepository,
private val accountRepository: AccountRepository,
private val postRepository: PostRepository,
private val communityRepository: CommunityRepository,
private val userRepository: UserRepository,
private val multiCommunityPaginator: MultiCommunityPaginator,
private val domainBlocklistRepository: DomainBlocklistRepository,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
notificationCenter: NotificationCenter,
) : PostPaginationManager {
@ -36,6 +40,7 @@ internal class DefaultPostPaginationManager(
private var currentPage: Int = 1
private var pageCursor: String? = null
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private var blockedDomains: List<String>? = null
init {
notificationCenter
@ -52,6 +57,7 @@ internal class DefaultPostPaginationManager(
currentPage = 1
pageCursor = null
multiCommunityPaginator.reset()
blockedDomains = null
(specification as? PostPaginationSpecification.MultiCommunity)?.also {
multiCommunityPaginator.setCommunities(it.communityIds)
}
@ -61,6 +67,10 @@ internal class DefaultPostPaginationManager(
withContext(Dispatchers.IO) {
val specification = specification ?: return@withContext emptyList()
val auth = identityRepository.authToken.value.orEmpty()
if (blockedDomains == null) {
val accountId = accountRepository.getActive()?.id
blockedDomains = domainBlocklistRepository.get(accountId)
}
val result =
when (specification) {
@ -85,6 +95,7 @@ internal class DefaultPostPaginationManager(
.deduplicate()
.filterNsfw(specification.includeNsfw)
.filterDeleted()
.filterByUrlDomain()
}
is PostPaginationSpecification.Community -> {
@ -125,6 +136,7 @@ internal class DefaultPostPaginationManager(
.deduplicate()
.filterNsfw(specification.includeNsfw)
.filterDeleted(includeCurrentCreator = true)
.filterByUrlDomain()
}
is PostPaginationSpecification.MultiCommunity -> {
@ -138,6 +150,7 @@ internal class DefaultPostPaginationManager(
.deduplicate()
.filterNsfw(specification.includeNsfw)
.filterDeleted(includeCurrentCreator = true)
.filterByUrlDomain()
}
is PostPaginationSpecification.User -> {
@ -159,6 +172,7 @@ internal class DefaultPostPaginationManager(
.deduplicate()
.filterNsfw(specification.includeNsfw)
.filterDeleted(includeCurrentCreator = specification.includeDeleted)
.filterByUrlDomain()
}
is PostPaginationSpecification.Votes -> {
@ -181,6 +195,7 @@ internal class DefaultPostPaginationManager(
.orEmpty()
.deduplicate()
.filterDeleted(includeCurrentCreator = true)
.filterByUrlDomain()
}
is PostPaginationSpecification.Saved -> {
@ -199,6 +214,7 @@ internal class DefaultPostPaginationManager(
.orEmpty()
.deduplicate()
.filterDeleted()
.filterByUrlDomain()
}
is PostPaginationSpecification.Hidden -> {
@ -220,6 +236,7 @@ internal class DefaultPostPaginationManager(
.orEmpty()
.deduplicate()
.filterDeleted()
.filterByUrlDomain()
}
}
@ -233,6 +250,7 @@ internal class DefaultPostPaginationManager(
specification = specification,
currentPage = currentPage,
pageCursor = pageCursor,
blockedDomains = blockedDomains,
history = history,
)
@ -242,6 +260,7 @@ internal class DefaultPostPaginationManager(
specification = it.specification
pageCursor = it.pageCursor
history.clear()
blockedDomains = it.blockedDomains
history.addAll(it.history)
}
}
@ -266,6 +285,14 @@ internal class DefaultPostPaginationManager(
}
}
private fun List<PostModel>.filterByUrlDomain(): List<PostModel> {
return filter { post ->
blockedDomains?.takeIf { it.isNotEmpty() }?.let { blockList ->
blockList.none { domain -> post.url?.contains(domain) ?: true }
} ?: true
}
}
private fun handlePostUpdate(post: PostModel) {
val index = history.indexOfFirst { it.id == post.id }.takeIf { it >= 0 } ?: return
history.removeAt(index)

View File

@ -7,4 +7,5 @@ internal data class DefaultPostPaginationManagerState(
val currentPage: Int = 1,
val pageCursor: String? = null,
val history: List<PostModel> = emptyList(),
val blockedDomains: List<String>? = null,
) : PostPaginationManagerState

View File

@ -22,11 +22,13 @@ val paginationModule =
factory<PostPaginationManager> {
DefaultPostPaginationManager(
identityRepository = get(),
accountRepository = get(),
postRepository = get(),
communityRepository = get(),
userRepository = get(),
multiCommunityPaginator = get(),
notificationCenter = get(),
domainBlocklistRepository = get(),
)
}
factory<CommentPaginationManager> {

View File

@ -5,6 +5,8 @@ import com.github.diegoberaldin.raccoonforlemmy.core.appearance.repository.Theme
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenterEvent
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.AccountRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.SettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.vibrate.HapticFeedback
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.ApiConfigurationRepository
@ -51,6 +53,8 @@ class ExploreViewModel(
private val hapticFeedback: HapticFeedback,
private val getSortTypesUseCase: GetSortTypesUseCase,
private val lemmyValueCache: LemmyValueCache,
private val accountRepository: AccountRepository,
private val domainBlocklistRepository: DomainBlocklistRepository,
) : DefaultMviModel<ExploreMviModel.Intent, ExploreMviModel.UiState, ExploreMviModel.Effect>(
initialState = ExploreMviModel.UiState(),
),
@ -66,6 +70,7 @@ class ExploreViewModel(
append(otherInstance)
}
}
private var blockedDomains: List<String>? = null
init {
screenModelScope.launch {
@ -355,6 +360,8 @@ class ExploreViewModel(
private suspend fun refresh(initial: Boolean = false) {
currentPage = 1
val accountId = accountRepository.getActive()?.id
blockedDomains = domainBlocklistRepository.get(accountId)
updateState {
it.copy(
canFetchMore = true,
@ -427,7 +434,18 @@ class ExploreViewModel(
} else {
isSafeForWork(item)
}
}.let {
}.filter {
when (it) {
is SearchResult.Post -> {
blockedDomains?.takeIf { l -> l.isNotEmpty() }?.let { blockList ->
blockList.none { domain -> it.model.url?.contains(domain) ?: true }
} ?: true
}
else -> true
}
}
.let {
when (resultType) {
SearchResultType.Communities -> {
if (additionalResolvedCommunity != null &&

View File

@ -11,6 +11,7 @@ val exploreModule =
otherInstance = params[0],
apiConfigRepository = get(),
identityRepository = get(),
accountRepository = get(),
communityRepository = get(),
userRepository = get(),
postRepository = get(),
@ -21,6 +22,7 @@ val exploreModule =
hapticFeedback = get(),
getSortTypesUseCase = get(),
lemmyValueCache = get(),
domainBlocklistRepository = get(),
)
}
}

View File

@ -7,12 +7,6 @@ import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.InstanceModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
enum class ManageBanSection {
Users,
Communities,
Instances,
}
@Stable
interface ManageBanMviModel :
MviModel<ManageBanMviModel.Intent, ManageBanMviModel.UiState, ManageBanMviModel.Effect>,
@ -39,6 +33,14 @@ interface ManageBanMviModel :
data class SetSearch(
val value: String,
) : Intent
data class BlockDomain(
val value: String,
) : Intent
data class UnblockDomain(
val value: String,
) : Intent
}
data class UiState(
@ -50,6 +52,7 @@ interface ManageBanMviModel :
val bannedUsers: List<UserModel> = emptyList(),
val bannedCommunities: List<CommunityModel> = emptyList(),
val bannedInstances: List<InstanceModel> = emptyList(),
val blockedDomains: List<String> = emptyList(),
val searchText: String = "",
)

View File

@ -1,5 +1,8 @@
package com.github.diegoberaldin.raccoonforlemmy.unit.manageban
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -7,6 +10,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -14,6 +18,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -31,7 +37,10 @@ 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -44,6 +53,8 @@ import androidx.compose.ui.text.style.TextAlign
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.FloatingActionButtonMenu
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.FloatingActionButtonMenuItem
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.SearchField
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.SectionSelector
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.CommunityItem
@ -51,15 +62,19 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.CommunityI
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.Option
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.OptionId
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.UserItem
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.di.getFabNestedScrollConnection
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.modals.EditTextualInfoDialog
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.di.getSettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallback
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs
import com.github.diegoberaldin.raccoonforlemmy.unit.manageban.components.InstanceItem
import com.github.diegoberaldin.raccoonforlemmy.unit.manageban.components.ManageBanItem
import com.github.diegoberaldin.raccoonforlemmy.unit.manageban.components.ManageBanItemPlaceholder
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class ManageBanScreen : Screen {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@ -70,6 +85,8 @@ class ManageBanScreen : Screen {
val navigationCoordinator = remember { getNavigationCoordinator() }
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
val fabNestedScrollConnection = remember { getFabNestedScrollConnection() }
val isFabVisible by fabNestedScrollConnection.isFabVisible.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val settingsRepository = remember { getSettingsRepository() }
val settings by settingsRepository.currentSettings.collectAsState()
@ -89,6 +106,8 @@ class ManageBanScreen : Screen {
}
val successMessage = LocalStrings.current.messageOperationSuccessful
val errorMessage = LocalStrings.current.messageGenericError
val scope = rememberCoroutineScope()
var addDomainDialogOpen by remember { mutableStateOf(false) }
LaunchedEffect(model) {
model.effects
@ -151,6 +170,51 @@ class ManageBanScreen : Screen {
)
}
},
floatingActionButton = {
AnimatedVisibility(
visible = isFabVisible,
enter =
slideInVertically(
initialOffsetY = { it * 2 },
),
exit =
slideOutVertically(
targetOffsetY = { it * 2 },
),
) {
FloatingActionButtonMenu(
items =
buildList {
this +=
FloatingActionButtonMenuItem(
icon = Icons.Default.ExpandLess,
text = LocalStrings.current.actionBackToTop,
onSelected =
rememberCallback {
scope.launch {
runCatching {
lazyListState.scrollToItem(0)
topAppBarState.heightOffset = 0f
topAppBarState.contentOffset = 0f
}
}
},
)
if (uiState.section == ManageBanSection.Domains) {
this +=
FloatingActionButtonMenuItem(
icon = Icons.Default.AddCircle,
text = LocalStrings.current.buttonAdd,
onSelected =
rememberCallback {
addDomainDialogOpen = true
},
)
}
},
)
}
},
) { padding ->
val pullRefreshState =
rememberPullRefreshState(
@ -165,7 +229,8 @@ class ManageBanScreen : Screen {
Modifier
.padding(
top = padding.calculateTopPadding(),
).then(
).navigationBarsPadding()
.then(
if (settings.hideNavigationBarWhileScrolling) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
@ -198,20 +263,12 @@ class ManageBanScreen : Screen {
LocalStrings.current.exploreResultTypeUsers,
LocalStrings.current.exploreResultTypeCommunities,
LocalStrings.current.settingsManageBanSectionInstances,
LocalStrings.current.settingsManageBanSectionDomains,
),
currentSection =
when (uiState.section) {
ManageBanSection.Instances -> 2
ManageBanSection.Communities -> 1
else -> 0
},
onSectionSelected = {
val section =
when (it) {
2 -> ManageBanSection.Instances
1 -> ManageBanSection.Communities
else -> ManageBanSection.Users
}
scrollable = true,
currentSection = uiState.section.toInt(),
onSectionSelected = { idx ->
val section = idx.toManageBanSection()
model.reduce(ManageBanMviModel.Intent.ChangeSection(section))
},
)
@ -255,7 +312,10 @@ class ManageBanScreen : Screen {
}
}
} else {
items(uiState.bannedUsers) { user ->
items(
items = uiState.bannedUsers,
key = { it.id },
) { user ->
UserItem(
user = user,
autoLoadImages = uiState.autoLoadImages,
@ -308,7 +368,10 @@ class ManageBanScreen : Screen {
}
}
} else {
items(uiState.bannedCommunities) { community ->
items(
items = uiState.bannedCommunities,
key = { it.id },
) { community ->
CommunityItem(
community = community,
autoLoadImages = uiState.autoLoadImages,
@ -344,7 +407,7 @@ class ManageBanScreen : Screen {
if (uiState.bannedInstances.isEmpty()) {
if (uiState.initial) {
items(5) {
CommunityItemPlaceholder()
ManageBanItemPlaceholder()
}
} else {
item {
@ -361,9 +424,12 @@ class ManageBanScreen : Screen {
}
}
} else {
items(uiState.bannedInstances) { instance ->
InstanceItem(
instance = instance,
items(
items = uiState.bannedInstances,
key = { it.id },
) { instance ->
ManageBanItem(
title = instance.domain,
options =
buildList {
this +=
@ -390,6 +456,60 @@ class ManageBanScreen : Screen {
}
}
}
ManageBanSection.Domains -> {
if (uiState.blockedDomains.isEmpty()) {
if (uiState.initial) {
items(5) {
ManageBanItemPlaceholder()
}
} else {
item {
Text(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Spacing.xs),
textAlign = TextAlign.Center,
text = LocalStrings.current.messageEmptyList,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
} else {
items(
items = uiState.blockedDomains,
key = { it },
) { domain ->
ManageBanItem(
title = domain,
options =
buildList {
this +=
Option(
OptionId.Unban,
LocalStrings.current.settingsManageBanActionUnban,
)
},
onOptionSelected =
rememberCallbackArgs(domain) { optionId ->
when (optionId) {
OptionId.Unban -> {
model.reduce(
ManageBanMviModel.Intent.UnblockDomain(
domain,
),
)
}
else -> Unit
}
},
)
}
}
}
}
}
@ -403,5 +523,19 @@ class ManageBanScreen : Screen {
}
}
}
if (addDomainDialogOpen) {
EditTextualInfoDialog(
title = LocalStrings.current.settingsManageBanDomainPlaceholder,
value = "",
onClose =
rememberCallbackArgs(model) { newValue ->
addDomainDialogOpen = false
newValue?.also {
model.reduce(ManageBanMviModel.Intent.BlockDomain(it))
}
},
)
}
}
}

View File

@ -0,0 +1,27 @@
package com.github.diegoberaldin.raccoonforlemmy.unit.manageban
sealed interface ManageBanSection {
data object Users : ManageBanSection
data object Communities : ManageBanSection
data object Instances : ManageBanSection
data object Domains : ManageBanSection
}
fun ManageBanSection.toInt(): Int =
when (this) {
ManageBanSection.Communities -> 1
ManageBanSection.Domains -> 3
ManageBanSection.Instances -> 2
ManageBanSection.Users -> 0
}
fun Int.toManageBanSection(): ManageBanSection =
when (this) {
3 -> ManageBanSection.Domains
2 -> ManageBanSection.Instances
1 -> ManageBanSection.Communities
else -> ManageBanSection.Users
}

View File

@ -2,6 +2,8 @@ package com.github.diegoberaldin.raccoonforlemmy.unit.manageban
import cafe.adriel.voyager.core.model.screenModelScope
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.AccountRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.SettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.AccountBansModel
@ -25,15 +27,18 @@ import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class ManageBanViewModel(
private val identityRepository: IdentityRepository,
private val accountRepository: AccountRepository,
private val siteRepository: SiteRepository,
private val settingsRepository: SettingsRepository,
private val userRepository: UserRepository,
private val communityRepository: CommunityRepository,
private val blocklistRepository: DomainBlocklistRepository,
) : DefaultMviModel<ManageBanMviModel.Intent, ManageBanMviModel.UiState, ManageBanMviModel.Effect>(
initialState = ManageBanMviModel.UiState(),
),
ManageBanMviModel {
private var originalBans: AccountBansModel? = null
private var originalBlockedDomains: List<String> = emptyList()
init {
screenModelScope.launch {
@ -83,6 +88,8 @@ class ManageBanViewModel(
is ManageBanMviModel.Intent.UnblockCommunity -> unbanCommunity(intent.id)
is ManageBanMviModel.Intent.UnblockInstance -> unbanInstance(intent.id)
is ManageBanMviModel.Intent.UnblockUser -> unbanUser(intent.id)
is ManageBanMviModel.Intent.BlockDomain -> blockDomain(intent.value)
is ManageBanMviModel.Intent.UnblockDomain -> unblockDomain(intent.value)
is ManageBanMviModel.Intent.SetSearch -> updateSearchText(intent.value)
}
}
@ -90,6 +97,8 @@ class ManageBanViewModel(
private suspend fun refresh() {
val auth = identityRepository.authToken.value.orEmpty()
originalBans = siteRepository.getBans(auth)
val accountId = accountRepository.getActive()?.id
originalBlockedDomains = blocklistRepository.get(accountId)
val query = uiState.value.searchText
filterResults(query)
}
@ -164,10 +173,38 @@ class ManageBanViewModel(
bannedUsers = bans.users.filterUsersBy(query),
bannedCommunities = bans.communities.filterCommunitiesBy(query),
bannedInstances = bans.instances.filterInstancesBy(query),
blockedDomains = originalBlockedDomains.filterBy(query),
initial = false,
)
}
}
private fun blockDomain(domain: String) {
val newValues =
if (originalBlockedDomains.contains(domain)) {
originalBlockedDomains
} else {
originalBlockedDomains + domain
}
screenModelScope.launch {
val accountId = accountRepository.getActive()?.id
originalBlockedDomains = newValues
blocklistRepository.update(accountId, newValues)
val query = uiState.value.searchText
filterResults(query)
}
}
private fun unblockDomain(domain: String) {
val newValues = originalBlockedDomains - domain
screenModelScope.launch {
val accountId = accountRepository.getActive()?.id
originalBlockedDomains = newValues
blocklistRepository.update(accountId, newValues)
val query = uiState.value.searchText
filterResults(query)
}
}
}
private fun List<UserModel>.filterUsersBy(query: String): List<UserModel> =
@ -188,5 +225,12 @@ private fun List<InstanceModel>.filterInstancesBy(query: String): List<InstanceM
if (query.isEmpty()) {
this
} else {
filter { it.domain.contains(query) }
filter { it.domain.contains(query, ignoreCase = true) }
}
private fun List<String>.filterBy(query: String): List<String> =
if (query.isEmpty()) {
this
} else {
filter { it.contains(query, ignoreCase = true) }
}

View File

@ -32,16 +32,14 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.Option
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.OptionId
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.utils.toLocalDp
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.InstanceModel
@Composable
fun InstanceItem(
instance: InstanceModel,
internal fun ManageBanItem(
title: String,
modifier: Modifier = Modifier,
options: List<Option> = emptyList(),
onOptionSelected: ((OptionId) -> Unit)? = null,
) {
val name = instance.domain
val iconSize = 30.dp
val fullColor = MaterialTheme.colorScheme.onBackground
val ancillaryColor = MaterialTheme.colorScheme.onBackground.copy(alpha = ancillaryTextAlpha)
@ -59,12 +57,12 @@ fun InstanceItem(
) {
PlaceholderImage(
size = iconSize,
title = name,
title = title,
)
Text(
modifier = Modifier.weight(1f),
text = name,
text = title,
style = MaterialTheme.typography.bodyLarge,
color = fullColor,
)

View File

@ -0,0 +1,51 @@
package com.github.diegoberaldin.raccoonforlemmy.unit.manageban.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.CornerSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.IconSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.shimmerEffect
@Composable
internal fun ManageBanItemPlaceholder() {
Row(
modifier =
Modifier.padding(
vertical = Spacing.xs,
horizontal = Spacing.s,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
Box(
modifier =
Modifier
.padding(Spacing.xxxs)
.size(IconSize.l)
.clip(CircleShape)
.shimmerEffect(),
)
Box(
modifier =
Modifier
.padding(start = Spacing.xs)
.height(40.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(CornerSize.s))
.shimmerEffect(),
)
}
}

View File

@ -9,10 +9,12 @@ val manageBanModule =
factory<ManageBanMviModel> {
ManageBanViewModel(
identityRepository = get(),
accountRepository = get(),
siteRepository = get(),
settingsRepository = get(),
userRepository = get(),
communityRepository = get(),
blocklistRepository = get(),
)
}
}