feat: allow blocking tags for a specific amount of time
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user