diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt index 7748ff1c6..7bbb64c27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -29,8 +29,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Event /** * Posts user-visible system notifications when a scheduled post completes @@ -51,7 +51,7 @@ object ScheduledPostNotifier { context = context, notId = idFor(post.id), title = stringRes(context, R.string.scheduled_posts_notification_sent_title), - body = previewOf(post), + body = extractContentPreview(post, 120), ) } @@ -61,7 +61,7 @@ object ScheduledPostNotifier { error: String?, ) { ensureChannel(context) - val snippet = previewOf(post) + val snippet = extractContentPreview(post, 120) val body = if (error.isNullOrBlank()) { snippet @@ -124,15 +124,6 @@ object ScheduledPostNotifier { nm.createNotificationChannel(channel!!) } - private fun previewOf(post: ScheduledPost): String = - runCatching { - Event - .fromJson(post.signedEventJson) - .content - .take(120) - .trim() - }.getOrDefault("") - // Distinct id per post so multiple completions don't collapse onto one row. private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index 8e8dc20d3..d740a6d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -81,6 +81,7 @@ fun SwipeToDeleteContainer( fun SwipeToDeleteWithConfirmation( modifier: Modifier = Modifier, onDelete: () -> Unit, + confirmLabelRes: Int = R.string.request_deletion, content: @Composable (RowScope.() -> Unit), ) { val scope = rememberCoroutineScope() @@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation( onCancel = { scope.launch { dismissState.reset() } }, + confirmLabelRes = confirmLabelRes, ) }, enableDismissFromEndToStart = true, @@ -156,6 +158,7 @@ fun ConfirmDeleteBackground( dismissState: SwipeToDismissBoxState, onConfirmDelete: () -> Unit, onCancel: () -> Unit, + confirmLabelRes: Int = R.string.request_deletion, ) { val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled @@ -195,12 +198,12 @@ fun ConfirmDeleteBackground( ) { Icon( MaterialSymbols.Delete, - contentDescription = stringRes(id = R.string.request_deletion), + contentDescription = stringRes(id = confirmLabelRes), tint = Color.White, ) Spacer(modifier = Modifier.padding(horizontal = 4.dp)) Text( - text = stringRes(id = R.string.request_deletion), + text = stringRes(id = confirmLabelRes), color = Color.White, style = MaterialTheme.typography.titleMedium, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt new file mode 100644 index 000000000..be44d2364 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +@file:Suppress("ktlint:standard:filename") + +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +private const val TAG = "ScheduledPostMedia" + +sealed class MediaUrl { + abstract val url: String + + data class Image( + override val url: String, + ) : MediaUrl() + + data class Video( + override val url: String, + ) : MediaUrl() +} + +/** + * Parse the signed-event JSON, run [block], and return its result. Returns null on + * any non-cancellation parse failure and logs a warning. The shared shape for + * [extractFirstMediaUrl], [extractEventId], and [extractContentPreview]. + */ +private inline fun parseSignedEvent( + post: ScheduledPost, + caller: String, + block: (Event) -> T?, +): T? = + try { + block(Event.fromJson(post.signedEventJson)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" } + null + } + +/** + * Returns the first media URL referenced via an imeta tag in the post's signed event, + * or null when there is no imeta tag or the JSON cannot be parsed. + * + * Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime) + * -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always + * carry an `m` property. + */ +fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? = + parseSignedEvent(post, "extractFirstMediaUrl") { event -> + val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null + val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty() + when { + mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url) + else -> MediaUrl.Image(firstImeta.url) + } + } + +/** + * Returns the signed event's id, or null when the JSON cannot be parsed. + */ +fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id } + +/** + * Returns the first [maxLen] chars of the signed event's content (trimmed) or + * an empty string when the JSON cannot be parsed. + */ +fun extractContentPreview( + post: ScheduledPost, + maxLen: Int, +): String = + parseSignedEvent(post, "extractContentPreview") { event -> + event.content.take(maxLen).trim() + } ?: "" + +@Composable +fun MediaThumbnail(media: MediaUrl) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = media.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(64.dp), + ) + if (media is MediaUrl.Video) { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt index 6022cc3d3..a034d52d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -20,32 +20,48 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts +import android.widget.Toast import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -53,10 +69,24 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -67,19 +97,21 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.coroutines.delay import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -93,9 +125,9 @@ fun ScheduledPostsScreen( ScheduledPostsViewModel.create(accountPubkey) } val groups by viewModel.groupedPosts.collectAsStateWithLifecycle() + val totalActive by viewModel.totalActive.collectAsStateWithLifecycle() val context = LocalContext.current - - var pendingPublishId by remember { mutableStateOf(null) } + var expandedId by remember { mutableStateOf(null) } // Tick once per minute so relative-time strings ("publishes in 2h 13m") // refresh on a long-open list instead of being frozen at first composition. @@ -106,10 +138,48 @@ fun ScheduledPostsScreen( } } + val dueSoonCount by remember { + derivedStateOf { + groups + .flatMap { it.posts } + .count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC } + } + } + + val barHeight = if (totalActive > 0) 64.dp else TopBarSize + Scaffold( topBar = { ShorterTopAppBar( - title = { Text(stringRes(R.string.scheduled_posts)) }, + expandedHeight = barHeight, + title = { + Column(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text(stringRes(R.string.scheduled_posts)) + if (totalActive > 0) { + val queuedText = + pluralStringResource( + id = R.plurals.scheduled_posts_subtitle_queued, + count = totalActive, + totalActive, + ) + val dueText = + if (dueSoonCount > 0) { + pluralStringResource( + id = R.plurals.scheduled_posts_subtitle_due_suffix, + count = dueSoonCount, + dueSoonCount, + ) + } else { + "" + } + Text( + text = queuedText + dueText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } }, @@ -117,7 +187,7 @@ fun ScheduledPostsScreen( }, ) { padding -> if (groups.isEmpty()) { - EmptyState(modifier = Modifier.padding(padding)) + EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding)) } else { val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) } LazyColumn( @@ -130,101 +200,274 @@ fun ScheduledPostsScreen( DayHeader(group.day, today, context) } items(group.posts, key = { it.id }) { post -> - SwipeToDeleteWithConfirmation( - modifier = Modifier.fillMaxWidth().animateContentSize(), - onDelete = { viewModel.cancel(post.id) }, - ) { - ScheduledPostRow( - post = post, - nowSec = nowSec, - onPublishNow = { pendingPublishId = post.id }, - ) + val isExpanded = expandedId == post.id + val rowAlpha by animateFloatAsState( + targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f, + label = "row-alpha", + ) + Box(modifier = Modifier.alpha(rowAlpha)) { + if (isExpanded) { + Column( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(), + ) { + ScheduledPostCardCollapsed( + post = post, + nowSec = nowSec, + onClick = { expandedId = null }, + ) + ScheduledPostCardExpandedPanel( + post = post, + onPublishNow = { + viewModel.publishNow(post.id) + ScheduledPostWorker.scheduleCatchUp(context) + expandedId = null + }, + onDelete = { + viewModel.cancel(post.id) + expandedId = null + }, + ) + } + } else { + SwipeToDeleteWithConfirmation( + modifier = Modifier.fillMaxWidth().animateContentSize(), + onDelete = { viewModel.cancel(post.id) }, + confirmLabelRes = R.string.scheduled_posts_action_delete, + ) { + ScheduledPostCardCollapsed( + post = post, + nowSec = nowSec, + onClick = { expandedId = post.id }, + ) + } + } } } } } } } +} - pendingPublishId?.let { id -> - ConfirmDialog( - title = stringRes(R.string.scheduled_posts_send_now_title), - message = stringRes(R.string.scheduled_posts_send_now_message), - confirmLabel = stringRes(R.string.scheduled_posts_send_now_confirm), - onConfirm = { - viewModel.publishNow(id) - ScheduledPostWorker.scheduleCatchUp(context) - pendingPublishId = null - }, - onDismiss = { pendingPublishId = null }, +private const val URGENT_THRESHOLD_SEC = 3600L + +// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse +// reads better than `tertiary` against a violet card. +private val PublishingAmber = Color(0xFFFBBF24) + +@Composable +private fun Modifier.urgentEdge(enabled: Boolean): Modifier { + if (!enabled) return this + val gradientStart = MaterialTheme.colorScheme.primary + val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + val brush = + remember(gradientStart, gradientEnd) { + Brush.verticalGradient(listOf(gradientStart, gradientEnd)) + } + return this.drawBehind { + drawRect( + brush = brush, + topLeft = Offset(0f, 8.dp.toPx()), + size = Size(3.dp.toPx(), size.height - 16.dp.toPx()), ) } } @Composable -private fun ScheduledPostRow( +private fun ScheduledPostCardCollapsed( post: ScheduledPost, nowSec: Long, - onPublishNow: () -> Unit, + onClick: () -> Unit, ) { val context = LocalContext.current - val preview = remember(post) { extractPreview(post) } + val preview = remember(post.id) { extractContentPreview(post, 200) } + val media = remember(post.id) { extractFirstMediaUrl(post) } + val relayCountText = + pluralStringResource( + id = R.plurals.scheduled_posts_relay_count, + count = post.relayUrls.size, + post.relayUrls.size, + ) + + val isFailed = post.status == ScheduledPostStatus.FAILED + // Composite the tint over surface so the card is opaque — otherwise the + // SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest. + val surface = MaterialTheme.colorScheme.surface + val containerColor = + if (isFailed) { + MaterialTheme.colorScheme.error + .copy(alpha = 0.06f) + .compositeOver(surface) + } else { + MaterialTheme.colorScheme.primary + .copy(alpha = 0.06f) + .compositeOver(surface) + } + val borderColor = + if (isFailed) { + MaterialTheme.colorScheme.error.copy(alpha = 0.22f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } + Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.outlinedCardColors(), + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke(1.dp, borderColor), + shape = RoundedCornerShape(14.dp), ) { Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Row( + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - StatusChip(post.status) + StatusPill(post.status) Text( text = formatAtTime(post.publishAtSec, nowSec, context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - } - - Text( - text = preview, - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - - if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) { - Text( - text = stringRes(R.string.scheduled_posts_error_prefix, post.lastError), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, ) } Row( - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth(), ) { - IconButton(onClick = onPublishNow) { - Icon( - symbol = MaterialSymbols.AutoMirrored.Send, - contentDescription = stringRes(R.string.scheduled_posts_action_send_now), - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary, - ) + if (media != null) { + MediaThumbnail(media) } + Text( + text = preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = relayCountText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ScheduledPostCardExpandedPanel( + post: ScheduledPost, + onPublishNow: () -> Unit, + onDelete: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val eventId = remember(post.id) { extractEventId(post) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + + Column { + SectionLabel(stringRes(R.string.relays)) + post.relayUrls.forEach { url -> + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } + + if (eventId != null) { + Column { + SectionLabel(stringRes(R.string.quick_action_copy_note_id)) + Text( + text = eventId, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = { + clipboard.setText(AnnotatedString(eventId)) + Toast + .makeText( + context, + stringRes(context, R.string.scheduled_posts_event_id_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ), + ) + } + } + + val err = post.lastError + if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) { + Text( + text = stringRes(R.string.scheduled_posts_error_prefix, err), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onPublishNow, + enabled = post.status != ScheduledPostStatus.PUBLISHING, + modifier = Modifier.weight(1f), + ) { + val labelRes = + when (post.status) { + ScheduledPostStatus.FAILED -> R.string.retry + ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing + else -> R.string.scheduled_posts_action_send_now + } + Text(stringRes(labelRes)) + } + OutlinedButton( + onClick = onDelete, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.scheduled_posts_action_delete)) } } } } +@Composable +private fun SectionLabel(text: String) { + Text( + text = text.uppercase(Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) +} + @Composable private fun DayHeader( day: LocalDate, @@ -246,41 +489,101 @@ private fun DayHeader( } @Composable -private fun StatusChip(status: ScheduledPostStatus) { - val (labelRes, tint) = +private fun StatusPill(status: ScheduledPostStatus) { + val (labelRes, color, pulse) = when (status) { - ScheduledPostStatus.PENDING -> R.string.scheduled_posts_status_pending to MaterialTheme.colorScheme.primary - ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing to MaterialTheme.colorScheme.tertiary - ScheduledPostStatus.FAILED -> R.string.scheduled_posts_status_failed to MaterialTheme.colorScheme.error - ScheduledPostStatus.SENT -> R.string.scheduled_posts_status_sent to MaterialTheme.colorScheme.tertiary - ScheduledPostStatus.CANCELLED -> R.string.scheduled_posts_status_cancelled to MaterialTheme.colorScheme.onSurfaceVariant + ScheduledPostStatus.PENDING -> { + Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false) + } + + ScheduledPostStatus.PUBLISHING -> { + Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true) + } + + ScheduledPostStatus.FAILED -> { + Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false) + } + + ScheduledPostStatus.SENT -> { + Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false) + } + + ScheduledPostStatus.CANCELLED -> { + Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false) + } } - AssistChip( - onClick = {}, - label = { Text(stringRes(labelRes), fontWeight = FontWeight.Medium) }, - colors = - AssistChipDefaults.assistChipColors( - labelColor = tint, - ), - ) + val dotAlpha = + if (pulse) { + val transition = rememberInfiniteTransition(label = "publishing-pulse") + transition + .animateFloat( + initialValue = 1f, + targetValue = 0.35f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "publishing-pulse-alpha", + ).value + } else { + 1f + } + + Surface( + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.18f), + ) { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(6.dp) + .clip(CircleShape) + .background(color.copy(alpha = dotAlpha)), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringRes(labelRes), + color = color, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ) + } + } } @Composable -private fun EmptyState(modifier: Modifier = Modifier) { +private fun EmptyState( + onCompose: () -> Unit, + modifier: Modifier = Modifier, +) { Box( modifier = modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon( - symbol = MaterialSymbols.Schedule, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box( + modifier = + Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } Text( text = stringRes(R.string.scheduled_posts_empty_title), style = MaterialTheme.typography.titleMedium, @@ -290,46 +593,13 @@ private fun EmptyState(modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Button(onClick = onCompose) { + Text(stringRes(R.string.new_post)) + } } } } -@Composable -private fun ConfirmDialog( - title: String, - message: String, - confirmLabel: String, - destructive: Boolean = false, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(message) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text( - confirmLabel, - color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } - }, - ) -} - -private fun extractPreview(post: ScheduledPost): String = - runCatching { - Event - .fromJson(post.signedEventJson) - .content - .take(200) - .trim() - }.getOrDefault("") - private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt index 8855a5ca1..cf24d233a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -76,6 +76,15 @@ class ScheduledPostsViewModel( initialValue = emptyList(), ) + val totalActive: StateFlow = + groupedPosts + .map { groups -> groups.sumOf { it.posts.size } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = 0, + ) + fun cancel(id: String) { viewModelScope.launch(Dispatchers.IO) { store.cancel(id) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6a2356beb..878eed228 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2488,4 +2488,23 @@ Naplánovaný příspěvek selhal Naplánované příspěvky Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci. + + %d ve frontě + %d ve frontě + %d ve frontě + %d ve frontě + + + · 1 do 1 h + · %d do 1 h + · %d do 1 h + · %d do 1 h + + + na 1 relay + na %d relaye + na %d relayů + na %d relayů + + ID příspěvku zkopírováno diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 1aa4e5199..333704cfd 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2479,4 +2479,17 @@ anz der Bedingungen ist erforderlich Geplanter Beitrag fehlgeschlagen Geplante Beiträge Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt. + + %d in Warteschlange + %d in Warteschlange + + + · 1 fällig in 1 Std. + · %d fällig in 1 Std. + + + an 1 Relay + an %d Relays + + Beitrags-ID kopiert diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 30788e286..64c830bb1 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2474,4 +2474,17 @@ Post agendado falhou Posts agendados Notificações quando um post agendado é publicado ou falha ao publicar. + + %d na fila + %d na fila + + + · 1 em 1h + · %d em 1h + + + para 1 relay + para %d relays + + ID do post copiado diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 1465ce335..5b6d4feb5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2473,4 +2473,17 @@ Schemalagt inlägg misslyckades Schemalagda inlägg Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera. + + %d i kö + %d i kö + + + · 1 inom 1 h + · %d inom 1 h + + + till 1 relä + till %d reläer + + Inläggs-ID kopierat diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c9a050e43..530343b67 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -477,6 +477,19 @@ Sent Cancelled You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them. + + %d queued + %d queued + + + · 1 due in 1h + · %d due in 1h + + + to 1 relay + to %d relays + + Note ID copied Polls Open Closed diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt new file mode 100644 index 000000000..c20a9a03b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts + +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScheduledPostMediaTest { + private fun postWithJson(json: String) = + ScheduledPost( + id = "id", + accountPubkey = "pk", + signedEventJson = json, + relayUrls = emptyList(), + extraEventsJson = emptyList(), + publishAtSec = 0, + createdAtSec = 0, + status = ScheduledPostStatus.PENDING, + ) + + @Test + fun extractFirstMediaUrl_returns_null_when_no_imeta() { + val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}""" + assertNull(extractFirstMediaUrl(postWithJson(json))) + } + + @Test + fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Video) + assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url) + } + + @Test + fun extractFirstMediaUrl_defaults_to_image_when_no_mime() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_picks_first_when_multiple_imeta() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Image) + assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url) + } + + @Test + fun extractFirstMediaUrl_returns_null_when_json_malformed() { + assertNull(extractFirstMediaUrl(postWithJson("{not json}"))) + } + + @Test + fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}""" + assertNull(extractFirstMediaUrl(postWithJson(json))) + } + + @Test + fun extractFirstMediaUrl_picks_first_when_video_precedes_image() { + val json = + """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}""" + val result = extractFirstMediaUrl(postWithJson(json)) + assertTrue(result is MediaUrl.Video) + assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url) + } + + @Test + fun extractEventId_returns_id_for_well_formed_json() { + val json = + """{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}""" + assertEquals("abc123", extractEventId(postWithJson(json))) + } + + @Test + fun extractEventId_returns_null_when_json_malformed() { + assertNull(extractEventId(postWithJson("{not json}"))) + } +}