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).
This commit is contained in:
+2
@@ -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(
|
||||||
|
|||||||
+44
-2
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -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))
|
||||||
|
|||||||
+78
-1
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -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
|
||||||
|
|||||||
+16
-5
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-11
@@ -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
|
||||||
|
|||||||
+90
-37
@@ -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(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+192
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user