merge: integrate share sheet feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:39:28 +01:00
commit 0e954e15d5
No known key found for this signature in database
7 changed files with 639 additions and 97 deletions

View file

@ -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
}
}

View file

@ -31,6 +31,8 @@ data class PostWrapper(
data class GhostPost( data class GhostPost(
val id: String? = null, val id: String? = null,
val title: String? = null, val title: String? = null,
val slug: String? = null,
val url: String? = null,
val html: String? = null, val html: String? = null,
val plaintext: String? = null, val plaintext: String? = null,
val mobiledoc: String? = null, val mobiledoc: String? = null,
@ -92,6 +94,8 @@ enum class QueueStatus {
data class FeedPost( data class FeedPost(
val localId: Long? = null, val localId: Long? = null,
val ghostId: String? = null, val ghostId: String? = null,
val slug: String? = null,
val url: String? = null,
val title: String, val title: String,
val textContent: String, val textContent: String,
val htmlContent: String?, val htmlContent: String?,

View file

@ -1,5 +1,9 @@
package com.swoosh.microblog.ui.detail 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.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
@ -10,12 +14,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -23,12 +30,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage 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.FeedPost
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime import com.swoosh.microblog.ui.feed.formatRelativeTime
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -39,6 +51,15 @@ fun DetailScreen(
onDelete: (FeedPost) -> Unit onDelete: (FeedPost) -> Unit
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } 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( Scaffold(
topBar = { topBar = {
@ -50,15 +71,67 @@ fun DetailScreen(
} }
}, },
actions = { 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) }) { IconButton(onClick = { onEdit(post) }) {
Icon(Icons.Default.Edit, "Edit") Icon(Icons.Default.Edit, "Edit")
} }
IconButton(onClick = { showDeleteDialog = true }) { // Overflow menu with Copy link and Delete
Icon(Icons.Default.Delete, "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 -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -1,7 +1,13 @@
package com.swoosh.microblog.ui.feed 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 androidx.compose.foundation.layout.*
import kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -10,10 +16,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.BrightnessAuto import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings 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.icons.filled.WifiOff
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@ -24,12 +32,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage 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.FeedPost
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
@ -48,6 +60,9 @@ fun FeedScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val baseUrl = remember { CredentialsManager(context).ghostUrl }
// Pull-to-refresh // Pull-to-refresh
val pullRefreshState = rememberPullRefreshState( val pullRefreshState = rememberPullRefreshState(
@ -98,7 +113,8 @@ fun FeedScreen(
FloatingActionButton(onClick = onCompose) { FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post") Icon(Icons.Default.Add, contentDescription = "New post")
} }
} },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
@ -171,7 +187,26 @@ fun FeedScreen(
PostCard( PostCard(
post = post, post = post,
onClick = { onPostClick(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
) )
} }
@ -211,28 +246,45 @@ fun FeedScreen(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PostCard( fun PostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit onCancelQueue: () -> Unit,
onShare: () -> Unit = {},
onCopyLink: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showContextMenu by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val displayText = if (expanded || post.textContent.length <= 280) { val displayText = if (expanded || post.textContent.length <= 280) {
post.textContent post.textContent
} else { } else {
post.textContent.take(280) + "..." post.textContent.take(280) + "..."
} }
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick), .combinedClickable(
onClick = onClick,
onLongClick = {
if (isPublished && hasShareableUrl) {
showContextMenu = true
}
}
),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) )
) { ) {
Box {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Status row // Status row
Row( Row(
@ -341,6 +393,61 @@ fun PostCard(
} }
} }
// 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
)
}
}
}
}
// 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)
}
)
}
// Post stats badges // Post stats badges
if (post.textContent.isNotBlank()) { if (post.textContent.isNotBlank()) {
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) { val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {

View file

@ -143,6 +143,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private fun GhostPost.toFeedPost(): FeedPost = FeedPost( private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
ghostId = id, ghostId = id,
slug = slug,
url = url,
title = title ?: "", title = title ?: "",
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
htmlContent = html, htmlContent = html,

View file

@ -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
)
}

View file

@ -67,6 +67,8 @@ class GhostModelsTest {
val post = GhostPost() val post = GhostPost()
assertNull(post.id) assertNull(post.id)
assertNull(post.title) assertNull(post.title)
assertNull(post.slug)
assertNull(post.url)
assertNull(post.html) assertNull(post.html)
assertNull(post.plaintext) assertNull(post.plaintext)
assertNull(post.mobiledoc) assertNull(post.mobiledoc)