feat: add hashtag parsing, highlighting, tag chips, and feed filtering by tag

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:11 +01:00
parent 74f42fd2f1
commit 5a41944a97
No known key found for this signature in database
13 changed files with 742 additions and 37 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,9 +13,10 @@ 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
): Response<PostsResponse> ): Response<PostsResponse>
@POST("ghost/api/admin/posts/") @POST("ghost/api/admin/posts/")

View file

@ -5,9 +5,11 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.swoosh.microblog.data.model.LocalPost import com.swoosh.microblog.data.model.LocalPost
@Database(entities = [LocalPost::class], version = 1, exportSchema = false) @Database(entities = [LocalPost::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
}
}
fun getInstance(context: Context): AppDatabase { fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"swoosh_database" "swoosh_database"
).build() )
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View file

@ -41,7 +41,14 @@ data class GhostPost(
val published_at: String? = null, val published_at: String? = null,
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 tags: List<GhostTag>? = null
)
data class GhostTag(
val id: String? = null,
val name: String,
val slug: String? = null
) )
data class Author( data class Author(
@ -67,6 +74,7 @@ data class LocalPost(
val linkDescription: String? = null, val linkDescription: String? = null,
val linkImageUrl: String? = null, val linkImageUrl: 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
@ -99,6 +107,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 publishedAt: String?, val publishedAt: String?,
val createdAt: String?, val createdAt: String?,

View file

@ -30,10 +30,11 @@ class PostRepository(private val context: Context) {
// --- Remote operations --- // --- Remote operations ---
suspend fun fetchPosts(page: Int = 1, limit: Int = 15): Result<PostsResponse> = suspend fun fetchPosts(page: Int = 1, limit: Int = 15, tagFilter: String? = null): Result<PostsResponse> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val response = getApi().getPosts(limit = limit, page = page) val filter = tagFilter?.let { "tag:$it" }
val response = getApi().getPosts(limit = limit, page = page, filter = filter)
if (response.isSuccessful) { if (response.isSuccessful) {
Result.success(response.body()!!) Result.success(response.body()!!)
} else { } else {

View file

@ -12,16 +12,24 @@ import androidx.compose.material.icons.filled.Close
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.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
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.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 java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@ -81,7 +89,12 @@ 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
val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) {
HashtagVisualTransformation(hashtagColor)
}
OutlinedTextField( OutlinedTextField(
value = state.text, value = state.text,
onValueChange = viewModel::updateText, onValueChange = viewModel::updateText,
@ -89,6 +102,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 = {
Text( Text(
"${state.text.length} characters", "${state.text.length} characters",
@ -100,6 +114,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
@ -353,3 +405,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,11 +4,14 @@ 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.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.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -41,13 +44,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
description = post.linkDescription, description = post.linkDescription,
imageUrl = post.linkImageUrl imageUrl = post.linkImageUrl
) else null, ) else null,
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) }
} }
fun setImage(uri: Uri?) { fun setImage(uri: Uri?) {
@ -88,6 +93,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)
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) { if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
// Save locally // Save locally
@ -103,6 +110,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)
@ -128,6 +136,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview) val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview)
val ghostTags = extractedTags.map { GhostTag(name = it) }
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = title, title = title,
@ -135,7 +144,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
status = status.name.lowercase(), status = status.name.lowercase(),
feature_image = featureImage, feature_image = featureImage,
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) {
@ -164,6 +174,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)
@ -188,6 +199,7 @@ data class ComposerUiState(
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,
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

@ -18,7 +18,7 @@ import com.swoosh.microblog.data.model.FeedPost
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
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun DetailScreen( fun DetailScreen(
post: FeedPost, post: FeedPost,
@ -76,6 +76,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

@ -8,8 +8,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
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.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
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.Tag
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
@ -86,6 +88,23 @@ fun FeedScreen(
.padding(padding) .padding(padding)
.pullRefresh(pullRefreshState) .pullRefresh(pullRefreshState)
) { ) {
// 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)
)
}
if (state.posts.isEmpty() && !state.isRefreshing) { if (state.posts.isEmpty() && !state.isRefreshing) {
if (state.isConnectionError && state.error != null) { if (state.isConnectionError && state.error != null) {
// Connection error empty state // Connection error empty state
@ -145,13 +164,17 @@ fun FeedScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(
top = if (state.activeTagFilter != null) 40.dp else 8.dp,
bottom = 8.dp
)
) { ) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard( PostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) } onCancelQueue = { viewModel.cancelQueuedPost(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) }
) )
} }
@ -191,11 +214,13 @@ fun FeedScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun PostCard( fun PostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit onCancelQueue: () -> Unit,
onTagClick: (String) -> Unit = {}
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) { val displayText = if (expanded || post.textContent.length <= 280) {
@ -262,6 +287,29 @@ fun PostCard(
) )
} }
// Hashtag chips
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
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

@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.feed
import android.app.Application import android.app.Application
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.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.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.ConnectException import java.net.ConnectException
@ -49,7 +52,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
currentPage = 1 currentPage = 1
hasMorePages = true hasMorePages = true
repository.fetchPosts(page = 1).fold( val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = 1, 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
@ -64,6 +68,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 {
@ -90,7 +104,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoadingMore = true) } _uiState.update { it.copy(isLoadingMore = true) }
currentPage++ currentPage++
repository.fetchPosts(page = currentPage).fold( val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = currentPage, 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
@ -133,8 +148,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
_uiState.update { it.copy(posts = allPosts) } _uiState.update { it.copy(posts = allPosts) }
} }
@ -148,6 +170,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",
publishedAt = published_at, publishedAt = published_at,
createdAt = created_at, createdAt = created_at,
@ -155,7 +178,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = false isLocal = false
) )
private fun LocalPost.toFeedPost(): FeedPost = FeedPost( private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
return FeedPost(
localId = localId, localId = localId,
ghostId = ghostId, ghostId = ghostId,
title = title, title = title,
@ -166,6 +195,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = linkTitle, linkTitle = linkTitle,
linkDescription = linkDescription, linkDescription = linkDescription,
linkImageUrl = linkImageUrl, linkImageUrl = linkImageUrl,
tags = tagNames,
status = status.name.lowercase(), status = status.name.lowercase(),
publishedAt = null, publishedAt = null,
createdAt = null, createdAt = null,
@ -174,13 +204,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
queueStatus = queueStatus queueStatus = queueStatus
) )
} }
}
data class FeedUiState( data class FeedUiState(
val posts: List<FeedPost> = emptyList(), val posts: List<FeedPost> = emptyList(),
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
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 activeTagFilter: String? = null
) )
fun formatRelativeTime(isoString: String?): String { fun formatRelativeTime(isoString: String?): String {

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(
@ -39,6 +42,14 @@ class PostUploadWorker(
val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription) val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription)
// 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,
@ -49,7 +60,8 @@ class PostUploadWorker(
}, },
feature_image = featureImage, feature_image = featureImage,
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)
}
}