feat: allow blocking tags for a specific amount of time

This commit is contained in:
Harsh Shandilya
2026-02-24 21:55:47 +05:30
parent 37523588e1
commit a3d8d5b4d3
10 changed files with 431 additions and 18 deletions
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Tag blocks can now be time bound
### Fixed
@@ -16,6 +16,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import dev.msfjarvis.claw.android.glance.SavedPostsWidgetReceiver
import dev.msfjarvis.claw.android.injection.AppGraph
import dev.msfjarvis.claw.android.work.SavedPostUpdaterWorker
import dev.msfjarvis.claw.android.work.TagExpirationCleanupWorker
import dev.zacsweers.metro.createGraphFactory
import dev.zacsweers.metrox.android.MetroAppComponentProviders
import dev.zacsweers.metrox.android.MetroApplication
@@ -44,6 +45,13 @@ class ClawApplication : Application(), MetroApplication {
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = postUpdateWorkRequest,
)
val tagCleanupWorkRequest =
PeriodicWorkRequestBuilder<TagExpirationCleanupWorker>(24, TimeUnit.HOURS).build()
appGraph.workManager.enqueueUniquePeriodicWork(
uniqueWorkName = "cleanupExpiredTags",
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = tagCleanupWorkRequest,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
GlobalScope.launch(appGraph.mainDispatcher) {
try {
@@ -0,0 +1,40 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dev.msfjarvis.claw.common.tags.TagFilterRepository
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
import dev.msfjarvis.claw.core.injection.WorkerKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.binding
@AssistedInject
class TagExpirationCleanupWorker(
context: Context,
@Assisted params: WorkerParameters,
private val tagFilterRepository: TagFilterRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
tagFilterRepository.removeExpiredTags()
return Result.success()
}
@WorkerKey(TagExpirationCleanupWorker::class)
@ContributesIntoMap(
AppScope::class,
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
)
@AssistedFactory
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<TagExpirationCleanupWorker>
}
+2
View File
@@ -72,5 +72,7 @@ dependencies {
runtimeOnly(libs.androidx.compose.ui.tooling)
testImplementation(libs.kotlinx.coroutines.test)
addTestDependencies(project)
}
@@ -0,0 +1,37 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.tags
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun TagExpirationTestControls(onTriggerCleanup: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = "Debug Controls",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
Button(onClick = onTriggerCleanup, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Force Cleanup Expired Tags Now")
}
Text(
text = "This will immediately run the cleanup worker to remove expired tag blocks",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
@@ -9,20 +9,94 @@ package dev.msfjarvis.claw.common.tags
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import dev.msfjarvis.claw.model.TagBlock
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.serialization.json.Json
@Inject
class TagFilterRepository(private val preferences: DataStore<Preferences>) {
private val tagsKey = stringSetPreferencesKey("tags")
private val legacyTagsKey = stringSetPreferencesKey("tags")
private val tagsKey = stringPreferencesKey("tags_with_expiration")
private suspend fun migrateIfNeeded() {
preferences.edit { prefs ->
val legacyTags = prefs[legacyTagsKey]
val newKeyJson = prefs[tagsKey]
if (legacyTags != null) {
val existingTagMap =
if (newKeyJson != null) {
Json.decodeFromString<Map<String, Long?>>(newKeyJson)
} else {
emptyMap()
}
val migratedTags = legacyTags.associateWith { null as Long? }
val mergedMap = existingTagMap + migratedTags
prefs[tagsKey] = Json.encodeToString(mergedMap)
prefs.remove(legacyTagsKey)
}
}
}
fun getSavedTags(): Flow<Set<String>> {
return preferences.data.map { prefs -> prefs[tagsKey] ?: emptySet() }
return preferences.data
.onStart { migrateIfNeeded() }
.map { prefs ->
val json = prefs[tagsKey] ?: return@map emptySet()
val tagBlocks = Json.decodeFromString<Map<String, Long?>>(json)
val now = System.currentTimeMillis()
tagBlocks.filterValues { expiration -> expiration == null || expiration > now }.keys
}
}
suspend fun saveTags(tags: Set<String>) {
preferences.edit { prefs -> prefs[tagsKey] = tags }
fun getTagBlocks(): Flow<List<TagBlock>> {
return preferences.data
.onStart { migrateIfNeeded() }
.map { prefs ->
val json = prefs[tagsKey] ?: return@map emptyList()
val tagMap = Json.decodeFromString<Map<String, Long?>>(json)
tagMap.map { (tag, expiration) -> TagBlock(tag, expiration) }
}
}
suspend fun saveTagBlock(tag: String, expirationMillis: Long?) {
preferences.edit { prefs ->
val json = prefs[tagsKey]
val tagMap =
if (json != null) {
Json.decodeFromString<Map<String, Long?>>(json).toMutableMap()
} else {
mutableMapOf()
}
tagMap[tag] = expirationMillis
prefs[tagsKey] = Json.encodeToString(tagMap)
}
}
suspend fun removeTagBlock(tag: String) {
preferences.edit { prefs ->
val json = prefs[tagsKey] ?: return@edit
val tagMap = Json.decodeFromString<Map<String, Long?>>(json).toMutableMap()
tagMap.remove(tag)
prefs[tagsKey] = Json.encodeToString(tagMap)
}
}
suspend fun removeExpiredTags() {
preferences.edit { prefs ->
val json = prefs[tagsKey] ?: return@edit
val tagMap = Json.decodeFromString<Map<String, Long?>>(json)
val now = System.currentTimeMillis()
val activeTagMap =
tagMap.filterValues { expiration -> expiration == null || expiration > now }
prefs[tagsKey] = Json.encodeToString(activeTagMap)
}
}
}
@@ -43,6 +43,7 @@ class TagFilterViewModel(
) : ViewModel() {
val filteredTags = tagFilterRepository.getSavedTags().map(Set<String>::toPersistentSet)
val tagBlocks = tagFilterRepository.getTagBlocks()
var allTags by mutableStateOf<NetworkState>(NetworkState.Loading)
private set
@@ -68,7 +69,15 @@ class TagFilterViewModel(
}
}
fun saveTags(tags: Set<String>) {
viewModelScope.launch { tagFilterRepository.saveTags(tags) }
fun saveTagBlock(tag: String, expirationMillis: Long?) {
viewModelScope.launch { tagFilterRepository.saveTagBlock(tag, expirationMillis) }
}
fun removeTagBlock(tag: String) {
viewModelScope.launch { tagFilterRepository.removeTagBlock(tag) }
}
fun triggerCleanupNow() {
viewModelScope.launch { tagFilterRepository.removeExpiredTags() }
}
}
@@ -6,6 +6,7 @@
*/
package dev.msfjarvis.claw.common.tags
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalFlexBoxApi
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.FlexBox
import androidx.compose.foundation.layout.FlexDirection
import androidx.compose.foundation.layout.FlexWrap
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -20,27 +22,40 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Block
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.msfjarvis.claw.common.BuildConfig
import dev.msfjarvis.claw.common.NetworkState.Error
import dev.msfjarvis.claw.common.NetworkState.Loading
import dev.msfjarvis.claw.common.NetworkState.Success
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.model.Tag
import dev.zacsweers.metrox.viewmodel.metroViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalFlexBoxApi::class)
@OptIn(ExperimentalFlexBoxApi::class, ExperimentalMaterial3Api::class)
@Composable
fun TagList(
contentPadding: PaddingValues,
@@ -49,6 +64,51 @@ fun TagList(
) {
val allTagsState = viewModel.allTags
val filteredTags by viewModel.filteredTags.collectAsStateWithLifecycle(emptySet())
val tagBlocks by viewModel.tagBlocks.collectAsStateWithLifecycle(emptyList())
var showDatePicker by remember { mutableStateOf(false) }
var selectedTagForDatePicker by remember { mutableStateOf<String?>(null) }
selectedTagForDatePicker?.let { tagName ->
if (showDatePicker) {
val tomorrow = Instant.now().plusMillis(24 * 60 * 60 * 1000)
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = tomorrow.toEpochMilli())
DatePickerDialog(
onDismissRequest = {
showDatePicker = false
selectedTagForDatePicker = null
},
confirmButton = {
TextButton(
onClick = {
val selectedDateMillis = datePickerState.selectedDateMillis
if (selectedDateMillis != null) {
viewModel.saveTagBlock(tagName, selectedDateMillis)
}
showDatePicker = false
selectedTagForDatePicker = null
}
) {
Text("Block Until")
}
},
dismissButton = {
TextButton(
onClick = {
viewModel.saveTagBlock(tagName, null)
showDatePicker = false
selectedTagForDatePicker = null
}
) {
Text("Block Forever")
}
},
) {
DatePicker(state = datePickerState)
}
}
}
when (allTagsState) {
is Loading -> {
@@ -66,6 +126,9 @@ fun TagList(
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(contentPadding)
) {
if (BuildConfig.DEBUG) {
TagExpirationTestControls(onTriggerCleanup = { viewModel.triggerCleanupNow() })
}
Text(
text = "Posts with selected tags will be filtered out of hottest/newest/search feeds",
style = MaterialTheme.typography.bodyMedium,
@@ -84,14 +147,20 @@ fun TagList(
.sortedBy { it.tag }
.forEach { tag ->
val isSelected = filteredTags.contains(tag.tag)
val tagBlock = tagBlocks.find { it.tag == tag.tag }
FilterChip(
selected = isSelected,
leadingIcon =
if (isSelected) {
{
Icon(
imageVector = Icons.Filled.Done,
contentDescription = "Done icon",
imageVector =
if (tagBlock?.isPermanent == false) Icons.Filled.AccessTime
else Icons.Filled.Block,
contentDescription =
if (tagBlock?.isPermanent == false) "Temporary block"
else "Permanent block",
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
@@ -99,15 +168,30 @@ fun TagList(
null
},
onClick = {
val updatedTags =
if (isSelected) {
filteredTags - tag.tag
} else {
filteredTags + tag.tag
}
viewModel.saveTags(updatedTags.toSet())
if (isSelected) {
viewModel.removeTagBlock(tag.tag)
} else {
selectedTagForDatePicker = tag.tag
showDatePicker = true
}
},
label = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(tag.tag)
if (isSelected && tagBlock != null) {
tagBlock.expirationMillis?.let { expirationMillis ->
Text(
text = "${formatDate(expirationMillis)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
label = { Text(tag.tag) },
)
}
}
@@ -115,3 +199,9 @@ fun TagList(
}
}
}
private fun formatDate(millis: Long): String {
val instant = Instant.ofEpochMilli(millis)
val formatter = DateTimeFormatter.ofPattern("MMM dd").withZone(ZoneId.systemDefault())
return formatter.format(instant)
}
@@ -0,0 +1,125 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.tags
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.common.truth.Truth.assertThat
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
@OptIn(ExperimentalCoroutinesApi::class)
class TagFilterRepositoryTest {
@TempDir lateinit var tempDir: File
@BeforeEach
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
private fun createTestDataStore(): DataStore<Preferences> {
return PreferenceDataStoreFactory.create { File(tempDir, "test_preferences.preferences_pb") }
}
@Test
fun `migrates legacy tags to new format with null expiration`() = runTest {
val dataStore = createTestDataStore()
val legacyTagsKey = stringSetPreferencesKey("tags")
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("android", "kotlin", "java") }
val repository = TagFilterRepository(dataStore)
val tags = repository.getSavedTags().first()
assertThat(tags).containsExactly("android", "kotlin", "java")
val dataStoreSnapshot = dataStore.data.first()
assertThat(dataStoreSnapshot[legacyTagsKey]).isNull()
}
@Test
fun `migration preserves tags as permanent blocks`() = runTest {
val dataStore = createTestDataStore()
val legacyTagsKey = stringSetPreferencesKey("tags")
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("security", "privacy") }
val repository = TagFilterRepository(dataStore)
val tagBlocks = repository.getTagBlocks().first()
assertThat(tagBlocks).hasSize(2)
assertThat(tagBlocks.all { it.isPermanent }).isTrue()
assertThat(tagBlocks.map { it.tag }).containsExactly("security", "privacy")
}
@Test
fun `merges legacy tags with existing new format tags`() = runTest {
val dataStore = createTestDataStore()
val repository = TagFilterRepository(dataStore)
val expirationTime = System.currentTimeMillis() + 86400000
repository.saveTagBlock("rust", expirationTime)
val legacyTagsKey = stringSetPreferencesKey("tags")
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("python", "javascript") }
val tags = repository.getSavedTags().first()
assertThat(tags).containsExactly("rust", "python", "javascript")
val dataStoreSnapshot = dataStore.data.first()
assertThat(dataStoreSnapshot[legacyTagsKey]).isNull()
}
@Test
fun `filters out expired tags from getSavedTags`() = runTest {
val dataStore = createTestDataStore()
val repository = TagFilterRepository(dataStore)
val pastTime = System.currentTimeMillis() - 1000
val futureTime = System.currentTimeMillis() + 86400000
repository.saveTagBlock("expired-tag", pastTime)
repository.saveTagBlock("active-tag", futureTime)
repository.saveTagBlock("permanent-tag", null)
val tags = repository.getSavedTags().first()
assertThat(tags).containsExactly("active-tag", "permanent-tag")
assertThat(tags).doesNotContain("expired-tag")
}
@Test
fun `removeExpiredTags only removes expired blocks`() = runTest {
val dataStore = createTestDataStore()
val repository = TagFilterRepository(dataStore)
val pastTime = System.currentTimeMillis() - 1000
val futureTime = System.currentTimeMillis() + 86400000
repository.saveTagBlock("expired1", pastTime)
repository.saveTagBlock("expired2", pastTime - 5000)
repository.saveTagBlock("future", futureTime)
repository.saveTagBlock("permanent", null)
repository.removeExpiredTags()
val remaining = repository.getTagBlocks().first()
assertThat(remaining).hasSize(2)
assertThat(remaining.map { it.tag }).containsExactly("future", "permanent")
}
}
@@ -0,0 +1,25 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.model
import dev.drewhamilton.poko.Poko
/**
* Represents a blocked tag with an optional expiration date.
*
* @property tag The tag name to block
* @property expirationMillis The expiration timestamp in milliseconds since epoch, or null for
* permanent blocks
*/
@Poko
class TagBlock(val tag: String, val expirationMillis: Long?) {
val isPermanent: Boolean
get() = expirationMillis == null
val isExpired: Boolean
get() = expirationMillis?.let { it < System.currentTimeMillis() } ?: false
}