merge: integrate hashtag support (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:09:30 +01:00
commit 91982a66a2
No known key found for this signature in database
13 changed files with 733 additions and 48 deletions

View file

@ -0,0 +1,78 @@
package com.swoosh.microblog.data
/**
* Parses #hashtag patterns from post text content.
*
* Rules:
* - Hashtags start with # followed by at least one letter or digit
* - Allowed characters: letters, digits, hyphens, underscores
* - Hashtags must NOT be inside URLs (http://, https://, or www.)
* - Leading # is stripped from the result
* - Results are unique (case-preserved, deduped case-insensitively)
* - Trailing hyphens/underscores are trimmed from tag names
*/
object HashtagParser {
// Matches URLs so we can skip hashtags that appear inside them
private val URL_PATTERN = Regex(
"""(?:https?://|www\.)\S+""",
RegexOption.IGNORE_CASE
)
// Matches a hashtag: # followed by at least one word char (letter/digit),
// then optionally more word chars or hyphens
private val HASHTAG_PATTERN = Regex(
"""#([\w][\w-]*)""",
RegexOption.IGNORE_CASE
)
/**
* Extracts unique hashtag names from text content.
* Returns tag names without the # prefix.
*/
fun parse(text: String): List<String> {
if (text.isBlank()) return emptyList()
// Find all URL ranges so we can exclude hashtags inside URLs
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
val seen = mutableSetOf<String>()
val result = mutableListOf<String>()
for (match in HASHTAG_PATTERN.findAll(text)) {
// Skip if this hashtag is inside a URL
if (urlRanges.any { urlRange -> match.range.first in urlRange }) {
continue
}
val tagName = match.groupValues[1].trimEnd('-', '_')
// Must have at least one character after trimming
if (tagName.isEmpty()) continue
// Deduplicate case-insensitively but preserve original casing
val lowerTag = tagName.lowercase()
if (lowerTag !in seen) {
seen.add(lowerTag)
result.add(tagName)
}
}
return result
}
/**
* Returns the ranges of hashtags in the text for highlighting.
* Each range covers the full hashtag including the # prefix.
*/
fun findHashtagRanges(text: String): List<IntRange> {
if (text.isBlank()) return emptyList()
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
return HASHTAG_PATTERN.findAll(text)
.filter { match -> urlRanges.none { urlRange -> match.range.first in urlRange } }
.map { it.range }
.toList()
}
}

View file

@ -13,7 +13,7 @@ interface GhostApiService {
suspend fun getPosts( suspend fun getPosts(
@Query("limit") limit: Int = 15, @Query("limit") limit: Int = 15,
@Query("page") page: Int = 1, @Query("page") page: Int = 1,
@Query("include") include: String = "authors", @Query("include") include: String = "authors,tags",
@Query("formats") formats: String = "html,plaintext,mobiledoc", @Query("formats") formats: String = "html,plaintext,mobiledoc",
@Query("order") order: String = "created_at desc", @Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null @Query("filter") filter: String? = null

View file

@ -23,6 +23,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL") db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
} }
} }

View file

