Code review:
- Switch list-screen formatters from java.text.DateFormat to java.time.format.DateTimeFormatter. DateFormat / SimpleDateFormat are documented not-thread-safe; the file-scope singletons are at risk under any future multi-threaded recomposition path. DateTimeFormatter is immutable and thread-safe. - LogoutButton: when decodePublicKeyAsHexOrNull fails, bail before the Toast fires so we don't claim "Logged out" while logOff's coroutine has aborted its cleanup. Theoretical case (npub from prefs is well-formed in practice) but the previous flow was misleading on the failure path. - ScheduledPostStore.purgeStale: document the terminatedAtSec ?: lastAttemptAtSec ?: createdAtSec fallback so the legacy-row migration semantics are clear from the source. - DrawerContent: drop fully-qualified inline references, add proper imports for Amethyst, ScheduledPostStatus, and Material3 Badge. - ScheduledPostsScreen: drop the redundant `posts` collection — derive the empty-state branch from `groups` directly. Use `group.day` as the sticky-header key instead of an interpolated string. Cache the SHORT time and FULL date `DateFormat` instances at file scope (matches the pattern in TimeAgoFormatter). Pass `today` into `DayHeader` so we don't call `LocalDate.now()` per recomposition. - ScheduledPostsViewModel: drop the intermediate public `posts` StateFlow now that no consumer needs it; fold the filter+sort into the `groupedPosts` chain. - ScheduledPostStoreTest: collapse the two `newStore()` overloads into one with a defaulted `now: () -> Long` parameter.
This commit is contained in:
+4
@@ -220,6 +220,10 @@ class ScheduledPostStore(
|
||||
private fun purgeStale(now: Long): Boolean {
|
||||
val before = posts.size
|
||||
posts.removeAll { post ->
|
||||
// terminatedAtSec is the post-PR field. Legacy rows lack it; fall back to
|
||||
// lastAttemptAtSec (set at SENT) and finally createdAtSec. The fallback
|
||||
// can purge old CANCELLED rows up to 30d earlier than intended on first
|
||||
// run after upgrade — self-healing once new rows are written.
|
||||
val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec)
|
||||
when (post.status) {
|
||||
ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC
|
||||
|
||||
+3
@@ -303,6 +303,9 @@ private fun LogoutButton(
|
||||
// tap and the cleanup completing.
|
||||
val confirmedCount = unpublishedCount
|
||||
logoutDialog = false
|
||||
// Guard against a malformed npub: skip the Toast so we don't
|
||||
// claim "Logged out" when logOff's coroutine bails early.
|
||||
if (accountHex == null) return@TextButton
|
||||
accountSessionManager.logOff(acc)
|
||||
val toastMessage =
|
||||
if (confirmedCount > 0) {
|
||||
|
||||
+8
-8
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
@@ -622,17 +625,16 @@ private fun ScheduledPostsNavigationRow(
|
||||
nav: INav,
|
||||
) {
|
||||
val accountHex = accountViewModel.account.signer.pubKey
|
||||
val allPosts by com.vitorpamplona.amethyst.Amethyst
|
||||
.instance.scheduledPostStore.flow
|
||||
val allPosts by 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
|
||||
it.status == ScheduledPostStatus.PENDING ||
|
||||
it.status == ScheduledPostStatus.PUBLISHING ||
|
||||
it.status == ScheduledPostStatus.FAILED
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -680,9 +682,7 @@ private fun IconRowWithBadge(
|
||||
fontSize = Font18SP,
|
||||
)
|
||||
if (badgeCount > 0) {
|
||||
androidx.compose.material3.Badge {
|
||||
Text(badgeCount.toString())
|
||||
}
|
||||
Badge { Text(badgeCount.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-29
@@ -75,10 +75,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.DateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -91,7 +92,6 @@ fun ScheduledPostsScreen(
|
||||
viewModel(key = "scheduled-posts-$accountPubkey") {
|
||||
ScheduledPostsViewModel.create(accountPubkey)
|
||||
}
|
||||
val posts by viewModel.posts.collectAsStateWithLifecycle()
|
||||
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -116,17 +116,18 @@ fun ScheduledPostsScreen(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (posts.isEmpty()) {
|
||||
if (groups.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
stickyHeader(key = "header-${group.day}") {
|
||||
DayHeader(group.day, context)
|
||||
stickyHeader(key = group.day) {
|
||||
DayHeader(group.day, today, context)
|
||||
}
|
||||
items(group.posts, key = { it.id }) { post ->
|
||||
SwipeToDeleteWithConfirmation(
|
||||
@@ -227,6 +228,7 @@ private fun ScheduledPostRow(
|
||||
@Composable
|
||||
private fun DayHeader(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
Surface(
|
||||
@@ -234,7 +236,7 @@ private fun DayHeader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = formatDayHeader(day, context),
|
||||
text = formatDayHeader(day, today, context),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -328,13 +330,20 @@ private fun extractPreview(post: ScheduledPost): String =
|
||||
.trim()
|
||||
}.getOrDefault("")
|
||||
|
||||
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
||||
|
||||
private fun formatAtTime(
|
||||
publishAtSec: Long,
|
||||
nowSec: Long,
|
||||
context: android.content.Context,
|
||||
): String {
|
||||
val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val absolute = timeFormat.format(Date(publishAtSec * 1000))
|
||||
val absolute =
|
||||
Instant
|
||||
.ofEpochSecond(publishAtSec)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalTime()
|
||||
.format(shortTimeFormatter)
|
||||
return if (publishAtSec > nowSec) {
|
||||
stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context))
|
||||
} else {
|
||||
@@ -344,25 +353,11 @@ private fun formatAtTime(
|
||||
|
||||
private fun formatDayHeader(
|
||||
day: LocalDate,
|
||||
today: 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
): String =
|
||||
when (day) {
|
||||
today -> stringRes(context, R.string.scheduled_posts_day_today)
|
||||
today.plusDays(1) -> stringRes(context, R.string.scheduled_posts_day_tomorrow)
|
||||
else -> day.format(fullDateFormatter)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-16
@@ -59,27 +59,14 @@ class ScheduledPostsViewModel(
|
||||
ScheduledPostStatus.FAILED,
|
||||
)
|
||||
|
||||
val posts: StateFlow<List<ScheduledPost>> =
|
||||
/** Posts for [accountPubkey] in active statuses, grouped by local-day, sorted ascending. */
|
||||
val groupedPosts: StateFlow<List<ScheduledPostDayGroup>> =
|
||||
store.flow
|
||||
.map { all ->
|
||||
val zone = ZoneId.systemDefault()
|
||||
all
|
||||
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
|
||||
.sortedBy { it.publishAtSec }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
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 }
|
||||
|
||||
+1
-3
@@ -44,9 +44,7 @@ class ScheduledPostStoreTest {
|
||||
file = File(temp.root, "scheduled_posts.json")
|
||||
}
|
||||
|
||||
private fun newStore() = ScheduledPostStore(file)
|
||||
|
||||
private fun newStore(now: () -> Long) = ScheduledPostStore(file, now)
|
||||
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
|
||||
|
||||
private fun samplePost(
|
||||
id: String = "id-1",
|
||||
|
||||
Reference in New Issue
Block a user