From 0bbbdc2f65ecc54d8786c7b3265467d6f67ca5b7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 13:34:12 +0200 Subject: [PATCH] Polish: - Delete scheduled posts on logout - presets, list grouping, drawer badge, always-on prompt, logout toast - bech hardening, retention, live countdown - Replace `bechToBytes()` chains at three account sites with the null-safe `decodePrivateKeyAsHexOrNull` / `decodePublicKeyAsHexOrNull`. A malformed npub no longer crashes the LogoutButton composable tree or leaves the logoff path half-cleaned (deleted account row but cache + scheduled posts still in memory). --- .../service/scheduledposts/ScheduledPost.kt | 2 + .../scheduledposts/ScheduledPostStore.kt | 46 ++++- .../drawer/AccountSwitchBottomSheet.kt | 44 +++- .../ui/navigation/drawer/DrawerContent.kt | 79 ++++++- .../creators/scheduling/ScheduleAtPicker.kt | 56 +++++ .../ui/screen/AccountSessionManager.kt | 21 +- .../loggedIn/home/ShortNotePostScreen.kt | 71 ++++++- .../scheduledposts/ScheduledPostsScreen.kt | 127 ++++++++---- .../scheduledposts/ScheduledPostsViewModel.kt | 27 +++ amethyst/src/main/res/values/strings.xml | 14 ++ .../scheduledposts/ScheduledPostStoreTest.kt | 192 ++++++++++++++++++ 11 files changed, 622 insertions(+), 57 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt index 91ba77a9d9..320782d470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt @@ -40,6 +40,8 @@ data class ScheduledPost( val lastAttemptAtSec: Long? = null, val attemptCount: Int = 0, val lastError: String? = null, + // Set when the row enters a terminal state (SENT/CANCELLED). Drives retention. + val terminatedAtSec: Long? = null, ) data class ScheduledPostFile( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt index 82a8d63c9f..285286826f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -33,6 +33,7 @@ import java.io.File class ScheduledPostStore( private val storageFile: File, + private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, ) { private val mapper = jacksonObjectMapper() @@ -57,7 +58,8 @@ class ScheduledPostStore( suspend fun cancel(id: String): Boolean = mutex.withLock { ensureLoaded() - val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED) } + val now = nowSec() + val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) } if (updated) persist() updated } @@ -104,7 +106,8 @@ class ScheduledPostStore( suspend fun markSent(id: String) = mutex.withLock { ensureLoaded() - if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null) }) persist() + val now = nowSec() + if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist() } suspend fun markFailed( @@ -135,12 +138,28 @@ class ScheduledPostStore( publishAtSec = nowSec, status = ScheduledPostStatus.PENDING, lastError = null, + terminatedAtSec = null, ) } if (updated) persist() updated } + /** + * Remove every row owned by [accountPubkey]. Used when the user deletes + * an account — the account's signed events should not linger. Returns the + * number of rows removed; persists once if any rows matched. + */ + suspend fun removeForAccount(accountPubkey: String): Int = + mutex.withLock { + ensureLoaded() + val before = posts.size + val removed = posts.removeAll { it.accountPubkey == accountPubkey } + val count = before - posts.size + if (removed) persist() + count + } + /** * Revert a PUBLISHING claim back to PENDING (e.g. when the account is not * loaded at fire time, so we should retry on the next cycle rather than @@ -187,7 +206,28 @@ class ScheduledPostStore( mutableListOf() } loaded = true + val purged = purgeStale(nowSec()) _flow.value = posts.toList() + if (purged) persist() + } + + /** + * Drop SENT rows older than [SENT_RETENTION_SEC] and CANCELLED rows older + * than [CANCELLED_RETENTION_SEC]. Returns true if any row was removed. + * FAILED rows are kept indefinitely so the user can still see and retry them; + * PENDING / PUBLISHING rows are never purged. + */ + private fun purgeStale(now: Long): Boolean { + val before = posts.size + posts.removeAll { post -> + val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec) + when (post.status) { + ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC + ScheduledPostStatus.CANCELLED -> age > CANCELLED_RETENTION_SEC + else -> false + } + } + return posts.size < before } /** @@ -224,5 +264,7 @@ class ScheduledPostStore( companion object { private const val TAG = "ScheduledPostStore" const val FILE_NAME = "scheduled_posts.json" + private const val SENT_RETENTION_SEC = 7L * 24 * 3600 + private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 3560b2f1f2..d0b92f582d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -46,11 +46,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext 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 com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.toShortDisplay @@ -261,16 +264,55 @@ private fun LogoutButton( accountSessionManager: AccountSessionManager, ) { var logoutDialog by remember { mutableStateOf(false) } + val context = LocalContext.current if (logoutDialog) { + val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) } + val allPosts by Amethyst.instance.scheduledPostStore.flow + .collectAsStateWithLifecycle() + val unpublishedCount by remember(accountHex) { + derivedStateOf { + if (accountHex == null) { + 0 + } else { + allPosts.count { + it.accountPubkey == accountHex && + ( + it.status == ScheduledPostStatus.PENDING || + it.status == ScheduledPostStatus.PUBLISHING || + it.status == ScheduledPostStatus.FAILED + ) + } + } + } + } AlertDialog( title = { Text(text = stringRes(R.string.log_out)) }, - text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) }, + text = { + if (unpublishedCount > 0) { + Text(text = stringRes(R.string.scheduled_posts_logout_warning, unpublishedCount)) + } else { + Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) + } + }, onDismissRequest = { logoutDialog = false }, confirmButton = { TextButton( onClick = { + // Snapshot the count *now* so the user-facing Toast matches what + // the dialog displayed, even if the store mutates between this + // tap and the cleanup completing. + val confirmedCount = unpublishedCount logoutDialog = false accountSessionManager.logOff(acc) + val toastMessage = + if (confirmedCount > 0) { + stringRes(context, R.string.scheduled_posts_logout_toast, confirmedCount) + } else { + stringRes(context, R.string.scheduled_posts_logout_toast_zero) + } + android.widget.Toast + .makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT) + .show() }, ) { Text(text = stringRes(R.string.log_out)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index e208de7e21..049b2ef6e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -604,7 +604,84 @@ fun CatalogSection( ids.forEach { id -> NavBarCatalog[id]?.let { def -> val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground - CatalogNavigationRow(def, tint, accountViewModel, nav) + if (def.id == NavBarItem.SCHEDULED_POSTS) { + ScheduledPostsNavigationRow(def, tint, accountViewModel, nav) + } else { + CatalogNavigationRow(def, tint, accountViewModel, nav) + } + } + } + } +} + +@Composable +private fun ScheduledPostsNavigationRow( + def: NavBarItemDef, + tint: Color, + accountViewModel: AccountViewModel, + nav: INav, +) { + val accountHex = accountViewModel.account.signer.pubKey + val allPosts by com.vitorpamplona.amethyst.Amethyst + .instance.scheduledPostStore.flow + .collectAsStateWithLifecycle() + val pendingCount by remember(accountHex) { + derivedStateOf { + allPosts.count { + it.accountPubkey == accountHex && + ( + it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PENDING || + it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.PUBLISHING || + it.status == com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus.FAILED + ) + } + } + } + IconRowWithBadge( + title = def.labelRes, + icon = def.icon, + tint = tint, + badgeCount = pendingCount, + onClick = { + nav.closeDrawer() + nav.nav { def.resolveRoute(accountViewModel) } + }, + ) +} + +@Composable +private fun IconRowWithBadge( + title: Int, + icon: MaterialSymbol, + tint: Color, + badgeCount: Int, + onClick: () -> Unit, +) { + val titleStr = stringRes(title) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + onClickLabel = titleStr, + ).padding(vertical = 15.dp, horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = icon, + contentDescription = titleStr, + modifier = Size22ModifierWith4Padding, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = titleStr, + fontSize = Font18SP, + ) + if (badgeCount > 0) { + androidx.compose.material3.Badge { + Text(badgeCount.toString()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt index 13ac2731c3..fc90827347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.note.creators.scheduling import android.text.format.DateFormat +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -28,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AssistChip import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DatePicker @@ -62,9 +66,13 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.DayOfWeek import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime import java.time.ZoneId import java.time.ZoneOffset +import java.time.temporal.TemporalAdjusters /** * Two-stage date + time picker for scheduling a post for future publication. @@ -144,6 +152,8 @@ fun ScheduleAtPicker( ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts) } + PresetChips(onPick = onChanged) + OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), @@ -249,6 +259,52 @@ private fun ReliabilityWarning(hasMultipleAccounts: Boolean) { } } +@Composable +private fun PresetChips(onPick: (Long) -> Unit) { + val scroll = rememberScrollState() + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(scroll) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetInOneHour())) }, + label = { Text(stringRes(R.string.schedule_post_preset_in_one_hour)) }, + ) + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetTomorrowMorning())) }, + label = { Text(stringRes(R.string.schedule_post_preset_tomorrow_morning)) }, + ) + AssistChip( + onClick = { onPick(roundUpToNextQuarterHour(presetNextMondayMorning())) }, + label = { Text(stringRes(R.string.schedule_post_preset_next_monday_morning)) }, + ) + } +} + +private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600 + +private fun presetTomorrowMorning(): Long { + val zone = ZoneId.systemDefault() + val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0)) + return tomorrow9am.atZone(zone).toEpochSecond() +} + +private fun presetNextMondayMorning(): Long { + val zone = ZoneId.systemDefault() + // Always step at least one day forward — if today is Monday, return next Monday. + val target = + LocalDate + .now(zone) + .plusDays(1) + .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)) + .atTime(LocalTime.of(9, 0)) + return target.atZone(zone).toEpochSecond() +} + /** * Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary, * returns the boundary itself. Edge case: if rounding yields a moment in the past diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index c26f6840da..e6de75c913 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.Account @@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -140,8 +141,11 @@ class AccountSessionManager( externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, ) } else if (key.startsWith("nsec")) { + val privHex = + decodePrivateKeyAsHexOrNull(key) + ?: throw Exception("Invalid nsec key") AccountSettings( - keyPair = KeyPair(privKey = key.bechToBytes()), + keyPair = KeyPair(privKey = privHex.hexToByteArray()), transientAccount = transientAccount, ) } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { @@ -356,6 +360,11 @@ class AccountSessionManager( fun logOff(accountInfo: AccountInfo) { scope.launch(Dispatchers.IO) { + val hex = decodePublicKeyAsHexOrNull(accountInfo.npub) + if (hex == null) { + Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup") + return@launch + } if (accountInfo.npub == currentAccountNPub()) { // Drop the Nest bridge ref before tearing down the // current account so the audio-room activity can't @@ -364,12 +373,14 @@ class AccountSessionManager( .clear() // log off and relogin with the 0 account localPreferences.deleteAccount(accountInfo) - accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + accountsCache.removeAccount(hex) + Amethyst.instance.scheduledPostStore.removeForAccount(hex) loginWithDefaultAccount() } else { // delete without switching logins localPreferences.deleteAccount(accountInfo) - accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + accountsCache.removeAccount(hex) + Amethyst.instance.scheduledPostStore.removeForAccount(hex) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index d8e6c211ef..2ac05e953b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton @@ -48,12 +49,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -81,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -599,12 +605,63 @@ private fun NewPostScreenBody( onDismiss = postViewModel::dismissAiResult, ) - BottomRowActions(postViewModel) + val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + var showAlwaysOnPrompt by remember { mutableStateOf(false) } + + BottomRowActions( + postViewModel = postViewModel, + onScheduleClicked = { + if (postViewModel.scheduledForSec != null) { + postViewModel.scheduledForSec = null + } else if (!alwaysOnEnabled) { + showAlwaysOnPrompt = true + } else { + postViewModel.scheduledForSec = + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + } + }, + ) + + if (showAlwaysOnPrompt) { + AlertDialog( + onDismissRequest = { showAlwaysOnPrompt = false }, + title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) }, + text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) }, + confirmButton = { + TextButton(onClick = { + showAlwaysOnPrompt = false + nav.nav(Route.Settings) + }) { + Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings)) + } + }, + dismissButton = { + TextButton(onClick = { + showAlwaysOnPrompt = false + postViewModel.scheduledForSec = + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + }) { + Text(stringRes(R.string.schedule_post_always_on_prompt_continue)) + } + }, + ) + } } } @Composable -private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { +private fun BottomRowActions( + postViewModel: ShortNotePostViewModel, + onScheduleClicked: () -> Unit = { + postViewModel.scheduledForSec = + if (postViewModel.scheduledForSec != null) { + null + } else { + roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) + } + }, +) { val scrollState = rememberScrollState() Row( modifier = @@ -681,15 +738,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.toggleExpirationDate() } - ScheduleAtButton(postViewModel.scheduledForSec != null) { - postViewModel.scheduledForSec = - if (postViewModel.scheduledForSec != null) { - null - } else { - // Default to 1 hour from now, rounded up to the next 15-min slot - roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60) - } - } + ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt index 33a2814e1c..fdfc50fb1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,11 +42,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -61,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker +import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -69,8 +74,13 @@ import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.delay +import java.text.DateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ScheduledPostsScreen( accountViewModel: AccountViewModel, @@ -82,10 +92,19 @@ fun ScheduledPostsScreen( ScheduledPostsViewModel.create(accountPubkey) } val posts by viewModel.posts.collectAsStateWithLifecycle() + val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() val context = LocalContext.current var pendingPublishId by remember { mutableStateOf(null) } - var pendingCancelId by remember { mutableStateOf(null) } + + // Tick once per minute so relative-time strings ("publishes in 2h 13m") + // refresh on a long-open list instead of being frozen at first composition. + val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) { + while (true) { + delay(60_000) + value = System.currentTimeMillis() / 1000 + } + } Scaffold( topBar = { @@ -102,15 +121,25 @@ fun ScheduledPostsScreen( } else { LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(posts, key = { it.id }) { post -> - ScheduledPostRow( - post = post, - onPublishNow = { pendingPublishId = post.id }, - onCancel = { pendingCancelId = post.id }, - ) + groups.forEach { group -> + stickyHeader(key = "header-${group.day}") { + DayHeader(group.day, context) + } + items(group.posts, key = { it.id }) { post -> + SwipeToDeleteWithConfirmation( + modifier = Modifier.fillMaxWidth().animateContentSize(), + onDelete = { viewModel.cancel(post.id) }, + ) { + ScheduledPostRow( + post = post, + nowSec = nowSec, + onPublishNow = { pendingPublishId = post.id }, + ) + } + } } } } @@ -129,27 +158,13 @@ fun ScheduledPostsScreen( onDismiss = { pendingPublishId = null }, ) } - - pendingCancelId?.let { id -> - ConfirmDialog( - title = stringRes(R.string.scheduled_posts_delete_title), - message = stringRes(R.string.scheduled_posts_delete_message), - confirmLabel = stringRes(R.string.scheduled_posts_delete_confirm), - destructive = true, - onConfirm = { - viewModel.cancel(id) - pendingCancelId = null - }, - onDismiss = { pendingCancelId = null }, - ) - } } @Composable private fun ScheduledPostRow( post: ScheduledPost, + nowSec: Long, onPublishNow: () -> Unit, - onCancel: () -> Unit, ) { val context = LocalContext.current val preview = remember(post) { extractPreview(post) } @@ -168,7 +183,7 @@ private fun ScheduledPostRow( ) { StatusChip(post.status) Text( - text = formatPublishMoment(post.publishAtSec, context), + text = formatAtTime(post.publishAtSec, nowSec, context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -196,14 +211,6 @@ private fun ScheduledPostRow( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), ) { - IconButton(onClick = onCancel) { - Icon( - symbol = MaterialSymbols.Delete, - contentDescription = stringRes(R.string.scheduled_posts_action_delete), - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.error, - ) - } IconButton(onClick = onPublishNow) { Icon( symbol = MaterialSymbols.AutoMirrored.Send, @@ -217,6 +224,25 @@ private fun ScheduledPostRow( } } +@Composable +private fun DayHeader( + day: LocalDate, + context: android.content.Context, +) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = formatDayHeader(day, context), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + @Composable private fun StatusChip(status: ScheduledPostStatus) { val (labelRes, tint) = @@ -302,14 +328,41 @@ private fun extractPreview(post: ScheduledPost): String = .trim() }.getOrDefault("") -private fun formatPublishMoment( +private fun formatAtTime( publishAtSec: Long, + nowSec: Long, context: android.content.Context, ): String { - val nowSec = System.currentTimeMillis() / 1000 + val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT) + val absolute = timeFormat.format(Date(publishAtSec * 1000)) return if (publishAtSec > nowSec) { - stringRes(context, R.string.schedule_post_publishes_in, timeAheadNoDot(publishAtSec, context)) + stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context)) } else { - stringRes(context, R.string.schedule_post_was_due, timeAgoNoDot(publishAtSec, context).trim()) + stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim()) + } +} + +private fun formatDayHeader( + day: LocalDate, + context: android.content.Context, +): String { + val today = LocalDate.now(ZoneId.systemDefault()) + return when (day) { + today -> { + stringRes(context, R.string.scheduled_posts_day_today) + } + + today.plusDays(1) -> { + stringRes(context, R.string.scheduled_posts_day_tomorrow) + } + + else -> { + val fullFormat = DateFormat.getDateInstance(DateFormat.FULL) + fullFormat.format( + Date.from( + day.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ), + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt index d9cbab00cf..e0aa58998b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -32,6 +32,15 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** A day-bucket of posts for the scheduled-posts list screen. */ +data class ScheduledPostDayGroup( + val day: LocalDate, + val posts: List, +) /** * Drives the "Scheduled posts" screen for a single account. Filters the global @@ -62,6 +71,24 @@ class ScheduledPostsViewModel( initialValue = emptyList(), ) + /** + * Posts grouped by local-day, sorted ascending. The UI uses this as the + * source for sticky-header sections. + */ + val groupedPosts: StateFlow> = + posts + .map { sorted -> + val zone = ZoneId.systemDefault() + sorted + .groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() } + .map { (day, list) -> ScheduledPostDayGroup(day, list) } + .sortedBy { it.day } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + fun cancel(id: String) { viewModelScope.launch(Dispatchers.IO) { store.cancel(id) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6a7e4264f8..e0f925a226 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -442,6 +442,19 @@ Always-on notifications disabled Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling. Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling. + In 1 hour + Tomorrow 9 AM + Next Monday 9 AM + Enable always-on notifications? + Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app. + Open settings + Continue anyway + %1$s · in %2$s + %1$s · %2$s ago + Today + Tomorrow + Logged out + Logged out · %1$d scheduled post(s) deleted Send now? This post will publish to relays immediately. The original schedule will be discarded. Send @@ -458,6 +471,7 @@ Failed Sent Cancelled + You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them. Polls Open Closed diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt index f1c70c50bd..b0947747b4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -46,6 +46,8 @@ class ScheduledPostStoreTest { private fun newStore() = ScheduledPostStore(file) + private fun newStore(now: () -> Long) = ScheduledPostStore(file, now) + private fun samplePost( id: String = "id-1", publishAtSec: Long = 1_000, @@ -325,6 +327,196 @@ class ScheduledPostStoreTest { assertEquals(1, store.flow.value.size) } + @Test + fun removeForAccount_removes_all_matching_rows_and_returns_count() = + runTest { + val store = newStore() + store.add(samplePost(id = "a1", accountPubkey = "pk-a")) + store.add(samplePost(id = "a2", accountPubkey = "pk-a")) + store.add(samplePost(id = "b1", accountPubkey = "pk-b")) + + val removed = store.removeForAccount("pk-a") + + assertEquals(2, removed) + val remaining = store.list() + assertEquals(1, remaining.size) + assertEquals("b1", remaining[0].id) + } + + @Test + fun removeForAccount_no_match_returns_zero_and_does_not_persist() = + runTest { + val store = newStore() + store.add(samplePost(accountPubkey = "pk-a")) + val bytesBefore = file.readBytes() + + val removed = store.removeForAccount("pk-other") + + assertEquals(0, removed) + assertEquals(1, store.list().size) + assertTrue("file should not be rewritten on no-op", bytesBefore.contentEquals(file.readBytes())) + } + + @Test + fun removeForAccount_persists_to_disk() = + runTest { + val store = newStore() + store.add(samplePost(id = "a1", accountPubkey = "pk-a")) + store.add(samplePost(id = "b1", accountPubkey = "pk-b")) + + store.removeForAccount("pk-a") + + val reloaded = newStore().list() + assertEquals(1, reloaded.size) + assertEquals("b1", reloaded[0].id) + } + + @Test + fun removeForAccount_purges_terminal_states_too() = + runTest { + val store = newStore() + store.add(samplePost(id = "p1", accountPubkey = "pk-a")) + store.add(samplePost(id = "p2", accountPubkey = "pk-a")) + store.markSent("p1") + store.cancel("p2") + + val removed = store.removeForAccount("pk-a") + + assertEquals(2, removed) + assertEquals(0, store.list().size) + } + + @Test + fun cancel_stamps_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x")) + + store.cancel("x") + + assertEquals(clock, store.list().single().terminatedAtSec) + } + + @Test + fun markSent_stamps_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x", publishAtSec = clock)) + store.claimDuePosts(clock) + + store.markSent("x") + + assertEquals(clock, store.list().single().terminatedAtSec) + } + + @Test + fun publishNow_clears_terminatedAtSec() = + runTest { + val clock = 1_700_000_000L + val store = newStore { clock } + store.add(samplePost(id = "x")) + store.cancel("x") // stamps terminatedAtSec + + store.publishNow("x", nowSec = clock + 5) + + assertNull(store.list().single().terminatedAtSec) + } + + @Test + fun ensureLoaded_purges_sent_older_than_seven_days() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "old-sent", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("old-sent") + } + + val eightDaysLater = createTime + 8L * 24 * 3600 + val reloaded = newStore { eightDaysLater } + assertEquals(0, reloaded.list().size) + } + + @Test + fun ensureLoaded_keeps_recent_sent() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "fresh", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("fresh") + } + + val sixDaysLater = createTime + 6L * 24 * 3600 + val reloaded = newStore { sixDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.SENT, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_purges_cancelled_older_than_thirty_days() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { + it.add(samplePost(id = "old-cancel")) + it.cancel("old-cancel") + } + + val thirtyOneDaysLater = createTime + 31L * 24 * 3600 + val reloaded = newStore { thirtyOneDaysLater } + assertEquals(0, reloaded.list().size) + } + + @Test + fun ensureLoaded_keeps_recent_cancelled() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { + it.add(samplePost(id = "recent-cancel")) + it.cancel("recent-cancel") + } + + val twentyDaysLater = createTime + 20L * 24 * 3600 + val reloaded = newStore { twentyDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.CANCELLED, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_keeps_failed_indefinitely() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "fail", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markFailed("fail", "boom") + } + + val ninetyDaysLater = createTime + 90L * 24 * 3600 + val reloaded = newStore { ninetyDaysLater } + assertEquals(1, reloaded.list().size) + assertEquals(ScheduledPostStatus.FAILED, reloaded.list().single().status) + } + + @Test + fun ensureLoaded_persists_purge_to_disk() = + runTest { + val createTime = 1_700_000_000L + newStore { createTime }.also { it.add(samplePost(id = "old", publishAtSec = createTime)) } + newStore { createTime }.also { + it.claimDuePosts(createTime) + it.markSent("old") + } + val sizeBefore = file.length() + + val eightDaysLater = createTime + 8L * 24 * 3600 + newStore { eightDaysLater }.list() // triggers ensureLoaded + purge + persist + + assertTrue("file should shrink after purge", file.length() < sizeBefore) + } + @Test fun roundtrip_preserves_all_fields() = runTest {