@ -46,7 +46,14 @@ data class GhostPost(
val custom_excerpt: String? = null, val custom_excerpt: String? = null,
val visibility: String? = "public", val visibility: String? = "public",
val authors: List<Author>? = null, val authors: List<Author>? = null,
val reading_time: Int? = null val reading_time: Int? = null,
val tags: List<GhostTag>? = null
)
data class GhostTag(
val id: String? = null,
val name: String,
val slug: String? = null
) )
data class Author( data class Author(
@ -74,6 +81,7 @@ data class LocalPost(
val linkImageUrl: String? = null, val linkImageUrl: String? = null,
val imageAlt: String? = null, val imageAlt: String? = null,
val scheduledAt: String? = null, val scheduledAt: String? = null,
val tags: String = "[]",
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val queueStatus: QueueStatus = QueueStatus.NONE val queueStatus: QueueStatus = QueueStatus.NONE
@ -109,6 +117,7 @@ data class FeedPost(
val linkTitle: String?, val linkTitle: String?,
val linkDescription: String?, val linkDescription: String?,
val linkImageUrl: String?, val linkImageUrl: String?,
val tags: List<String> = emptyList(),
val status: String, val status: String,
val featured: Boolean = false, val featured: Boolean = false,
val publishedAt: String?, val publishedAt: String?,

View file

@ -36,15 +36,22 @@ class PostRepository(private val context: Context) {
page: Int = 1, page: Int = 1,
limit: Int = 15, limit: Int = 15,
filter: PostFilter = PostFilter.ALL, filter: PostFilter = PostFilter.ALL,
sortOrder: SortOrder = SortOrder.NEWEST sortOrder: SortOrder = SortOrder.NEWEST,
tagFilter: String? = null
): Result<PostsResponse> = ): Result<PostsResponse> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
// Combine status filter and tag filter
val filterParts = mutableListOf<String>()
filter.ghostFilter?.let { filterParts.add(it) }
tagFilter?.let { filterParts.add("tag:$it") }
val combinedFilter = filterParts.takeIf { it.isNotEmpty() }?.joinToString("+")
val response = getApi().getPosts( val response = getApi().getPosts(
limit = limit, limit = limit,
page = page, page = page,
order = sortOrder.ghostOrder, order = sortOrder.ghostOrder,
filter = filter.ghostFilter filter = combinedFilter
) )
if (response.isSuccessful) { if (response.isSuccessful) {
Result.success(response.body()!!) Result.success(response.body()!!)

View file

@ -16,15 +16,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
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.HashtagParser
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.ui.preview.HtmlPreviewWebView import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
@ -96,6 +103,12 @@ fun ComposerScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// Hashtag visual transformation for edit mode text field
val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) {
HashtagVisualTransformation(hashtagColor)
}
// Edit / Preview segmented button row // Edit / Preview segmented button row
SingleChoiceSegmentedButtonRow( SingleChoiceSegmentedButtonRow(
modifier = Modifier modifier = Modifier
@ -168,7 +181,7 @@ fun ComposerScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// Text field with character counter // Text field with character counter and hashtag highlighting
OutlinedTextField( OutlinedTextField(
value = state.text, value = state.text,
onValueChange = viewModel::updateText, onValueChange = viewModel::updateText,
@ -176,6 +189,7 @@ fun ComposerScreen(
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 150.dp), .heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") }, placeholder = { Text("What's on your mind?") },
visualTransformation = hashtagTransformation,
supportingText = { supportingText = {
val charCount = state.text.length val charCount = state.text.length
val statsText = PostStats.formatComposerStats(state.text) val statsText = PostStats.formatComposerStats(state.text)
@ -192,6 +206,44 @@ fun ComposerScreen(
} }
) )
// Extracted tags preview chips
if (state.extractedTags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.Tag,
contentDescription = "Tags",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.extractedTags.forEach { tag ->
SuggestionChip(
onClick = {},
label = {
Text(
"#$tag",
style = MaterialTheme.typography.labelSmall
)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
border = null
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row // Attachment buttons row
@ -550,3 +602,28 @@ fun ScheduleDateTimePicker(
) )
} }
} }
/**
* VisualTransformation that highlights #hashtags in a different color.
*/
class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTransformation {
override fun filter(text: androidx.compose.ui.text.AnnotatedString): TransformedText {
val ranges = HashtagParser.findHashtagRanges(text.text)
if (ranges.isEmpty()) {
return TransformedText(text, OffsetMapping.Identity)
}
val annotated = buildAnnotatedString {
append(text.text)
for (range in ranges) {
val safeEnd = minOf(range.last + 1, text.text.length)
addStyle(
SpanStyle(color = hashtagColor),
start = range.first,
end = safeEnd
)
}
}
return TransformedText(annotated, OffsetMapping.Identity)
}
}

View file

@ -4,12 +4,15 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.PreviewHtmlBuilder import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.worker.PostUploadWorker import com.swoosh.microblog.worker.PostUploadWorker
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -48,13 +51,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
imageUrl = post.linkImageUrl imageUrl = post.linkImageUrl
) else null, ) else null,
featured = post.featured, featured = post.featured,
extractedTags = HashtagParser.parse(post.textContent),
isEditing = true isEditing = true
) )
} }
} }
fun updateText(text: String) { fun updateText(text: String) {
_uiState.update { it.copy(text = text) } val extractedTags = HashtagParser.parse(text)
_uiState.update { it.copy(text = text, extractedTags = extractedTags) }
if (_uiState.value.isPreviewMode) { if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate() debouncedPreviewUpdate()
} }
@ -161,6 +166,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isSubmitting = true, error = null) } _uiState.update { it.copy(isSubmitting = true, error = null) }
val title = state.text.take(60) val title = state.text.take(60)
val extractedTags = HashtagParser.parse(state.text)
val tagsJson = Gson().toJson(extractedTags)
val altText = state.imageAlt.ifBlank { null } val altText = state.imageAlt.ifBlank { null }
@ -180,6 +187,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl, linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt, scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
) )
repository.saveLocalPost(localPost) repository.saveLocalPost(localPost)
@ -212,6 +220,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
featureImage, featureImage,
altText altText
) )
val ghostTags = extractedTags.map { GhostTag(name = it) }
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = title, title = title,
@ -221,7 +230,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = altText, feature_image_alt = altText,
published_at = state.scheduledAt, published_at = state.scheduledAt,
visibility = "public" visibility = "public",
tags = ghostTags.ifEmpty { null }
) )
val result = if (editingGhostId != null) { val result = if (editingGhostId != null) {
@ -252,6 +262,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl, linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt, scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = offlineQueueStatus queueStatus = offlineQueueStatus
) )
repository.saveLocalPost(localPost) repository.saveLocalPost(localPost)
@ -283,6 +294,7 @@ data class ComposerUiState(
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,
val featured: Boolean = false, val featured: Boolean = false,
val extractedTags: List<String> = emptyList(),
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,

View file

@ -50,7 +50,7 @@ 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 import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun DetailScreen( fun DetailScreen(
post: FeedPost, post: FeedPost,
@ -202,6 +202,29 @@ fun DetailScreen(
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
// Tags
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
SuggestionChip(
onClick = {},
label = {
Text("#$tag", style = MaterialTheme.typography.labelMedium)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
),
border = null
)
}
}
}
// Full image // Full image
if (post.imageUrl != null) { if (post.imageUrl != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View file

@ -23,20 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons 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.Add
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.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
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.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.PushPin
@ -225,6 +211,23 @@ fun FeedScreen(
) )
} }
// Active tag filter bar
if (state.activeTagFilter != null) {
FilterChip(
onClick = { viewModel.clearTagFilter() },
label = { Text("#${state.activeTagFilter}") },
selected = true,
leadingIcon = {
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Show recent searches when search is active but query is empty // Show recent searches when search is active but query is empty
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) { if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
RecentSearchesList( RecentSearchesList(
@ -415,6 +418,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) }, onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post }, onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) }, onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -455,6 +459,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) }, onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post }, onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) }, onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -663,6 +668,7 @@ fun SwipeablePostCard(
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onTogglePin: () -> Unit = {}, onTogglePin: () -> Unit = {},
onTagClick: (String) -> Unit = {},
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null
) { ) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
@ -713,6 +719,7 @@ fun SwipeablePostCard(
onEdit = onEdit, onEdit = onEdit,
onDelete = onDelete, onDelete = onDelete,
onTogglePin = onTogglePin, onTogglePin = onTogglePin,
onTagClick = onTagClick,
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -790,7 +797,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SearchTopBar( fun SearchTopBar(
query: String, query: String,
@ -917,6 +924,7 @@ fun PostCardContent(
onEdit: () -> Unit = {}, onEdit: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onTogglePin: () -> Unit = {}, onTogglePin: () -> Unit = {},
onTagClick: (String) -> Unit = {},
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
highlightQuery: String? = null highlightQuery: String? = null
) { ) {
@ -1060,6 +1068,30 @@ fun PostCardContent(
} }
} }
// Hashtag chips
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
SuggestionChip(
onClick = { onTagClick(tag) },
label = {
Text("#$tag", style = MaterialTheme.typography.labelSmall)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
),
border = null
)
}
}
}
// Link preview // Link preview
if (post.linkUrl != null && post.linkTitle != null) { if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View file

@ -7,8 +7,11 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -205,8 +208,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val filter = _activeFilter.value val filter = _activeFilter.value
val sort = _sortOrder.value val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort).fold( repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response -> onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() } remotePosts = response.posts.map { it.toFeedPost() }
hasMorePages = response.meta?.pagination?.next != null hasMorePages = response.meta?.pagination?.next != null
@ -221,6 +225,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun filterByTag(tag: String) {
_uiState.update { it.copy(activeTagFilter = tag) }
refresh()
}
fun clearTagFilter() {
_uiState.update { it.copy(activeTagFilter = null) }
refresh()
}
private fun classifyError(e: Throwable): Pair<String, Boolean> { private fun classifyError(e: Throwable): Pair<String, Boolean> {
val cause = e.cause ?: e val cause = e.cause ?: e
return when { return when {
@ -249,8 +263,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val filter = _activeFilter.value val filter = _activeFilter.value
val sort = _sortOrder.value val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort).fold( repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response -> onSuccess = { response ->
val newPosts = response.posts.map { it.toFeedPost() } val newPosts = response.posts.map { it.toFeedPost() }
remotePosts = remotePosts + newPosts remotePosts = remotePosts + newPosts
@ -385,8 +400,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
private fun mergePosts(queuedPosts: List<FeedPost>? = null) { private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
val tagFilter = _uiState.value.activeTagFilter
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal } val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
val allPosts = queued + remotePosts // Filter local posts by tag if a tag filter is active
val filteredQueued = if (tagFilter != null) {
queued.filter { post ->
post.tags.any { it.equals(tagFilter, ignoreCase = true) }
}
} else queued
val allPosts = filteredQueued + remotePosts
// Sort: featured/pinned posts first, then chronological // Sort: featured/pinned posts first, then chronological
val sorted = allPosts.sortedWith( val sorted = allPosts.sortedWith(
compareByDescending<FeedPost> { it.featured } compareByDescending<FeedPost> { it.featured }
@ -409,6 +431,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = null, linkTitle = null,
linkDescription = null, linkDescription = null,
linkImageUrl = null, linkImageUrl = null,
tags = tags?.map { it.name } ?: emptyList(),
status = status ?: "draft", status = status ?: "draft",
featured = featured ?: false, featured = featured ?: false,
publishedAt = published_at, publishedAt = published_at,
@ -417,26 +440,34 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = false isLocal = false
) )
private fun LocalPost.toFeedPost(): FeedPost = FeedPost( private fun LocalPost.toFeedPost(): FeedPost {
localId = localId, val tagNames: List<String> = try {
ghostId = ghostId, Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
title = title, } catch (e: Exception) {
textContent = content, emptyList()
htmlContent = htmlContent, }
imageUrl = uploadedImageUrl ?: imageUri, return FeedPost(
imageAlt = imageAlt, localId = localId,
linkUrl = linkUrl, ghostId = ghostId,
linkTitle = linkTitle, title = title,
linkDescription = linkDescription, textContent = content,
linkImageUrl = linkImageUrl, htmlContent = htmlContent,
status = status.name.lowercase(), imageUrl = uploadedImageUrl ?: imageUri,
featured = featured, imageAlt = imageAlt,
publishedAt = null, linkUrl = linkUrl,
createdAt = null, linkTitle = linkTitle,
updatedAt = null, linkDescription = linkDescription,
isLocal = true, linkImageUrl = linkImageUrl,
queueStatus = queueStatus tags = tagNames,
) status = status.name.lowercase(),
featured = featured,
publishedAt = null,
createdAt = null,
updatedAt = null,
isLocal = true,
queueStatus = queueStatus
)
}
companion object { companion object {
/** /**
@ -468,7 +499,8 @@ data class FeedUiState(
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val error: String? = null, val error: String? = null,
val isConnectionError: Boolean = false, val isConnectionError: Boolean = false,
val snackbarMessage: String? = null val snackbarMessage: String? = null,
val activeTagFilter: String? = null
) )
/** /**

View file

@ -5,8 +5,11 @@ import android.net.Uri
import androidx.work.* import androidx.work.*
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.model.GhostPost import com.swoosh.microblog.data.model.GhostPost
import com.swoosh.microblog.data.model.GhostTag
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class PostUploadWorker( class PostUploadWorker(
@ -42,6 +45,14 @@ class PostUploadWorker(
featureImage, post.imageAlt featureImage, post.imageAlt
) )
// Parse tags from JSON stored in LocalPost
val tagNames: List<String> = try {
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
val ghostTags = tagNames.map { GhostTag(name = it) }
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = post.title, title = post.title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,
@ -54,7 +65,8 @@ class PostUploadWorker(
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = post.imageAlt, feature_image_alt = post.imageAlt,
published_at = post.scheduledAt, published_at = post.scheduledAt,
visibility = "public" visibility = "public",
tags = ghostTags.ifEmpty { null }
) )
val result = if (post.ghostId != null) { val result = if (post.ghostId != null) {

View file

@ -0,0 +1,218 @@
package com.swoosh.microblog.data
import org.junit.Assert.*
import org.junit.Test
class HashtagParserTest {
// --- Basic parsing ---
@Test
fun `parse extracts single hashtag`() {
val tags = HashtagParser.parse("Hello #world")
assertEquals(listOf("world"), tags)
}
@Test
fun `parse extracts multiple hashtags`() {
val tags = HashtagParser.parse("Hello #world #kotlin #android")
assertEquals(listOf("world", "kotlin", "android"), tags)
}
@Test
fun `parse returns empty list for text without hashtags`() {
val tags = HashtagParser.parse("Hello world, no tags here!")
assertTrue(tags.isEmpty())
}
@Test
fun `parse returns empty list for blank text`() {
val tags = HashtagParser.parse("")
assertTrue(tags.isEmpty())
val tags2 = HashtagParser.parse(" ")
assertTrue(tags2.isEmpty())
}
@Test
fun `parse strips hash prefix from results`() {
val tags = HashtagParser.parse("#kotlin")
assertEquals("kotlin", tags.first())
assertFalse(tags.first().startsWith("#"))
}
// --- Position handling ---
@Test
fun `parse handles hashtag at start of text`() {
val tags = HashtagParser.parse("#hello this is a post")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtag at end of text`() {
val tags = HashtagParser.parse("This is a post #hello")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtag in middle of text`() {
val tags = HashtagParser.parse("This is a #hello post")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtags on multiple lines`() {
val tags = HashtagParser.parse("Line one #tag1\nLine two #tag2\nLine three #tag3")
assertEquals(listOf("tag1", "tag2", "tag3"), tags)
}
// --- Special characters ---
@Test
fun `parse handles CamelCase hashtags`() {
val tags = HashtagParser.parse("Hello #CamelCase and #helloWorld")
assertEquals(listOf("CamelCase", "helloWorld"), tags)
}
@Test
fun `parse handles hyphenated hashtags`() {
val tags = HashtagParser.parse("Check out #hello-world")
assertEquals(listOf("hello-world"), tags)
}
@Test
fun `parse handles underscored hashtags`() {
val tags = HashtagParser.parse("Check out #hello_world")
assertEquals(listOf("hello_world"), tags)
}
@Test
fun `parse handles hashtags with digits`() {
val tags = HashtagParser.parse("Check out #tag123 and #2024goals")
assertEquals(listOf("tag123", "2024goals"), tags)
}
@Test
fun `parse trims trailing hyphens from tag names`() {
val tags = HashtagParser.parse("Check #hello- there")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse trims trailing underscores from tag names`() {
val tags = HashtagParser.parse("Check #hello_ there")
assertEquals(listOf("hello"), tags)
}
// --- Deduplication ---
@Test
fun `parse deduplicates tags case-insensitively`() {
val tags = HashtagParser.parse("#Hello #hello #HELLO")
assertEquals(1, tags.size)
assertEquals("Hello", tags.first()) // Preserves first occurrence casing
}
@Test
fun `parse preserves order of unique tags`() {
val tags = HashtagParser.parse("#beta #alpha #gamma")
assertEquals(listOf("beta", "alpha", "gamma"), tags)
}
// --- URL exclusion ---
@Test
fun `parse ignores hashtags inside URLs with https`() {
val tags = HashtagParser.parse("Visit https://example.com/page#section and also #realtag")
assertEquals(listOf("realtag"), tags)
}
@Test
fun `parse ignores hashtags inside URLs with http`() {
val tags = HashtagParser.parse("Visit http://example.com/path#anchor and #realtag")
assertEquals(listOf("realtag"), tags)
}
@Test
fun `parse ignores hashtags inside www URLs`() {
val tags = HashtagParser.parse("Visit www.example.com/path#anchor for more #info")
assertEquals(listOf("info"), tags)
}
@Test
fun `parse handles URL followed by real hashtag`() {
val tags = HashtagParser.parse("https://example.com #kotlin")
assertEquals(listOf("kotlin"), tags)
}
// --- Edge cases ---
@Test
fun `parse ignores lone hash symbol`() {
val tags = HashtagParser.parse("Use # wisely")
assertTrue(tags.isEmpty())
}
@Test
fun `parse handles adjacent hashtags`() {
val tags = HashtagParser.parse("#one#two#three")
// #one is parsed, then #two and #three appear inside #one's text or as separate matches
assertTrue(tags.contains("one"))
}
@Test
fun `parse handles hashtag with punctuation after it`() {
val tags = HashtagParser.parse("Check #kotlin, it's great! And #android.")
assertTrue(tags.contains("kotlin"))
assertTrue(tags.contains("android"))
}
@Test
fun `parse handles hashtag in parentheses`() {
val tags = HashtagParser.parse("Languages (#kotlin) are great")
assertTrue(tags.contains("kotlin"))
}
@Test
fun `parse handles real-world microblog post`() {
val text = "Just shipped a new feature for Swoosh! #android #ghostcms #opensource\n\nCheck it out at https://github.com/example/swoosh#readme"
val tags = HashtagParser.parse(text)
assertEquals(3, tags.size)
assertTrue(tags.contains("android"))
assertTrue(tags.contains("ghostcms"))
assertTrue(tags.contains("opensource"))
// Should NOT include 'readme' from the URL
assertFalse(tags.contains("readme"))
}
// --- findHashtagRanges ---
@Test
fun `findHashtagRanges returns correct ranges`() {
val text = "Hello #world and #kotlin"
val ranges = HashtagParser.findHashtagRanges(text)
assertEquals(2, ranges.size)
assertEquals("#world", text.substring(ranges[0]))
assertEquals("#kotlin", text.substring(ranges[1]))
}
@Test
fun `findHashtagRanges returns empty for no hashtags`() {
val ranges = HashtagParser.findHashtagRanges("No hashtags here")
assertTrue(ranges.isEmpty())
}
@Test
fun `findHashtagRanges excludes URL hashtags`() {
val text = "Visit https://example.com#section and #realtag"
val ranges = HashtagParser.findHashtagRanges(text)
assertEquals(1, ranges.size)
assertEquals("#realtag", text.substring(ranges[0]))
}
@Test
fun `findHashtagRanges returns empty for blank text`() {
val ranges = HashtagParser.findHashtagRanges("")
assertTrue(ranges.isEmpty())
}
}

View file

@ -0,0 +1,184 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.junit.Assert.*
import org.junit.Test
class GhostTagTest {
private val gson = Gson()
@Test
fun `GhostTag serializes to JSON correctly`() {
val tag = GhostTag(name = "kotlin")
val json = gson.toJson(tag)
assertTrue(json.contains("\"name\":\"kotlin\""))
}
@Test
fun `GhostTag serializes with all fields`() {
val tag = GhostTag(id = "tag123", name = "kotlin", slug = "kotlin")
val json = gson.toJson(tag)
assertTrue(json.contains("\"id\":\"tag123\""))
assertTrue(json.contains("\"name\":\"kotlin\""))
assertTrue(json.contains("\"slug\":\"kotlin\""))
}
@Test
fun `GhostTag deserializes from JSON`() {
val json = """{"id":"abc","name":"android","slug":"android"}"""
val tag = gson.fromJson(json, GhostTag::class.java)
assertEquals("abc", tag.id)
assertEquals("android", tag.name)
assertEquals("android", tag.slug)
}
@Test
fun `GhostTag deserializes with only name`() {
val json = """{"name":"test-tag"}"""
val tag = gson.fromJson(json, GhostTag::class.java)
assertNull(tag.id)
assertEquals("test-tag", tag.name)
assertNull(tag.slug)
}
@Test
fun `GhostTag list serializes for API payload`() {
val tags = listOf(
GhostTag(name = "kotlin"),
GhostTag(name = "android"),
GhostTag(name = "ghost-cms")
)
val json = gson.toJson(tags)
assertTrue(json.contains("kotlin"))
assertTrue(json.contains("android"))
assertTrue(json.contains("ghost-cms"))
}
@Test
fun `GhostTag list deserializes from API response`() {
val json = """[{"id":"1","name":"kotlin","slug":"kotlin"},{"id":"2","name":"android","slug":"android"}]"""
val type = object : TypeToken<List<GhostTag>>() {}.type
val tags: List<GhostTag> = gson.fromJson(json, type)
assertEquals(2, tags.size)
assertEquals("kotlin", tags[0].name)
assertEquals("android", tags[1].name)
}
@Test
fun `GhostPost with tags serializes correctly for API`() {
val post = GhostPost(
title = "Test Post",
status = "published",
tags = listOf(GhostTag(name = "kotlin"), GhostTag(name = "android"))
)
val wrapper = PostWrapper(listOf(post))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"tags\""))
assertTrue(json.contains("\"kotlin\""))
assertTrue(json.contains("\"android\""))
}
@Test
fun `GhostPost with null tags serializes without tags`() {
val post = GhostPost(title = "Test Post", tags = null)
val json = gson.toJson(post)
// Gson does not serialize null fields by default
assertFalse(json.contains("\"tags\""))
}
@Test
fun `GhostPost with empty tags list serializes with empty array`() {
val post = GhostPost(title = "Test Post", tags = emptyList())
val json = gson.toJson(post)
assertTrue(json.contains("\"tags\":[]"))
}
@Test
fun `GhostPost deserializes tags from API response`() {
val json = """{
"id": "post1",
"title": "Hello",
"tags": [{"id":"t1","name":"kotlin","slug":"kotlin"},{"id":"t2","name":"android","slug":"android"}]
}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNotNull(post.tags)
assertEquals(2, post.tags!!.size)
assertEquals("kotlin", post.tags!![0].name)
assertEquals("android", post.tags!![1].name)
}
@Test
fun `GhostPost without tags field deserializes with null tags`() {
val json = """{"id":"post1","title":"Hello"}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNull(post.tags)
}
@Test
fun `Tag names stored as JSON in LocalPost round-trip correctly`() {
val tagNames = listOf("kotlin", "android", "ghost-cms")
val json = gson.toJson(tagNames)
val type = object : TypeToken<List<String>>() {}.type
val restored: List<String> = gson.fromJson(json, type)
assertEquals(tagNames, restored)
}
@Test
fun `Empty tag list stored as JSON round-trips correctly`() {
val tagNames = emptyList<String>()
val json = gson.toJson(tagNames)
assertEquals("[]", json)
val type = object : TypeToken<List<String>>() {}.type
val restored: List<String> = gson.fromJson(json, type)
assertTrue(restored.isEmpty())
}
@Test
fun `FeedPost default tags is empty list`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertTrue(post.tags.isEmpty())
}
@Test
fun `FeedPost with tags stores them correctly`() {
val post = FeedPost(
title = "Test",
textContent = "Content #kotlin",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
tags = listOf("kotlin", "android"),
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertEquals(2, post.tags.size)
assertEquals("kotlin", post.tags[0])
assertEquals("android", post.tags[1])
}
@Test
fun `LocalPost default tags is empty JSON array`() {
val post = LocalPost()
assertEquals("[]", post.tags)
}
}