feat: restrict local user search to results on the current instance (#209)

* update l10n

* update settings model

* migrate DB schema

* update search pagination

* update explore business logic

* add option in advanced settings
This commit is contained in:
Dieguitux 2025-01-01 13:41:50 +01:00 committed by GitHub
parent 716847c819
commit 799fa59ec3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 162 additions and 5 deletions

View File

@ -392,6 +392,9 @@ interface Strings {
val settingsReplyColor: String @Composable get
val settingsSaveColor: String @Composable get
val settingsSearchPostsTitleOnly: String @Composable get
val settingsSearchPostsTitleOnlySubtitle: String @Composable get
val settingsSearchRestrictLocalUserSearch: String @Composable get
val settingsSearchRestrictLocalUserSearchSubtitle: String @Composable get
val settingsSectionAccount: String @Composable get
val settingsSectionAppearance: String @Composable get
val settingsSectionDebug: String @Composable get

View File

@ -174,6 +174,7 @@ class DefaultSettingsRepositoryTest {
useAvatarAsProfileNavigationIcon = if (model.useAvatarAsProfileNavigationIcon) 1 else 0,
randomThemeColor = if (model.randomThemeColor) 1 else 0,
openPostWebPageOnImageClick = if (model.openPostWebPageOnImageClick) 1 else 0,
restrictLocalUserSearch = if (model.restrictLocalUserSearch) 1 else 0,
)
}
}
@ -251,6 +252,7 @@ class DefaultSettingsRepositoryTest {
useAvatarAsProfileNavigationIcon = if (model.useAvatarAsProfileNavigationIcon) 1 else 0,
randomThemeColor = if (model.randomThemeColor) 1 else 0,
openPostWebPageOnImageClick = if (model.openPostWebPageOnImageClick) 1 else 0,
restrictLocalUserSearch = if (model.restrictLocalUserSearch) 1 else 0,
)
}
}
@ -392,6 +394,7 @@ class DefaultSettingsRepositoryTest {
useAvatarAsProfileNavigationIcon: Boolean = false,
randomThemeColor: Boolean = true,
openPostWebPageOnImageClick: Boolean = true,
restrictLocalUserSearch: Boolean = false,
) = GetBy(
id = id,
theme = theme,
@ -458,5 +461,6 @@ class DefaultSettingsRepositoryTest {
useAvatarAsProfileNavigationIcon = if (useAvatarAsProfileNavigationIcon) 1 else 0,
randomThemeColor = if (randomThemeColor) 1 else 0,
openPostWebPageOnImageClick = if (openPostWebPageOnImageClick) 1 else 0,
restrictLocalUserSearch = if (restrictLocalUserSearch) 1 else 0,
)
}

View File

@ -69,4 +69,5 @@ data class SettingsModel(
val randomThemeColor: Boolean = true,
val openPostWebPageOnImageClick: Boolean = true,
val enableAlternateMarkdownRendering: Boolean = false,
val restrictLocalUserSearch: Boolean = false,
)

View File

