- 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).
This commit is contained in:
davotoula
2026-05-07 13:34:12 +02:00
parent 214a35d620
commit 0bbbdc2f65
11 changed files with 622 additions and 57 deletions
@@ -40,6 +40,8 @@ data class ScheduledPost(
val lastAttemptAtSec: Long? = null, val lastAttemptAtSec: Long? = null,
val attemptCount: Int = 0, val attemptCount: Int = 0,
val lastError: String? = null, val lastError: String? = null,
// Set when the row enters a terminal state (SENT/CANCELLED). Drives retention.
val terminatedAtSec: Long? = null,
) )
data class ScheduledPostFile( data class ScheduledPostFile(
@@ -33,6 +33,7 @@ import java.io.File
class ScheduledPostStore( class ScheduledPostStore(
private val storageFile: File, private val storageFile: File,
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
) { ) {
private val mapper = private val mapper =
jacksonObjectMapper() jacksonObjectMapper()
@@ -57,7 +58,8 @@ class ScheduledPostStore(
suspend fun cancel(id: String): Boolean = suspend fun cancel(id: String): Boolean =
mutex.withLock { mutex.withLock {
ensureLoaded() 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() if (updated) persist()
updated updated
} }
@@ -104,7 +106,8 @@ class ScheduledPostStore(
suspend fun markSent(id: String) = suspend fun markSent(id: String) =
mutex.withLock { mutex.withLock {
ensureLoaded() 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( suspend fun markFailed(
@@ -135,12 +138,28 @@ class ScheduledPostStore(
publishAtSec = nowSec, publishAtSec = nowSec,
status = ScheduledPostStatus.PENDING, status = ScheduledPostStatus.PENDING,
lastError = null, lastError = null,
terminatedAtSec = null,
) )
} }
if (updated) persist() if (updated) persist()
updated 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 * 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 * loaded at fire time, so we should retry on the next cycle rather than
@@ -187,7 +206,28 @@ class ScheduledPostStore(
mutableListOf() mutableListOf()
} }
loaded = true loaded = true
val purged = purgeStale(nowSec())
_flow.value = posts.toList() _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 { companion object {
private const val TAG = "ScheduledPostStore" private const val TAG = "ScheduledPostStore"
const val FILE_NAME = "scheduled_posts.json" 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
} }
} }
@@ -46,11 +46,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo 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.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.note.toShortDisplay
@@ -261,16 +264,55 @@ private fun LogoutButton(
accountSessionManager: AccountSessionManager, accountSessionManager: AccountSessionManager,
) { ) {
var logoutDialog by remember { mutableStateOf(false) } var logoutDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
if (logoutDialog) { 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( AlertDialog(
title = { Text(text = stringRes(R.string.log_out)) }, 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 }, onDismissRequest = { logoutDialog = false },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { 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 logoutDialog = false
accountSessionManager.logOff(acc) 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)) Text(text = stringRes(R.string.log_out))
@@ -604,7 +604,84 @@ fun CatalogSection(
ids.forEach { id -> ids.forEach { id ->
NavBarCatalog[id]?.let { def -> NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground 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())
} }
} }
} }
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.note.creators.scheduling package com.vitorpamplona.amethyst.ui.note.creators.scheduling
import android.text.format.DateFormat 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker 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.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.temporal.TemporalAdjusters
/** /**
* Two-stage date + time picker for scheduling a post for future publication. * Two-stage date + time picker for scheduling a post for future publication.
@@ -144,6 +152,8 @@ fun ScheduleAtPicker(
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts) ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
} }
PresetChips(onPick = onChanged)
OutlinedCard( OutlinedCard(
onClick = { showDatePicker = true }, onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(), 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, * 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 * returns the boundary itself. Edge case: if rounding yields a moment in the past
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account 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.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray 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.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser 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.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -140,8 +141,11 @@ class AccountSessionManager(
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
) )
} else if (key.startsWith("nsec")) { } else if (key.startsWith("nsec")) {
val privHex =
decodePrivateKeyAsHexOrNull(key)
?: throw Exception("Invalid nsec key")
AccountSettings( AccountSettings(
keyPair = KeyPair(privKey = key.bechToBytes()), keyPair = KeyPair(privKey = privHex.hexToByteArray()),
transientAccount = transientAccount, transientAccount = transientAccount,
) )
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
@@ -356,6 +360,11 @@ class AccountSessionManager(
fun logOff(accountInfo: AccountInfo) { fun logOff(accountInfo: AccountInfo) {
scope.launch(Dispatchers.IO) { 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()) { if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the // Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't // current account so the audio-room activity can't
@@ -364,12 +373,14 @@ class AccountSessionManager(
.clear() .clear()
// log off and relogin with the 0 account // log off and relogin with the 0 account
localPreferences.deleteAccount(accountInfo) localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
loginWithDefaultAccount() loginWithDefaultAccount()
} else { } else {
// delete without switching logins // delete without switching logins
localPreferences.deleteAccount(accountInfo) localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
} }
} }
} }
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -48,12 +49,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav 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.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -599,12 +605,63 @@ private fun NewPostScreenBody(
onDismiss = postViewModel::dismissAiResult, 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 @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() val scrollState = rememberScrollState()
Row( Row(
modifier = modifier =
@@ -681,15 +738,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
postViewModel.toggleExpirationDate() postViewModel.toggleExpirationDate()
} }
ScheduleAtButton(postViewModel.scheduledForSec != null) { ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
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)
}
}
AddGeoHashButton(postViewModel.wantsToAddGeoHash) { AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -40,11 +42,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event 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 @Composable
fun ScheduledPostsScreen( fun ScheduledPostsScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -82,10 +92,19 @@ fun ScheduledPostsScreen(
ScheduledPostsViewModel.create(accountPubkey) ScheduledPostsViewModel.create(accountPubkey)
} }
val posts by viewModel.posts.collectAsStateWithLifecycle() val posts by viewModel.posts.collectAsStateWithLifecycle()
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var pendingPublishId by remember { mutableStateOf<String?>(null) } var pendingPublishId by remember { mutableStateOf<String?>(null) }
var pendingCancelId by remember { mutableStateOf<String?>(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( Scaffold(
topBar = { topBar = {
@@ -102,15 +121,25 @@ fun ScheduledPostsScreen(
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(12.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(posts, key = { it.id }) { post -> groups.forEach { group ->
ScheduledPostRow( stickyHeader(key = "header-${group.day}") {
post = post, DayHeader(group.day, context)
onPublishNow = { pendingPublishId = post.id }, }
onCancel = { pendingCancelId = post.id }, 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 }, 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 @Composable
private fun ScheduledPostRow( private fun ScheduledPostRow(
post: ScheduledPost, post: ScheduledPost,
nowSec: Long,
onPublishNow: () -> Unit, onPublishNow: () -> Unit,
onCancel: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val preview = remember(post) { extractPreview(post) } val preview = remember(post) { extractPreview(post) }
@@ -168,7 +183,7 @@ private fun ScheduledPostRow(
) { ) {
StatusChip(post.status) StatusChip(post.status)
Text( Text(
text = formatPublishMoment(post.publishAtSec, context), text = formatAtTime(post.publishAtSec, nowSec, context),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -196,14 +211,6 @@ private fun ScheduledPostRow(
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(), 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) { IconButton(onClick = onPublishNow) {
Icon( Icon(
symbol = MaterialSymbols.AutoMirrored.Send, 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 @Composable
private fun StatusChip(status: ScheduledPostStatus) { private fun StatusChip(status: ScheduledPostStatus) {
val (labelRes, tint) = val (labelRes, tint) =
@@ -302,14 +328,41 @@ private fun extractPreview(post: ScheduledPost): String =
.trim() .trim()
}.getOrDefault("") }.getOrDefault("")
private fun formatPublishMoment( private fun formatAtTime(
publishAtSec: Long, publishAtSec: Long,
nowSec: Long,
context: android.content.Context, context: android.content.Context,
): String { ): String {
val nowSec = System.currentTimeMillis() / 1000 val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val absolute = timeFormat.format(Date(publishAtSec * 1000))
return if (publishAtSec > nowSec) { 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 { } 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(),
),
)
}
} }
} }
@@ -32,6 +32,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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<ScheduledPost>,
)
/** /**
* Drives the "Scheduled posts" screen for a single account. Filters the global * Drives the "Scheduled posts" screen for a single account. Filters the global
@@ -62,6 +71,24 @@ class ScheduledPostsViewModel(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* Posts grouped by local-day, sorted ascending. The UI uses this as the
* source for sticky-header sections.
*/
val groupedPosts: StateFlow<List<ScheduledPostDayGroup>> =
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) { fun cancel(id: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
store.cancel(id) store.cancel(id)
+14
View File
@@ -442,6 +442,19 @@
<string name="schedule_post_warning_title">Always-on notifications disabled</string> <string name="schedule_post_warning_title">Always-on notifications disabled</string>
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string> <string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_warning_multi">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.</string> <string name="schedule_post_warning_multi">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.</string>
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
<string name="scheduled_posts_day_today">Today</string>
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
<string name="scheduled_posts_logout_toast">Logged out · %1$d scheduled post(s) deleted</string>
<string name="scheduled_posts_send_now_title">Send now?</string> <string name="scheduled_posts_send_now_title">Send now?</string>
<string name="scheduled_posts_send_now_message">This post will publish to relays immediately. The original schedule will be discarded.</string> <string name="scheduled_posts_send_now_message">This post will publish to relays immediately. The original schedule will be discarded.</string>
<string name="scheduled_posts_send_now_confirm">Send</string> <string name="scheduled_posts_send_now_confirm">Send</string>
@@ -458,6 +471,7 @@
<string name="scheduled_posts_status_failed">Failed</string> <string name="scheduled_posts_status_failed">Failed</string>
<string name="scheduled_posts_status_sent">Sent</string> <string name="scheduled_posts_status_sent">Sent</string>
<string name="scheduled_posts_status_cancelled">Cancelled</string> <string name="scheduled_posts_status_cancelled">Cancelled</string>
<string name="scheduled_posts_logout_warning">You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them.</string>
<string name="polls">Polls</string> <string name="polls">Polls</string>
<string name="open_polls">Open</string> <string name="open_polls">Open</string>
<string name="closed_polls">Closed</string> <string name="closed_polls">Closed</string>
@@ -46,6 +46,8 @@ class ScheduledPostStoreTest {
private fun newStore() = ScheduledPostStore(file) private fun newStore() = ScheduledPostStore(file)
private fun newStore(now: () -> Long) = ScheduledPostStore(file, now)
private fun samplePost( private fun samplePost(
id: String = "id-1", id: String = "id-1",
publishAtSec: Long = 1_000, publishAtSec: Long = 1_000,
@@ -325,6 +327,196 @@ class ScheduledPostStoreTest {
assertEquals(1, store.flow.value.size) 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 @Test
fun roundtrip_preserves_all_fields() = fun roundtrip_preserves_all_fields() =
runTest { runTest {