diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c8921d7e7..a1eedaac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen @@ -217,6 +218,7 @@ fun AppNavigation( composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } + composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index a040c45cf..429d87a58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -140,6 +140,8 @@ sealed class Route { @Serializable object RequestToVanish : Route() + @Serializable object VanishEvents : Route() + @Serializable object EditMediaServers : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt new file mode 100644 index 000000000..67ad7b894 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material.icons.outlined.PublicOff +import androidx.compose.material.icons.outlined.Science +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun VanishEventsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: VanishEventsViewModel = viewModel() + viewModel.account = accountViewModel.account + + val vanishEvents by viewModel.vanishEvents.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val complianceResults by viewModel.complianceResults.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.load() + } + + Scaffold( + topBar = { + TopBarWithBackButton( + stringRes(id = R.string.vanish_events_title), + nav::popBack, + ) + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } else if (vanishEvents.isEmpty()) { + Column( + modifier = Modifier.align(Alignment.Center).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Outlined.PublicOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.vanish_events_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringRes(R.string.vanish_events_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + item { + Text( + text = stringRes(R.string.vanish_events_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + items(vanishEvents, key = { it.event.id }) { item -> + VanishEventCard( + item = item, + complianceResults = complianceResults, + onTestCompliance = { relayUrl, date -> + viewModel.testCompliance(relayUrl, date) + }, + ) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } + } + } +} + +@Composable +private fun VanishEventCard( + item: VanishEventItem, + complianceResults: Map, + onTestCompliance: (String, Long) -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.vanish_date_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatTimestamp(item.event.createdAt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + + if (item.isAllRelays) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringRes(R.string.vanish_all_relays), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringRes(R.string.vanish_all_relays_compliance_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringRes(R.string.vanish_target_relays_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + item.relays.forEach { relayUrl -> + RelayComplianceRow( + relayUrl = relayUrl, + vanishDate = item.event.createdAt, + status = complianceResults["$relayUrl:${item.event.createdAt}"] ?: ComplianceStatus.UNTESTED, + onTest = { onTestCompliance(relayUrl, item.event.createdAt) }, + ) + } + } + + if (item.event.content.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(thickness = DividerThickness) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.event.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RelayComplianceRow( + relayUrl: String, + vanishDate: Long, + status: ComplianceStatus, + onTest: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + relayUrl + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + when (status) { + ComplianceStatus.UNTESTED -> { + FilledTonalButton( + onClick = onTest, + modifier = Modifier.height(32.dp), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Icon( + Icons.Outlined.Science, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_test_button), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + ComplianceStatus.TESTING -> { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + ComplianceStatus.COMPLIANT -> { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = stringRes(R.string.vanish_compliant), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_compliant), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + ComplianceStatus.NON_COMPLIANT -> { + Icon( + Icons.Outlined.Error, + contentDescription = stringRes(R.string.vanish_non_compliant), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_non_compliant), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + + ComplianceStatus.ERROR -> { + Icon( + Icons.Outlined.Error, + contentDescription = stringRes(R.string.vanish_test_error), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_test_error), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun formatTimestamp(epochSeconds: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) + return sdf.format(Date(epochSeconds * 1000)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsViewModel.kt new file mode 100644 index 000000000..af40bba45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsViewModel.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.RelayTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +@Stable +data class VanishEventItem( + val event: RequestToVanishEvent, + val relays: List, + val isAllRelays: Boolean, + val sourceRelay: NormalizedRelayUrl, +) + +enum class ComplianceStatus { + UNTESTED, + TESTING, + COMPLIANT, + NON_COMPLIANT, + ERROR, +} + +class VanishEventsViewModel : ViewModel() { + lateinit var account: Account + + private val _vanishEvents = MutableStateFlow>(emptyList()) + val vanishEvents = _vanishEvents.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _complianceResults = MutableStateFlow>(emptyMap()) + val complianceResults = _complianceResults.asStateFlow() + + fun load() { + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _vanishEvents.value = emptyList() + _complianceResults.value = emptyMap() + + val connectedRelays = account.client.connectedRelaysFlow().value + if (connectedRelays.isEmpty()) { + _isLoading.value = false + return@launch + } + + val filter = + Filter( + kinds = listOf(RequestToVanishEvent.KIND), + authors = listOf(account.pubKey), + limit = 100, + ) + + val filtersPerRelay = connectedRelays.associateWith { listOf(filter) } + val events = mutableListOf() + val seenIds = mutableSetOf() + val subId = newSubId() + val doneChannel = Channel(Channel.CONFLATED) + var eoseCount = 0 + val totalRelays = connectedRelays.size + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is RequestToVanishEvent && seenIds.add(event.id)) { + val relayTags = event.vanishFromRelays() + val isAll = relayTags.contains(RelayTag.EVERYWHERE) + events.add( + VanishEventItem( + event = event, + relays = relayTags, + isAllRelays = isAll, + sourceRelay = relay, + ), + ) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eoseCount++ + if (eoseCount >= totalRelays) { + doneChannel.trySend(Unit) + } + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eoseCount++ + if (eoseCount >= totalRelays) { + doneChannel.trySend(Unit) + } + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + eoseCount++ + if (eoseCount >= totalRelays) { + doneChannel.trySend(Unit) + } + } + } + + try { + account.client.openReqSubscription(subId, filtersPerRelay, listener) + + withTimeoutOrNull(15_000) { + doneChannel.receive() + } + } finally { + account.client.close(subId) + doneChannel.close() + } + + _vanishEvents.value = events.sortedByDescending { it.event.createdAt } + _isLoading.value = false + } + } + + fun testCompliance( + relayUrl: String, + vanishDate: Long, + ) { + val key = "$relayUrl:$vanishDate" + _complianceResults.value = _complianceResults.value + (key to ComplianceStatus.TESTING) + + viewModelScope.launch(Dispatchers.IO) { + try { + val foundEvent = + account.client.downloadFirstEvent( + relay = relayUrl, + filter = + Filter( + authors = listOf(account.pubKey), + until = vanishDate, + limit = 1, + ), + ) + + _complianceResults.value = + _complianceResults.value + + ( + key to + if (foundEvent != null) { + ComplianceStatus.NON_COMPLIANT + } else { + ComplianceStatus.COMPLIANT + } + ) + } catch (_: Exception) { + _complianceResults.value = _complianceResults.value + (key to ComplianceStatus.ERROR) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 897488463..8db53f82d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security @@ -144,6 +145,13 @@ fun AllSettingsScreen( tint = MaterialTheme.colorScheme.error, onClick = { nav.nav(Route.RequestToVanish) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.vanish_history, + icon = Icons.Outlined.History, + tint = tint, + onClick = { nav.nav(Route.VanishEvents) }, + ) } HorizontalDivider(thickness = 4.dp) SettingsSectionHeader(R.string.app_settings) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b6a841c90..55a0cc83a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1973,4 +1973,17 @@ Vanish request sent Select date Select time + + Vanish History + Refresh + These are your past Request to Vanish events found on connected relays. Relays tagged in these events should not hold any of your data from before the event date. + No vanish requests found + You haven\'t sent any Request to Vanish events yet. + Target Relays + This request targets all relays. Use the Request to Vanish screen to test specific relays for compliance. + Test + Compliant + Non-compliant + Error + Vanish History