@ -69,6 +69,7 @@ private object KeyStoreKeys {
const val RANDOM_THEME_COLOR = "randomThemeColor"
const val OPEN_POST_WEB_PAGE_ON_IMAGE_CLICK = "openPostWebPageOnImageClick"
const val ENABLE_ALTERNATE_MARKDOWN_RENDERING = "enableAlternateMarkdownRendering"
const val RESTRICT_LOCAL_USER_SEARCH = "restrictLocalUserSearch"
}
internal class DefaultSettingsRepository(
@ -168,6 +169,7 @@ internal class DefaultSettingsRepository(
useAvatarAsProfileNavigationIcon = if (settings.useAvatarAsProfileNavigationIcon) 1L else 0L,
randomThemeColor = if (settings.randomThemeColor) 1L else 0L,
openPostWebPageOnImageClick = if (settings.openPostWebPageOnImageClick) 1L else 0L,
restrictLocalUserSearch = if (settings.restrictLocalUserSearch) 1L else 0L,
)
}
@ -250,6 +252,7 @@ internal class DefaultSettingsRepository(
randomThemeColor = keyStore[KeyStoreKeys.RANDOM_THEME_COLOR, true],
openPostWebPageOnImageClick = keyStore[KeyStoreKeys.OPEN_POST_WEB_PAGE_ON_IMAGE_CLICK, true],
enableAlternateMarkdownRendering = keyStore[KeyStoreKeys.ENABLE_ALTERNATE_MARKDOWN_RENDERING, false],
restrictLocalUserSearch = keyStore[KeyStoreKeys.RESTRICT_LOCAL_USER_SEARCH, false],
)
} else {
val entity = db.settingsQueries.getBy(accountId).executeAsOneOrNull()
@ -381,6 +384,7 @@ internal class DefaultSettingsRepository(
KeyStoreKeys.OPEN_POST_WEB_PAGE_ON_IMAGE_CLICK,
settings.openPostWebPageOnImageClick,
)
keyStore.save(KeyStoreKeys.RESTRICT_LOCAL_USER_SEARCH, settings.restrictLocalUserSearch)
} else {
db.settingsQueries.update(
theme = settings.theme?.toLong(),
@ -466,6 +470,7 @@ internal class DefaultSettingsRepository(
useAvatarAsProfileNavigationIcon = if (settings.useAvatarAsProfileNavigationIcon) 1L else 0L,
randomThemeColor = if (settings.randomThemeColor) 1L else 0L,
openPostWebPageOnImageClick = if (settings.openPostWebPageOnImageClick) 1L else 0L,
restrictLocalUserSearch = if (settings.restrictLocalUserSearch) 1L else 0L,
)
}
}
@ -573,4 +578,5 @@ private fun GetBy.toModel() =
useAvatarAsProfileNavigationIcon = useAvatarAsProfileNavigationIcon == 1L,
randomThemeColor = randomThemeColor == 1L,
openPostWebPageOnImageClick = openPostWebPageOnImageClick == 1L,
restrictLocalUserSearch = restrictLocalUserSearch == 1L,
)

View File

@ -80,6 +80,7 @@ internal data class SerializableSettings(
val useAvatarAsProfileNavigationIcon: Boolean = false,
val randomThemeColor: Boolean = false,
val openPostWebPageOnImageClick: Boolean = true,
val restrictLocalUserSearch: Boolean = true,
)
internal fun SerializableSettings.toModel() =
@ -150,6 +151,7 @@ internal fun SerializableSettings.toModel() =
useAvatarAsProfileNavigationIcon = useAvatarAsProfileNavigationIcon,
randomThemeColor = randomThemeColor,
openPostWebPageOnImageClick = openPostWebPageOnImageClick,
restrictLocalUserSearch = restrictLocalUserSearch,
)
internal fun SettingsModel.toData() =
@ -222,4 +224,5 @@ internal fun SettingsModel.toData() =
useAvatarAsProfileNavigationIcon = useAvatarAsProfileNavigationIcon,
randomThemeColor = randomThemeColor,
openPostWebPageOnImageClick = openPostWebPageOnImageClick,
restrictLocalUserSearch = restrictLocalUserSearch,
)

View File

