diff --git a/app/src/main/java/com/swoosh/microblog/data/ShareUtils.kt b/app/src/main/java/com/swoosh/microblog/data/ShareUtils.kt new file mode 100644 index 0000000..6c952be --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/ShareUtils.kt @@ -0,0 +1,78 @@ +package com.swoosh.microblog.data + +import com.swoosh.microblog.data.model.FeedPost + +/** + * Utility functions for sharing posts via Android share sheet and clipboard. + */ +object ShareUtils { + + /** + * Constructs a post URL from the Ghost base URL and the post slug. + * Ghost default URL format: https://yourblog.com/slug/ + * + * @param baseUrl The Ghost instance base URL (e.g. "https://blog.example.com" or "https://blog.example.com/") + * @param slug The post slug (e.g. "my-first-post") + * @return The full post URL with trailing slash + */ + fun getPostUrl(baseUrl: String, slug: String): String { + val normalizedBase = baseUrl.trimEnd('/') + val normalizedSlug = slug.trim('/') + return "$normalizedBase/$normalizedSlug/" + } + + /** + * Resolves the shareable URL for a post. + * Prefers the GhostPost url field if available, otherwise constructs from baseUrl + slug. + * + * @param post The FeedPost to get the URL for + * @param baseUrl The Ghost instance base URL + * @return The post URL, or null if no slug or url is available + */ + fun resolvePostUrl(post: FeedPost, baseUrl: String?): String? { + // Prefer the url field from the Ghost API response + if (!post.url.isNullOrBlank()) { + return post.url + } + // Fall back to constructing from base URL + slug + if (!post.slug.isNullOrBlank() && !baseUrl.isNullOrBlank()) { + return getPostUrl(baseUrl, post.slug) + } + return null + } + + /** + * Formats share content for a post. + * Includes the first 280 chars of text content, then "Read more:" link. + * For posts with link previews, the linked URL is included. + * + * @param post The FeedPost to format + * @param postUrl The resolved URL for the post + * @return Formatted share text + */ + fun formatShareContent(post: FeedPost, postUrl: String): String { + val content = buildString { + // Post text content, truncated to 280 chars + val text = post.textContent.trim() + if (text.isNotEmpty()) { + if (text.length > 280) { + append(text.take(280)) + append("...") + } else { + append(text) + } + } + + // If there's a link preview URL, include it + if (!post.linkUrl.isNullOrBlank()) { + if (isNotEmpty()) append("\n\n") + append(post.linkUrl) + } + + // Always include the post URL + if (isNotEmpty()) append("\n\n") + append("Read more: $postUrl") + } + return content + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..2041e5a 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -31,6 +31,8 @@ data class PostWrapper( data class GhostPost( val id: String? = null, val title: String? = null, + val slug: String? = null, + val url: String? = null, val html: String? = null, val plaintext: String? = null, val mobiledoc: String? = null, @@ -91,6 +93,8 @@ enum class QueueStatus { data class FeedPost( val localId: Long? = null, val ghostId: String? = null, + val slug: String? = null, + val url: String? = null, val title: String, val textContent: String, val htmlContent: String?, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..be474fb 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -1,22 +1,34 @@ package com.swoosh.microblog.ui.detail +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.swoosh.microblog.data.CredentialsManager +import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.formatRelativeTime +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -27,6 +39,15 @@ fun DetailScreen( onDelete: (FeedPost) -> Unit ) { var showDeleteDialog by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val baseUrl = remember { CredentialsManager(context).ghostUrl } + + val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE + val postUrl = remember(post, baseUrl) { ShareUtils.resolvePostUrl(post, baseUrl) } + val canShare = isPublished && postUrl != null Scaffold( topBar = { @@ -38,15 +59,67 @@ fun DetailScreen( } }, actions = { + // Share button - only for published posts with a URL + if (canShare) { + IconButton(onClick = { + val shareText = ShareUtils.formatShareContent(post, postUrl!!) + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(sendIntent, "Share post")) + }) { + Icon(Icons.Default.Share, "Share") + } + } IconButton(onClick = { onEdit(post) }) { Icon(Icons.Default.Edit, "Edit") } - IconButton(onClick = { showDeleteDialog = true }) { - Icon(Icons.Default.Delete, "Delete") + // Overflow menu with Copy link and Delete + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon(Icons.Default.MoreVert, "More options") + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false } + ) { + if (canShare) { + DropdownMenuItem( + text = { Text("Copy link") }, + onClick = { + showOverflowMenu = false + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl)) + coroutineScope.launch { + snackbarHostState.showSnackbar("Link copied to clipboard") + } + }, + leadingIcon = { + Icon(Icons.Default.ContentCopy, contentDescription = null) + } + ) + } + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + showOverflowMenu = false + showDeleteDialog = true + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Column( modifier = Modifier @@ -132,7 +205,7 @@ fun DetailScreen( // Metadata Spacer(modifier = Modifier.height(24.dp)) - Divider() + HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) if (post.createdAt != null) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..177237b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -1,15 +1,23 @@ package com.swoosh.microblog.ui.feed -import androidx.compose.foundation.clickable +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* +import kotlinx.coroutines.launch import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -20,12 +28,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage +import com.swoosh.microblog.data.CredentialsManager +import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.QueueStatus @@ -39,6 +51,9 @@ fun FeedScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val baseUrl = remember { CredentialsManager(context).ghostUrl } // Pull-to-refresh val pullRefreshState = rememberPullRefreshState( @@ -78,7 +93,8 @@ fun FeedScreen( FloatingActionButton(onClick = onCompose) { Icon(Icons.Default.Add, contentDescription = "New post") } - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box( modifier = Modifier @@ -151,7 +167,26 @@ fun FeedScreen( PostCard( post = post, onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } + onCancelQueue = { viewModel.cancelQueuedPost(post) }, + onShare = { + val postUrl = ShareUtils.resolvePostUrl(post, baseUrl) + if (postUrl != null) { + val shareText = ShareUtils.formatShareContent(post, postUrl) + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(sendIntent, "Share post")) + } + }, + onCopyLink = { + val postUrl = ShareUtils.resolvePostUrl(post, baseUrl) + if (postUrl != null) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl)) + } + }, + snackbarHostState = snackbarHostState ) } @@ -191,134 +226,206 @@ fun FeedScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun PostCard( post: FeedPost, onClick: () -> Unit, - onCancelQueue: () -> Unit + onCancelQueue: () -> Unit, + onShare: () -> Unit = {}, + onCopyLink: () -> Unit = {}, + snackbarHostState: SnackbarHostState? = null ) { var expanded by remember { mutableStateOf(false) } + var showContextMenu by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() val displayText = if (expanded || post.textContent.length <= 280) { post.textContent } else { post.textContent.take(280) + "..." } + val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE + val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank() + Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick), + .combinedClickable( + onClick = onClick, + onLongClick = { + if (isPublished && hasShareableUrl) { + showContextMenu = true + } + } + ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { - Column(modifier = Modifier.padding(16.dp)) { - // Status row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - StatusBadge(post) - - Text( - text = formatRelativeTime(post.publishedAt ?: post.createdAt), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Content - Text( - text = displayText, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (expanded) Int.MAX_VALUE else 8, - overflow = TextOverflow.Ellipsis - ) - - if (!expanded && post.textContent.length > 280) { - TextButton( - onClick = { expanded = true }, - contentPadding = PaddingValues(0.dp) + Box { + Column(modifier = Modifier.padding(16.dp)) { + // Status row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text("Show more", style = MaterialTheme.typography.labelMedium) + StatusBadge(post) + + Text( + text = formatRelativeTime(post.publishedAt ?: post.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - } - // Image thumbnail - if (post.imageUrl != null) { Spacer(modifier = Modifier.height(8.dp)) - AsyncImage( - model = post.imageUrl, - contentDescription = "Post image", - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop + + // Content + Text( + text = displayText, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (expanded) Int.MAX_VALUE else 8, + overflow = TextOverflow.Ellipsis ) - } - // Link preview - if (post.linkUrl != null && post.linkTitle != null) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - if (post.linkImageUrl != null) { - AsyncImage( - model = post.linkImageUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .clip(MaterialTheme.shapes.small), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.height(8.dp)) - } - Text( - text = post.linkTitle, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (post.linkDescription != null) { + if (!expanded && post.textContent.length > 280) { + TextButton( + onClick = { expanded = true }, + contentPadding = PaddingValues(0.dp) + ) { + Text("Show more", style = MaterialTheme.typography.labelMedium) + } + } + + // Image thumbnail + if (post.imageUrl != null) { + Spacer(modifier = Modifier.height(8.dp)) + AsyncImage( + model = post.imageUrl, + contentDescription = "Post image", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + } + + // Link preview + if (post.linkUrl != null && post.linkTitle != null) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + if (post.linkImageUrl != null) { + AsyncImage( + model = post.linkImageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(8.dp)) + } Text( - text = post.linkDescription, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = post.linkTitle, + style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis ) + if (post.linkDescription != null) { + Text( + text = post.linkDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + // Queue status + if (post.queueStatus != QueueStatus.NONE) { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val queueLabel = when (post.queueStatus) { + QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload" + QueueStatus.UPLOADING -> "Uploading..." + QueueStatus.FAILED -> "Upload failed" + else -> "" + } + AssistChip( + onClick = {}, + label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) } + ) + Spacer(modifier = Modifier.width(8.dp)) + if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) { + TextButton(onClick = onCancelQueue) { + Text("Cancel", style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Share button row for published posts + if (isPublished && hasShareableUrl) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = onShare, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Share, + contentDescription = "Share", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } - // Queue status - if (post.queueStatus != QueueStatus.NONE) { - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - val queueLabel = when (post.queueStatus) { - QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload" - QueueStatus.UPLOADING -> "Uploading..." - QueueStatus.FAILED -> "Upload failed" - else -> "" - } - AssistChip( - onClick = {}, - label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) } - ) - Spacer(modifier = Modifier.width(8.dp)) - if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) { - TextButton(onClick = onCancelQueue) { - Text("Cancel", style = MaterialTheme.typography.labelSmall) + // Long-press context menu for copy link + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false }, + offset = DpOffset(16.dp, 0.dp) + ) { + DropdownMenuItem( + text = { Text("Copy link") }, + onClick = { + showContextMenu = false + onCopyLink() + snackbarHostState?.let { host -> + coroutineScope.launch { + host.showSnackbar("Link copied to clipboard") + } } + }, + leadingIcon = { + Icon(Icons.Default.ContentCopy, contentDescription = null) } - } + ) + DropdownMenuItem( + text = { Text("Share") }, + onClick = { + showContextMenu = false + onShare() + }, + leadingIcon = { + Icon(Icons.Default.Share, contentDescription = null) + } + ) } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..0f0db5f 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -140,6 +140,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { private fun GhostPost.toFeedPost(): FeedPost = FeedPost( ghostId = id, + slug = slug, + url = url, title = title ?: "", textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", htmlContent = html, diff --git a/app/src/test/java/com/swoosh/microblog/data/ShareUtilsTest.kt b/app/src/test/java/com/swoosh/microblog/data/ShareUtilsTest.kt new file mode 100644 index 0000000..6910b57 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/ShareUtilsTest.kt @@ -0,0 +1,276 @@ +package com.swoosh.microblog.data + +import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.QueueStatus +import org.junit.Assert.* +import org.junit.Test + +class ShareUtilsTest { + + // --- getPostUrl tests --- + + @Test + fun `getPostUrl constructs URL with base and slug`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com", "my-post") + ) + } + + @Test + fun `getPostUrl handles base URL with trailing slash`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com/", "my-post") + ) + } + + @Test + fun `getPostUrl handles base URL with multiple trailing slashes`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com///", "my-post") + ) + } + + @Test + fun `getPostUrl handles slug with leading slash`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com", "/my-post") + ) + } + + @Test + fun `getPostUrl handles slug with trailing slash`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com", "my-post/") + ) + } + + @Test + fun `getPostUrl handles slug with both leading and trailing slashes`() { + assertEquals( + "https://blog.example.com/my-post/", + ShareUtils.getPostUrl("https://blog.example.com", "/my-post/") + ) + } + + @Test + fun `getPostUrl handles subdomain base URL`() { + assertEquals( + "https://my-blog.ghost.io/hello-world/", + ShareUtils.getPostUrl("https://my-blog.ghost.io", "hello-world") + ) + } + + @Test + fun `getPostUrl handles http base URL`() { + assertEquals( + "http://localhost:2368/test-post/", + ShareUtils.getPostUrl("http://localhost:2368", "test-post") + ) + } + + @Test + fun `getPostUrl handles slug with hyphens`() { + assertEquals( + "https://blog.example.com/my-long-post-title-here/", + ShareUtils.getPostUrl("https://blog.example.com", "my-long-post-title-here") + ) + } + + // --- resolvePostUrl tests --- + + @Test + fun `resolvePostUrl prefers url field when available`() { + val post = createFeedPost( + url = "https://blog.example.com/custom-url/", + slug = "my-slug" + ) + assertEquals( + "https://blog.example.com/custom-url/", + ShareUtils.resolvePostUrl(post, "https://blog.example.com") + ) + } + + @Test + fun `resolvePostUrl falls back to slug when url is null`() { + val post = createFeedPost(slug = "my-slug", url = null) + assertEquals( + "https://blog.example.com/my-slug/", + ShareUtils.resolvePostUrl(post, "https://blog.example.com") + ) + } + + @Test + fun `resolvePostUrl falls back to slug when url is blank`() { + val post = createFeedPost(slug = "my-slug", url = " ") + assertEquals( + "https://blog.example.com/my-slug/", + ShareUtils.resolvePostUrl(post, "https://blog.example.com") + ) + } + + @Test + fun `resolvePostUrl returns null when no url and no slug`() { + val post = createFeedPost(slug = null, url = null) + assertNull(ShareUtils.resolvePostUrl(post, "https://blog.example.com")) + } + + @Test + fun `resolvePostUrl returns null when no url and blank slug`() { + val post = createFeedPost(slug = "", url = null) + assertNull(ShareUtils.resolvePostUrl(post, "https://blog.example.com")) + } + + @Test + fun `resolvePostUrl returns null when no url, has slug, but no base URL`() { + val post = createFeedPost(slug = "my-slug", url = null) + assertNull(ShareUtils.resolvePostUrl(post, null)) + } + + @Test + fun `resolvePostUrl returns null when no url, has slug, but blank base URL`() { + val post = createFeedPost(slug = "my-slug", url = null) + assertNull(ShareUtils.resolvePostUrl(post, "")) + } + + // --- formatShareContent tests --- + + @Test + fun `formatShareContent includes text and read more link`() { + val post = createFeedPost(textContent = "Hello world") + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/hello/") + assertEquals( + "Hello world\n\nRead more: https://blog.example.com/hello/", + result + ) + } + + @Test + fun `formatShareContent truncates text over 280 chars`() { + val longText = "a".repeat(300) + val post = createFeedPost(textContent = longText) + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertTrue(result.startsWith("a".repeat(280) + "...")) + assertTrue(result.contains("Read more: https://blog.example.com/post/")) + } + + @Test + fun `formatShareContent does not truncate text at exactly 280 chars`() { + val text = "b".repeat(280) + val post = createFeedPost(textContent = text) + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertTrue(result.startsWith(text)) + assertFalse(result.contains("...")) + } + + @Test + fun `formatShareContent includes link URL when present`() { + val post = createFeedPost( + textContent = "Check this out", + linkUrl = "https://external.com/article" + ) + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertEquals( + "Check this out\n\nhttps://external.com/article\n\nRead more: https://blog.example.com/post/", + result + ) + } + + @Test + fun `formatShareContent does not include link URL when null`() { + val post = createFeedPost(textContent = "Hello", linkUrl = null) + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertFalse(result.contains("https://external")) + assertEquals("Hello\n\nRead more: https://blog.example.com/post/", result) + } + + @Test + fun `formatShareContent does not include link URL when blank`() { + val post = createFeedPost(textContent = "Hello", linkUrl = " ") + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertEquals("Hello\n\nRead more: https://blog.example.com/post/", result) + } + + @Test + fun `formatShareContent handles empty text content`() { + val post = createFeedPost(textContent = "") + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertEquals("Read more: https://blog.example.com/post/", result) + } + + @Test + fun `formatShareContent handles whitespace-only text content`() { + val post = createFeedPost(textContent = " ") + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertEquals("Read more: https://blog.example.com/post/", result) + } + + @Test + fun `formatShareContent handles empty text with link URL`() { + val post = createFeedPost(textContent = "", linkUrl = "https://external.com") + val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/") + assertEquals("https://external.com\n\nRead more: https://blog.example.com/post/", result) + } + + // --- Share visibility tests --- + + @Test + fun `published post with slug is shareable`() { + val post = createFeedPost(status = "published", slug = "my-post") + val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com") + assertNotNull(url) + } + + @Test + fun `draft post is not shareable via url resolution`() { + // Drafts don't typically have a slug or url from Ghost + val post = createFeedPost(status = "draft", slug = null, url = null) + val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com") + assertNull(url) + } + + @Test + fun `published post with url field is shareable`() { + val post = createFeedPost( + status = "published", + slug = null, + url = "https://blog.example.com/my-post/" + ) + val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com") + assertEquals("https://blog.example.com/my-post/", url) + } + + // --- Helper --- + + private fun createFeedPost( + textContent: String = "Test content", + slug: String? = null, + url: String? = null, + status: String = "published", + linkUrl: String? = null, + imageUrl: String? = null + ) = FeedPost( + localId = null, + ghostId = "test-id", + slug = slug, + url = url, + title = "", + textContent = textContent, + htmlContent = null, + imageUrl = imageUrl, + linkUrl = linkUrl, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = status, + publishedAt = null, + createdAt = null, + updatedAt = null, + isLocal = false, + queueStatus = QueueStatus.NONE + ) +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 6f4f370..91548e0 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -67,6 +67,8 @@ class GhostModelsTest { val post = GhostPost() assertNull(post.id) assertNull(post.title) + assertNull(post.slug) + assertNull(post.url) assertNull(post.html) assertNull(post.plaintext) assertNull(post.mobiledoc)