diff --git a/CHANGELOG.md b/CHANGELOG.md index 858d50a5..0413ffe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ClawApplication.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ClawApplication.kt index 6ef701d7..bb582bf1 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ClawApplication.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ClawApplication.kt @@ -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(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 { diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/work/TagExpirationCleanupWorker.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/work/TagExpirationCleanupWorker.kt new file mode 100644 index 00000000..9c73f361 --- /dev/null +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/work/TagExpirationCleanupWorker.kt @@ -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>(), + ) + @AssistedFactory + abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory +} diff --git a/common/build.gradle.kts b/common/build.gradle.kts index cb16813f..0e07aa0f 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -72,5 +72,7 @@ dependencies { runtimeOnly(libs.androidx.compose.ui.tooling) + testImplementation(libs.kotlinx.coroutines.test) + addTestDependencies(project) } diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagExpirationTestControls.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagExpirationTestControls.kt new file mode 100644 index 00000000..d3f6ff2e --- /dev/null +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagExpirationTestControls.kt @@ -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), + ) + } +} diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt index 7a20c953..aa3f9726 100644 --- a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt @@ -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) { - 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>(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> { - 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>(json) + val now = System.currentTimeMillis() + tagBlocks.filterValues { expiration -> expiration == null || expiration > now }.keys + } } - suspend fun saveTags(tags: Set) { - preferences.edit { prefs -> prefs[tagsKey] = tags } + fun getTagBlocks(): Flow> { + return preferences.data + .onStart { migrateIfNeeded() } + .map { prefs -> + val json = prefs[tagsKey] ?: return@map emptyList() + val tagMap = Json.decodeFromString>(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>(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>(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>(json) + val now = System.currentTimeMillis() + val activeTagMap = + tagMap.filterValues { expiration -> expiration == null || expiration > now } + prefs[tagsKey] = Json.encodeToString(activeTagMap) + } } } diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt index c0bbb241..36b3f72d 100644 --- a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt @@ -43,6 +43,7 @@ class TagFilterViewModel( ) : ViewModel() { val filteredTags = tagFilterRepository.getSavedTags().map(Set::toPersistentSet) + val tagBlocks = tagFilterRepository.getTagBlocks() var allTags by mutableStateOf(NetworkState.Loading) private set @@ -68,7 +69,15 @@ class TagFilterViewModel( } } - fun saveTags(tags: Set) { - 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() } } } diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt index e8a295ad..0c249c3b 100644 --- a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt @@ -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(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) +} diff --git a/common/src/test/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepositoryTest.kt b/common/src/test/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepositoryTest.kt new file mode 100644 index 00000000..21fdf9b9 --- /dev/null +++ b/common/src/test/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepositoryTest.kt @@ -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 { + 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") + } +} diff --git a/model/src/main/kotlin/dev/msfjarvis/claw/model/TagBlock.kt b/model/src/main/kotlin/dev/msfjarvis/claw/model/TagBlock.kt new file mode 100644 index 00000000..8c771e1e --- /dev/null +++ b/model/src/main/kotlin/dev/msfjarvis/claw/model/TagBlock.kt @@ -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 +}