@ -65,6 +65,7 @@ CREATE TABLE SettingsEntity (
useAvatarAsProfileNavigationIcon INTEGER NOT NULL DEFAULT 0,
randomThemeColor INTEGER NOT NULL DEFAULT 1,
openPostWebPageOnImageClick INTEGER NOT NULL DEFAULT 1,
restrictLocalUserSearch INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (account_id) REFERENCES AccountEntity(id) ON DELETE CASCADE,
UNIQUE(account_id)
);
@ -135,7 +136,8 @@ INSERT OR IGNORE INTO SettingsEntity (
useAvatarAsProfileNavigationIcon,
randomThemeColor,
openPostWebPageOnImageClick,
account_id
account_id,
restrictLocalUserSearch
) VALUES (
?,
?,
@ -201,6 +203,7 @@ INSERT OR IGNORE INTO SettingsEntity (
?,
?,
?,
?,
?
);
@ -269,7 +272,8 @@ SET theme = ?,
defaultExploreResultType = ?,
useAvatarAsProfileNavigationIcon = ?,
randomThemeColor = ?,
openPostWebPageOnImageClick = ?
openPostWebPageOnImageClick = ?,
restrictLocalUserSearch = ?
WHERE account_id = ?;
getBy:
@ -338,6 +342,7 @@ SELECT
defaultExploreResultType,
useAvatarAsProfileNavigationIcon,
randomThemeColor,
openPostWebPageOnImageClick
openPostWebPageOnImageClick,
restrictLocalUserSearch
FROM SettingsEntity
WHERE account_id = ?;

View File

@ -0,0 +1,2 @@
ALTER TABLE SettingsEntity
ADD COLUMN restrictLocalUserSearch INTEGER NOT NULL DEFAULT 0;

View File

@ -4,7 +4,9 @@ import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.Account
import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.StopWordRepository
import com.livefast.eattrash.raccoonforlemmy.core.testutils.DispatcherTestRule
import com.livefast.eattrash.raccoonforlemmy.domain.identity.repository.ApiConfigurationRepository
import com.livefast.eattrash.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.ListingType
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.PostModel
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.SearchResult
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.SearchResultType
@ -69,6 +71,10 @@ class DefaultExplorePaginationManagerTest {
mockk<UserTagHelper> {
coEvery { any<UserModel>().withTags() } answers { firstArg() }
}
private val apiConfigurationRepository =
mockk<ApiConfigurationRepository> {
every { instance } returns MutableStateFlow("instance")
}
private val sut =
DefaultExplorePaginationManager(
@ -80,6 +86,7 @@ class DefaultExplorePaginationManagerTest {
userRepository = userRepository,
domainBlocklistRepository = domainBlocklistRepository,
stopWordRepository = stopWordRepository,
apiConfigurationRepository = apiConfigurationRepository,
userTagHelper = userTagHelper,
)
@ -408,6 +415,70 @@ class DefaultExplorePaginationManagerTest {
}
}
@Test
fun givenResultsAndRestrictLocalUserSearch_whenLoadNextPage_thenResultIsAsExpected() =
runTest {
val page = slot<Int>()
coEvery {
communityRepository.search(
query = any(),
auth = any(),
page = capture(page),
limit = any(),
sortType = any(),
listingType = any(),
resultType = any(),
instance = any(),
communityId = any(),
)
} answers {
val pageNumber = page.captured
if (pageNumber == 1) {
(0..<20).map { idx ->
SearchResult.User(
UserModel(
id = idx.toLong(),
host =
if (idx == 0) {
"instance"
} else {
""
},
),
)
}
} else {
emptyList()
}
}
val specification =
ExplorePaginationSpecification(
resultType = SearchResultType.Users,
listingType = ListingType.Local,
restrictLocalUserSearch = true,
)
sut.reset(specification)
val items = sut.loadNextPage()
assertEquals(1, items.size)
assertTrue(sut.canFetchMore)
coVerify {
communityRepository.search(
auth = AUTH_TOKEN,
page = 1,
limit = 20,
listingType = specification.listingType,
sortType = specification.sortType,
communityId = null,
instance = null,
resultType = specification.resultType,
query = "",
)
}
}
companion object {
private const val AUTH_TOKEN = "fake-token"
}

View File

@ -3,7 +3,9 @@ package com.livefast.eattrash.raccoonforlemmy.domain.lemmy.pagination
import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.AccountRepository
import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.DomainBlocklistRepository
import com.livefast.eattrash.raccoonforlemmy.core.persistence.repository.StopWordRepository
import com.livefast.eattrash.raccoonforlemmy.domain.identity.repository.ApiConfigurationRepository
import com.livefast.eattrash.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.ListingType
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.SearchResult
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.SearchResultType
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.data.uniqueIdentifier
@ -25,6 +27,7 @@ class DefaultExplorePaginationManager(
private val userRepository: UserRepository,
private val domainBlocklistRepository: DomainBlocklistRepository,
private val stopWordRepository: StopWordRepository,
private val apiConfigurationRepository: ApiConfigurationRepository,
private val userTagHelper: UserTagHelper,
) : ExplorePaginationManager {
override var canFetchMore: Boolean = true
@ -136,6 +139,19 @@ class DefaultExplorePaginationManager(
}
}
SearchResultType.Users -> {
if (specification.listingType == ListingType.Local && specification.restrictLocalUserSearch) {
val referenceHost =
specification.otherInstance?.takeIf { s -> s.isNotEmpty() }
?: apiConfigurationRepository.instance.value
it.filterIsInstance<SearchResult.User>().filter { res ->
res.model.host == referenceHost
}
} else {
it
}
}
else -> it
}
}.deduplicate()

