Screen re-design
This commit is contained in:
+3
-12
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+139
@@ -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 <T : Any> 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+393
-123
@@ -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<String?>(null) }
|
||||
var expandedId 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
+9
@@ -76,6 +76,15 @@ class ScheduledPostsViewModel(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val totalActive: StateFlow<Int> =
|
||||
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)
|
||||
|
||||
@@ -2488,4 +2488,23 @@
|
||||
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d ve frontě</item>
|
||||
<item quantity="few">%d ve frontě</item>
|
||||
<item quantity="many">%d ve frontě</item>
|
||||
<item quantity="other">%d ve frontě</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 do 1 h</item>
|
||||
<item quantity="few"> · %d do 1 h</item>
|
||||
<item quantity="many"> · %d do 1 h</item>
|
||||
<item quantity="other"> · %d do 1 h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">na 1 relay</item>
|
||||
<item quantity="few">na %d relaye</item>
|
||||
<item quantity="many">na %d relayů</item>
|
||||
<item quantity="other">na %d relayů</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
|
||||
</resources>
|
||||
|
||||
@@ -2479,4 +2479,17 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d in Warteschlange</item>
|
||||
<item quantity="other">%d in Warteschlange</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 fällig in 1 Std.</item>
|
||||
<item quantity="other"> · %d fällig in 1 Std.</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">an 1 Relay</item>
|
||||
<item quantity="other">an %d Relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
|
||||
</resources>
|
||||
|
||||
@@ -2474,4 +2474,17 @@
|
||||
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d na fila</item>
|
||||
<item quantity="other">%d na fila</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 em 1h</item>
|
||||
<item quantity="other"> · %d em 1h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">para 1 relay</item>
|
||||
<item quantity="other">para %d relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
|
||||
</resources>
|
||||
|
||||
@@ -2473,4 +2473,17 @@
|
||||
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d i kö</item>
|
||||
<item quantity="other">%d i kö</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 inom 1 h</item>
|
||||
<item quantity="other"> · %d inom 1 h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">till 1 relä</item>
|
||||
<item quantity="other">till %d reläer</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
|
||||
</resources>
|
||||
|
||||
@@ -477,6 +477,19 @@
|
||||
<string name="scheduled_posts_status_sent">Sent</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>
|
||||
<plurals name="scheduled_posts_subtitle_queued">
|
||||
<item quantity="one">%d queued</item>
|
||||
<item quantity="other">%d queued</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_subtitle_due_suffix">
|
||||
<item quantity="one"> · 1 due in 1h</item>
|
||||
<item quantity="other"> · %d due in 1h</item>
|
||||
</plurals>
|
||||
<plurals name="scheduled_posts_relay_count">
|
||||
<item quantity="one">to 1 relay</item>
|
||||
<item quantity="other">to %d relays</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="open_polls">Open</string>
|
||||
<string name="closed_polls">Closed</string>
|
||||
|
||||
+117
@@ -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}")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user