View File

@ -10,6 +10,7 @@ data class ExplorePaginationSpecification(
val sortType: SortType = SortType.Active,
val includeNsfw: Boolean = true,
val searchPostTitleOnly: Boolean = false,
val restrictLocalUserSearch: Boolean = false,
val otherInstance: String? = null,
val query: String? = null,
)

View File

@ -50,6 +50,7 @@ val lemmyPaginationModule =
userRepository = instance(),
domainBlocklistRepository = instance(),
stopWordRepository = instance(),
apiConfigurationRepository = instance(),
userTagHelper = instance(),
)
}

View File

@ -90,6 +90,10 @@ interface AdvancedSettingsMviModel :
data class ChangeEnableAlternateMarkdownRendering(
val value: Boolean,
) : Intent
data class ChangeRestrictLocalUserSearch(
val value: Boolean,
) : Intent
}
data class UiState(
@ -126,6 +130,7 @@ interface AdvancedSettingsMviModel :
val openPostWebPageOnImageClick: Boolean = true,
val alternateMarkdownRenderingItemVisible: Boolean = false,
val enableAlternateMarkdownRendering: Boolean = false,
val restrictLocalUserSearch: Boolean = false,
)
sealed interface Effect {

View File

@ -500,6 +500,7 @@ class AdvancedSettingsScreen : Screen {
// search posts only in title
SettingsSwitchRow(
title = LocalStrings.current.settingsSearchPostsTitleOnly,
subtitle = LocalStrings.current.settingsSearchPostsTitleOnlySubtitle,
value = uiState.searchPostTitleOnly,
onValueChanged = { value ->
model.reduce(
@ -507,6 +508,17 @@ class AdvancedSettingsScreen : Screen {
)
},
)
// restrict local user search to results
SettingsSwitchRow(
title = LocalStrings.current.settingsSearchRestrictLocalUserSearch,
subtitle = LocalStrings.current.settingsSearchRestrictLocalUserSearchSubtitle,
value = uiState.restrictLocalUserSearch,
onValueChanged = { value ->
model.reduce(
AdvancedSettingsMviModel.Intent.ChangeRestrictLocalUserSearch(value),
)
},
)
if (uiState.isLogged) {
// check inbox unread items

View File

@ -164,6 +164,7 @@ class AdvancedSettingsViewModel(
useAvatarAsProfileNavigationIcon = settings.useAvatarAsProfileNavigationIcon,
openPostWebPageOnImageClick = settings.openPostWebPageOnImageClick,
enableAlternateMarkdownRendering = settings.enableAlternateMarkdownRendering,
restrictLocalUserSearch = settings.restrictLocalUserSearch,
)
}
}
@ -218,6 +219,9 @@ class AdvancedSettingsViewModel(
is AdvancedSettingsMviModel.Intent.ChangeEnableAlternateMarkdownRendering ->
changeEnableAlternateMarkdownRendering(intent.value)
is AdvancedSettingsMviModel.Intent.ChangeRestrictLocalUserSearch ->
changeRestrictLocalUserSearch(intent.value)
}
}
@ -458,6 +462,15 @@ class AdvancedSettingsViewModel(
}
}
private fun changeRestrictLocalUserSearch(value: Boolean) {
screenModelScope.launch {
updateState { it.copy(restrictLocalUserSearch = value) }
val settings =
settingsRepository.currentSettings.value.copy(restrictLocalUserSearch = value)
saveSettings(settings)
}
}
private suspend fun saveSettings(settings: SettingsModel) {
val accountId = accountRepository.getActive()?.id
settingsRepository.updateSettings(settings, accountId)

View File

@ -390,6 +390,9 @@
<string name="settings_reply_color">Reply action color</string>
<string name="settings_save_color">Save action color</string>
<string name="settings_search_posts_title_only">Search posts only in title</string>
<string name="settings_search_posts_title_only_subtitle">Filter post search results based on title match</string>
<string name="settings_search_posts_restrict_local_user_search">Restrict local user search</string>
<string name="settings_search_posts_restrict_local_user_search_subtitle">Filter local user search results on current instance</string>
<string name="settings_section_account">Account settings</string>
<string name="settings_section_appearance">Look and feel</string>
<string name="settings_section_debug">Debug</string>

View File

@ -396,7 +396,10 @@ import raccoonforlemmy.shared.generated.resources.settings_post_layout_full
import raccoonforlemmy.shared.generated.resources.settings_prefer_user_nicknames
import raccoonforlemmy.shared.generated.resources.settings_reply_color
import raccoonforlemmy.shared.generated.resources.settings_save_color
import raccoonforlemmy.shared.generated.resources.settings_search_posts_restrict_local_user_search
import raccoonforlemmy.shared.generated.resources.settings_search_posts_restrict_local_user_search_subtitle
import raccoonforlemmy.shared.generated.resources.settings_search_posts_title_only
import raccoonforlemmy.shared.generated.resources.settings_search_posts_title_only_subtitle
import raccoonforlemmy.shared.generated.resources.settings_section_account
import raccoonforlemmy.shared.generated.resources.settings_section_appearance
import raccoonforlemmy.shared.generated.resources.settings_section_debug
@ -1253,6 +1256,12 @@ internal class SharedStrings : Strings {
@Composable get() = stringResource(Res.string.settings_save_color)
override val settingsSearchPostsTitleOnly: String
@Composable get() = stringResource(Res.string.settings_search_posts_title_only)
override val settingsSearchPostsTitleOnlySubtitle: String
@Composable get() = stringResource(Res.string.settings_search_posts_title_only_subtitle)
override val settingsSearchRestrictLocalUserSearch: String
@Composable get() = stringResource(Res.string.settings_search_posts_restrict_local_user_search)
override val settingsSearchRestrictLocalUserSearchSubtitle: String
@Composable get() = stringResource(Res.string.settings_search_posts_restrict_local_user_search_subtitle)
override val settingsSectionAccount: String
@Composable get() = stringResource(Res.string.settings_section_account)
override val settingsSectionAppearance: String

View File

@ -357,13 +357,15 @@ class ExploreViewModel(
}
private suspend fun refresh(initial: Boolean = false) {
val currentSettings = settingsRepository.currentSettings.value
paginationManager.reset(
ExplorePaginationSpecification(
listingType = uiState.value.listingType,
sortType = uiState.value.sortType,
query = uiState.value.searchText,
includeNsfw = settingsRepository.currentSettings.value.includeNsfw,
searchPostTitleOnly = settingsRepository.currentSettings.value.searchPostTitleOnly,
includeNsfw = currentSettings.includeNsfw,
searchPostTitleOnly = currentSettings.searchPostTitleOnly,
restrictLocalUserSearch = currentSettings.restrictLocalUserSearch,
otherInstance = otherInstance,
resultType = uiState.value.resultType,
),