From ff0641b9975c0c18477aeb1d5154eab3b25e6aab Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:16:02 +0900 Subject: [PATCH 01/10] Add bookmark data support Add bookmark fields and mutations to the GraphQL layer. Extend repository and overlay mapping so screens can load and optimistically reflect bookmark state. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../pub/hackers/android/operations.graphql | 45 ++++++++++++ .../pub/hackers/android/schema.graphqls | 57 +++++++++++++++- .../android/data/paging/CursorPagingSource.kt | 6 ++ .../android/data/paging/PostOverlay.kt | 2 + .../data/repository/HackersPubRepository.kt | 68 +++++++++++++++++++ .../hackers/android/domain/model/Models.kt | 1 + 6 files changed, 178 insertions(+), 1 deletion(-) diff --git a/app/src/main/graphql/pub/hackers/android/operations.graphql b/app/src/main/graphql/pub/hackers/android/operations.graphql index c32b09e1..267eb133 100644 --- a/app/src/main/graphql/pub/hackers/android/operations.graphql +++ b/app/src/main/graphql/pub/hackers/android/operations.graphql @@ -33,6 +33,7 @@ fragment PostFields on Post { url iri viewerHasShared + viewerHasBookmarked actor { ...ActorFields } @@ -102,6 +103,7 @@ fragment SharedPostFields on Post { url iri viewerHasShared + viewerHasBookmarked actor { ...ActorFields } @@ -462,6 +464,24 @@ query PostReplies($id: ID!, $after: String) { } } +query Bookmarks($after: String, $postType: PostType) { + bookmarks(first: 20, after: $after, postType: $postType) { + edges { + cursor + node { + ...PostFields + sharedPost { + ...SharedPostFields + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} + mutation LoginByUsername($username: String!, $locale: Locale!, $verifyUrl: URITemplate!) { loginByUsername(username: $username, locale: $locale, verifyUrl: $verifyUrl) { ... on LoginChallenge { @@ -519,6 +539,31 @@ mutation VerifyPasskeyRegistration($accountId: ID!, $name: String!, $registratio } } +mutation BookmarkPost($postId: ID!) { + bookmarkPost(input: { postId: $postId }) { + __typename + ... on BookmarkPostPayload { + post { + id + viewerHasBookmarked + } + } + } +} + +mutation UnbookmarkPost($postId: ID!) { + unbookmarkPost(input: { postId: $postId }) { + __typename + ... on UnbookmarkPostPayload { + post { + id + viewerHasBookmarked + } + unbookmarkedPostId + } + } +} + mutation RevokePasskey($passkeyId: ID!) { revokePasskey(passkeyId: $passkeyId) } diff --git a/app/src/main/graphql/pub/hackers/android/schema.graphqls b/app/src/main/graphql/pub/hackers/android/schema.graphqls index e16478ef..6b3b3397 100644 --- a/app/src/main/graphql/pub/hackers/android/schema.graphqls +++ b/app/src/main/graphql/pub/hackers/android/schema.graphqls @@ -484,6 +484,8 @@ type Article implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -561,6 +563,20 @@ type BlockActorPayload { union BlockActorResult = BlockActorPayload|InvalidInputError|NotAuthenticatedError +input BookmarkPostInput { + clientMutationId: ID + + postId: ID! +} + +type BookmarkPostPayload { + clientMutationId: ID + + post: Post! +} + +union BookmarkPostResult = BookmarkPostPayload|InvalidInputError|NotAuthenticatedError + union CreateInvitationLinkResult = InvalidInputError|InvitationLinkPayload|NotAuthenticatedError input CreateNoteInput { @@ -885,6 +901,8 @@ type Mutation { blockActor(input: BlockActorInput!): BlockActorResult! + bookmarkPost(input: BookmarkPostInput!): BookmarkPostResult! + completeLoginChallenge("The code of the login challenge." code: String!, "The token of the login challenge." token: UUID!): Session """ @@ -939,6 +957,8 @@ type Mutation { unblockActor(input: UnblockActorInput!): UnblockActorResult! + unbookmarkPost(input: UnbookmarkPostInput!): UnbookmarkPostResult! + unfollowActor(input: UnfollowActorInput!): UnfollowActorResult! unregisterApnsDeviceToken(input: UnregisterApnsDeviceTokenInput!): UnregisterApnsDeviceTokenResult! @@ -1011,6 +1031,8 @@ type Note implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1207,6 +1229,8 @@ interface Post implements Node & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1381,6 +1405,8 @@ type Query { availableLocales: [Locale!]! + bookmarks(after: String, before: String, first: Int, last: Int, postType: PostType): QueryBookmarksConnection! + codeOfConduct("The locale for the Code of Conduct." locale: Locale!): Document! instanceByHost(host: String!): Instance @@ -1429,6 +1455,18 @@ type Query { viewer: Account } +type QueryBookmarksConnection { + edges: [QueryBookmarksConnectionEdge!]! + + pageInfo: PageInfo! +} + +type QueryBookmarksConnectionEdge { + cursor: String! + + node: Post! +} + type QueryPersonalTimelineConnection { edges: [QueryPersonalTimelineConnectionEdge!]! @@ -1530,6 +1568,8 @@ type Question implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1896,6 +1936,22 @@ type UnblockActorPayload { union UnblockActorResult = InvalidInputError|NotAuthenticatedError|UnblockActorPayload +input UnbookmarkPostInput { + clientMutationId: ID + + postId: ID! +} + +type UnbookmarkPostPayload { + clientMutationId: ID + + post: Post! + + unbookmarkedPostId: ID! +} + +union UnbookmarkPostResult = InvalidInputError|NotAuthenticatedError|UnbookmarkPostPayload + input UnfollowActorInput { actorId: ID! @@ -2023,4 +2079,3 @@ type WebFingerResult { url: URL } - diff --git a/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt b/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt index d0e7dd7a..245033f0 100644 --- a/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt +++ b/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt @@ -115,4 +115,10 @@ suspend fun HackersPubRepository.actorArticlesPage(handle: String, after: String getActorArticles(handle, after) .map { CursorPage(it.posts, it.endCursor, it.hasNextPage) } +suspend fun HackersPubRepository.bookmarksPage( + after: String?, + postType: pub.hackers.android.graphql.type.PostType?, +) = getBookmarks(after, postType) + .map { CursorPage(it.posts, it.endCursor, it.hasNextPage) } + // endregion diff --git a/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt b/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt index 2585337b..971b1256 100644 --- a/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt +++ b/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt @@ -22,6 +22,7 @@ import pub.hackers.android.domain.model.ReactionGroup @Immutable data class PostOverlay( val viewerHasShared: Boolean? = null, // null = no override + val viewerHasBookmarked: Boolean? = null, // null = no override val shareDelta: Int = 0, // added to engagementStats.shares val reactionOverride: List? = null, // full replacement when we touched reactions val reactionCountOverride: Int? = null, // engagementStats.reactions override @@ -34,6 +35,7 @@ fun Post.applyOverlay(overlay: PostOverlay?): Post { if (overlay == null) return this return copy( viewerHasShared = overlay.viewerHasShared ?: viewerHasShared, + viewerHasBookmarked = overlay.viewerHasBookmarked ?: viewerHasBookmarked, engagementStats = engagementStats.copy( shares = maxOf(0, engagementStats.shares + overlay.shareDelta), reactions = overlay.reactionCountOverride ?: engagementStats.reactions, diff --git a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt index 142b2d5f..64613e19 100644 --- a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt +++ b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt @@ -14,6 +14,8 @@ import pub.hackers.android.graphql.ActorByHandleQuery import pub.hackers.android.graphql.ActorNotesQuery import pub.hackers.android.graphql.ActorPostsQuery import pub.hackers.android.graphql.AddReactionToPostMutation +import pub.hackers.android.graphql.BookmarkPostMutation +import pub.hackers.android.graphql.BookmarksQuery import pub.hackers.android.graphql.BlockActorMutation import pub.hackers.android.graphql.CompleteLoginChallengeMutation import pub.hackers.android.graphql.CreateNoteMutation @@ -50,6 +52,7 @@ import pub.hackers.android.graphql.SearchPostQuery import pub.hackers.android.graphql.SharePostMutation import pub.hackers.android.graphql.UnblockActorMutation import pub.hackers.android.graphql.UnfollowActorMutation +import pub.hackers.android.graphql.UnbookmarkPostMutation import pub.hackers.android.graphql.UnsharePostMutation import pub.hackers.android.graphql.UpdateAccountMutation import pub.hackers.android.graphql.ViewerQuery @@ -196,6 +199,36 @@ class HackersPubRepository @Inject constructor( } } + suspend fun bookmarkPost(postId: String): Result { + return try { + val response = apolloClient.mutation(BookmarkPostMutation(postId)).execute() + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else if (response.data?.bookmarkPost?.onBookmarkPostPayload != null) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to bookmark")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun unbookmarkPost(postId: String): Result { + return try { + val response = apolloClient.mutation(UnbookmarkPostMutation(postId)).execute() + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else if (response.data?.unbookmarkPost?.onUnbookmarkPostPayload != null) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to remove bookmark")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun searchPosts( query: String, languages: List @@ -221,6 +254,39 @@ class HackersPubRepository @Inject constructor( } } + suspend fun getBookmarks( + after: String? = null, + postType: pub.hackers.android.graphql.type.PostType? = null, + ): Result { + return try { + val response = apolloClient.query( + BookmarksQuery( + after = Optional.presentIfNotNull(after), + postType = Optional.presentIfNotNull(postType) + ) + ).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else { + val data = response.data?.bookmarks + withContext(Dispatchers.Default) { + Result.success( + TimelineResult( + posts = data?.edges?.map { edge -> + edge.node.postFields.toPost(edge.node.sharedPost?.sharedPostFields?.toPost()) + } ?: emptyList(), + hasNextPage = data?.pageInfo?.hasNextPage ?: false, + endCursor = data?.pageInfo?.endCursor + ) + ) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun getPostDetail(id: String, repliesAfter: String? = null): Result { return try { val response = apolloClient.query( @@ -1399,6 +1465,7 @@ class HackersPubRepository @Inject constructor( url = url?.toString(), iri = iri.toString(), viewerHasShared = viewerHasShared, + viewerHasBookmarked = viewerHasBookmarked, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, link = link?.let { l -> @@ -1465,6 +1532,7 @@ class HackersPubRepository @Inject constructor( url = url?.toString(), iri = iri.toString(), viewerHasShared = viewerHasShared, + viewerHasBookmarked = viewerHasBookmarked, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, engagementStats = engagementStats.engagementStatsFields.toEngagementStats(), diff --git a/app/src/main/java/pub/hackers/android/domain/model/Models.kt b/app/src/main/java/pub/hackers/android/domain/model/Models.kt index 46496523..de70bd1c 100644 --- a/app/src/main/java/pub/hackers/android/domain/model/Models.kt +++ b/app/src/main/java/pub/hackers/android/domain/model/Models.kt @@ -78,6 +78,7 @@ data class Post( val url: String?, val iri: String? = null, val viewerHasShared: Boolean, + val viewerHasBookmarked: Boolean = false, val actor: Actor, val media: List, val link: PostLink? = null, From 54ca6dbe2e9c051b59cf0c25547ca78560e86b93 Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:16:19 +0900 Subject: [PATCH 02/10] Add bookmark actions to post surfaces Show bookmark controls in cards and post detail actions. Hook optimistic toggles into the existing feed and detail view models so bookmark state updates immediately. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../android/ui/components/ArticleCard.kt | 13 +++++++ .../hackers/android/ui/components/PostCard.kt | 38 +++++++++++++++++++ .../ui/screens/explore/ExploreScreen.kt | 18 +++++++++ .../ui/screens/explore/ExploreViewModel.kt | 22 +++++++++++ .../ui/screens/postdetail/PostDetailScreen.kt | 25 ++++++++++++ .../screens/postdetail/PostDetailViewModel.kt | 25 ++++++++++++ .../ui/screens/profile/ProfileViewModel.kt | 22 +++++++++++ .../ui/screens/timeline/TimelineScreen.kt | 16 ++++++++ .../ui/screens/timeline/TimelineViewModel.kt | 22 +++++++++++ app/src/main/res/values/strings.xml | 10 +++++ .../postdetail/PostDetailContentTest.kt | 1 + 11 files changed, 212 insertions(+) diff --git a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt index a8ae22c6..83376c8f 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt @@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Card @@ -57,6 +59,7 @@ fun ArticleCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null ) { val displayPost = post.sharedPost ?: post @@ -218,6 +221,7 @@ fun ArticleCard( onShareClick = onShareClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick ) } @@ -232,11 +236,13 @@ private fun ArticleEngagementBar( onShareClick: (() -> Unit)?, onReactionClick: (() -> Unit)?, onReactionLongPress: (() -> Unit)?, + onBookmarkClick: (() -> Unit)?, onExternalShareClick: (() -> Unit)? ) { val colors = LocalAppColors.current val isShared = post.viewerHasShared val isReacted = post.reactionGroups.any { it.viewerHasReacted } + val isBookmarked = post.viewerHasBookmarked Row( modifier = Modifier @@ -274,6 +280,13 @@ private fun ArticleEngagementBar( tint = if (isReacted) colors.reaction else colors.textSecondary ) } + IconButton(onClick = { onBookmarkClick?.invoke() }, enabled = onBookmarkClick != null) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (isBookmarked) colors.accent else colors.textSecondary + ) + } if (onExternalShareClick != null) { IconButton(onClick = onExternalShareClick) { Icon( diff --git a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt index 14aa4613..0329f690 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt @@ -36,9 +36,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FormatQuote @@ -107,6 +109,7 @@ fun PostCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null, onQuotedPostClick: ((String) -> Unit)? = null, contentMaxLength: Int = 0 @@ -123,6 +126,7 @@ fun PostCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick, modifier = modifier ) @@ -136,6 +140,7 @@ fun PostCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick, onQuotedPostClick = onQuotedPostClick, contentMaxLength = contentMaxLength, @@ -155,6 +160,7 @@ private fun NoteCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null, onQuotedPostClick: ((String) -> Unit)? = null, contentMaxLength: Int = 0 @@ -541,6 +547,7 @@ private fun NoteCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick ) } @@ -557,12 +564,14 @@ private fun EngagementBar( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null ) { val colors = LocalAppColors.current val isReplied = post.engagementStats.replies > 0 && post.replyTarget != null val isShared = post.viewerHasShared val isReacted = post.reactionGroups.any { it.viewerHasReacted } + val isBookmarked = post.viewerHasBookmarked Row( modifier = Modifier @@ -596,6 +605,11 @@ private fun EngagementBar( onLongClick = onReactionLongPress ) + BookmarkEngagementButton( + isBookmarked = isBookmarked, + onClick = onBookmarkClick, + ) + Spacer(modifier = Modifier.weight(1f)) // External share — always textSecondary, offset back to align right edge @@ -615,6 +629,30 @@ private fun EngagementBar( } } +@Composable +private fun BookmarkEngagementButton( + isBookmarked: Boolean, + onClick: (() -> Unit)?, +) { + val colors = LocalAppColors.current + val tint = if (isBookmarked) colors.accent else colors.textSecondary + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(enabled = onClick != null) { onClick?.invoke() } + ) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = tint, + modifier = Modifier.size(20.dp) + ) + } +} + @Composable private fun EngagementButton( icon: androidx.compose.ui.graphics.vector.ImageVector, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt index ce2d5669..afa8c82c 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt @@ -1,6 +1,7 @@ package pub.hackers.android.ui.screens.explore import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -215,6 +216,23 @@ fun ExploreScreen( ) } } else null, + onBookmarkClick = if (isLoggedIn) { + { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + context.getString( + if (displayPost.viewerHasBookmarked) { + R.string.bookmark_removed + } else { + R.string.bookmarked + } + ), + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + } + } else null, onExternalShareClick = { val displayPost = post.sharedPost ?: post val shareUrl = displayPost.url ?: displayPost.iri diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt index 4fded8cd..3cc1dc82 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt @@ -103,6 +103,28 @@ class ExploreViewModel @Inject constructor( toggleReaction(post, "❤️") } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + val willBookmark = !target.viewerHasBookmarked + + overlayStore.mutate(target.id) { + it.copy(viewerHasBookmarked = willBookmark) + } + + viewModelScope.launch { + val result = if (willBookmark) { + repository.bookmarkPost(target.id) + } else { + repository.unbookmarkPost(target.id) + } + result.onFailure { + overlayStore.mutate(target.id) { prev -> + prev.copy(viewerHasBookmarked = !willBookmark) + } + } + } + } + /** * Optimistically toggle a reaction on [post] (or its sharedPost target). * The overlay is computed from the post's current reactionGroups (possibly diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index 49407dd4..af495614 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.text.Html import android.webkit.WebView +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -33,11 +34,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.FormatQuote import androidx.compose.material.icons.outlined.Group import androidx.compose.material.icons.outlined.Lock @@ -485,6 +488,20 @@ fun PostDetailScreen( }, onReactionClick = { group -> viewModel.showReactorsSheet(group) }, onReactionPickerClick = { viewModel.toggleReactionPicker() }, + onBookmarkClick = { + Toast.makeText( + context, + context.getString( + if (resolvedPost.viewerHasBookmarked) { + R.string.bookmark_removed + } else { + R.string.bookmarked + } + ), + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark() + }, onQuoteClick = { onQuoteClick(postId) }, onSharesClick = { viewModel.showSharesSheet() }, onQuotesClick = { viewModel.showQuotesSheet() }, @@ -571,6 +588,7 @@ internal fun PostDetailContent( onReplyClick: () -> Unit, onReactionClick: (ReactionGroup) -> Unit, onReactionPickerClick: () -> Unit, + onBookmarkClick: () -> Unit, onQuoteClick: () -> Unit, onSharesClick: () -> Unit, onQuotesClick: () -> Unit, @@ -977,6 +995,13 @@ internal fun PostDetailContent( colors.textSecondary ) } + IconButton(onClick = onBookmarkClick) { + Icon( + imageVector = if (post.viewerHasBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (post.viewerHasBookmarked) colors.accent else colors.textSecondary + ) + } IconButton(onClick = onQuoteClick) { Icon( imageVector = Icons.Outlined.FormatQuote, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt index fad2859b..1ce3e8c7 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt @@ -286,6 +286,31 @@ class PostDetailViewModel @Inject constructor( } } + fun toggleBookmark() { + val post = _uiState.value.post ?: return + val willBookmark = !post.viewerHasBookmarked + + _uiState.update { + it.copy(post = post.copy(viewerHasBookmarked = willBookmark)) + } + + viewModelScope.launch { + val result = if (willBookmark) { + repository.bookmarkPost(postId) + } else { + repository.unbookmarkPost(postId) + } + + result.onFailure { + _uiState.update { state -> + state.copy( + post = state.post?.copy(viewerHasBookmarked = !willBookmark) + ) + } + } + } + } + fun toggleReaction(emoji: String) { if (_uiState.value.isReacting) return diff --git a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt index 1f8af840..4ca5cd0f 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt @@ -270,6 +270,28 @@ class ProfileViewModel @Inject constructor( } } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + val willBookmark = !target.viewerHasBookmarked + + overlayStore.mutate(target.id) { + it.copy(viewerHasBookmarked = willBookmark) + } + + viewModelScope.launch { + val result = if (willBookmark) { + repository.bookmarkPost(target.id) + } else { + repository.unbookmarkPost(target.id) + } + result.onFailure { + overlayStore.mutate(target.id) { prev -> + prev.copy(viewerHasBookmarked = !willBookmark) + } + } + } + } + @Suppress("unused") fun toggleReaction(post: Post, emoji: String) { val target = post.sharedPost ?: post diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt index da7bb351..d1765dda 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt @@ -1,6 +1,7 @@ package pub.hackers.android.ui.screens.timeline import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -264,6 +265,21 @@ fun TimelineScreen( onReactionLongPress = { viewModel.showReactionPicker(post.sharedPost?.id ?: post.id) }, + onBookmarkClick = { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + context.getString( + if (displayPost.viewerHasBookmarked) { + R.string.bookmark_removed + } else { + R.string.bookmarked + } + ), + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + }, onExternalShareClick = { val displayPost = post.sharedPost ?: post val shareUrl = displayPost.url ?: displayPost.iri diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt index 91f13429..0f9148b7 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt @@ -122,6 +122,28 @@ class TimelineViewModel @Inject constructor( toggleReaction(post, "❤️") } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + val willBookmark = !target.viewerHasBookmarked + + overlayStore.mutate(target.id) { + it.copy(viewerHasBookmarked = willBookmark) + } + + viewModelScope.launch { + val result = if (willBookmark) { + repository.bookmarkPost(target.id) + } else { + repository.unbookmarkPost(target.id) + } + result.onFailure { + overlayStore.mutate(target.id) { prev -> + prev.copy(viewerHasBookmarked = !willBookmark) + } + } + } + } + /** * Optimistically toggle a reaction on [post] (or its sharedPost target). * The overlay is computed from the post's current reactionGroups (possibly diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 994613d0..cd28dce3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,16 @@ Article Read full article Read on web + Bookmark + Bookmarks + Bookmarked + Bookmark removed + Failed to bookmark + Failed to remove bookmark + No bookmarks yet + All + Articles + Notes Replies Shares Reactions diff --git a/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt b/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt index f12db52b..26eb542d 100644 --- a/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt +++ b/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt @@ -158,6 +158,7 @@ class PostDetailContentTest { onReplyClick = {}, onReactionClick = {}, onReactionPickerClick = {}, + onBookmarkClick = {}, onQuoteClick = {}, onSharesClick = {}, onQuotesClick = {}, From b34566f79e23bd725c8a35e39c972c95aa273cfa Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:16:37 +0900 Subject: [PATCH 03/10] Add bookmarks screen and settings entry Add a dedicated bookmarks screen with All, Articles, and Notes tabs. Wire it into Settings so bookmarked posts remain discoverable from the app. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../pub/hackers/android/ui/HackersPubApp.kt | 25 ++ .../ui/screens/bookmarks/BookmarksScreen.kt | 253 ++++++++++++++++++ .../screens/bookmarks/BookmarksViewModel.kt | 198 ++++++++++++++ .../ui/screens/settings/SettingsScreen.kt | 24 ++ 4 files changed, 500 insertions(+) create mode 100644 app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt create mode 100644 app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt diff --git a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt index 021c2c78..c737fca9 100644 --- a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt +++ b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt @@ -41,6 +41,7 @@ import pub.hackers.android.ui.components.BottomNavItem import pub.hackers.android.ui.components.LocalFontScale import pub.hackers.android.ui.components.ProvideInAppBrowserUriHandler import pub.hackers.android.ui.screens.auth.SignInScreen +import pub.hackers.android.ui.screens.bookmarks.BookmarksScreen import pub.hackers.android.ui.screens.compose.ComposeArticleScreen import pub.hackers.android.ui.screens.compose.ComposeScreen import pub.hackers.android.ui.screens.drafts.DraftsScreen @@ -144,6 +145,7 @@ sealed class DetailScreen(val route: String) { } } data object Drafts : DetailScreen("drafts") + data object Bookmarks : DetailScreen("bookmarks") data object EditProfile : DetailScreen("edit-profile") data object WebView : DetailScreen("webview?url={url}") { fun createRoute(url: String): String { @@ -447,6 +449,9 @@ fun HackersPubApp( onDraftsClick = { navController.navigate(DetailScreen.Drafts.route) }, + onBookmarksClick = { + navController.navigate(DetailScreen.Bookmarks.route) + }, onLicensesClick = { navController.navigate(DetailScreen.Licenses.route) }, @@ -647,6 +652,26 @@ fun HackersPubApp( ) } + composable(DetailScreen.Bookmarks.route) { + BookmarksScreen( + onNavigateBack = { + navController.popBackStack() + }, + onPostClick = { postId -> + navController.navigate(DetailScreen.PostDetail.createRoute(postId)) + }, + onProfileClick = { handle -> + navController.navigate(DetailScreen.Profile.createRoute(handle)) + }, + onReplyClick = { postId -> + navController.navigate(DetailScreen.Compose.createRoute(replyTo = postId)) + }, + onQuoteClick = { postId -> + navController.navigate(DetailScreen.Compose.createRoute(quoteOf = postId)) + }, + ) + } + composable(DetailScreen.RecommendedActors.route) { RecommendedActorsScreen( onNavigateBack = { diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt new file mode 100644 index 00000000..3a3b2896 --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt @@ -0,0 +1,253 @@ +package pub.hackers.android.ui.screens.bookmarks + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import pub.hackers.android.R +import pub.hackers.android.ui.components.ErrorMessage +import pub.hackers.android.ui.components.FullScreenLoading +import pub.hackers.android.ui.components.LargeTitleHeader +import pub.hackers.android.ui.components.LoadingItem +import pub.hackers.android.ui.components.PostCard +import pub.hackers.android.ui.components.ReactionPicker +import pub.hackers.android.ui.theme.LocalAppColors +import pub.hackers.android.ui.theme.LocalAppTypography + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookmarksScreen( + onNavigateBack: () -> Unit, + onPostClick: (String) -> Unit, + onProfileClick: (String) -> Unit, + onReplyClick: (String) -> Unit, + onQuoteClick: (String) -> Unit, + viewModel: BookmarksViewModel = hiltViewModel(), +) { + val items = viewModel.posts.collectAsLazyPagingItems() + val selectedTab by viewModel.selectedTab.collectAsState() + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + val context = LocalContext.current + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + + LaunchedEffect(selectedTab) { + listState.scrollToItem(0) + } + + val pickerPostId = uiState.reactionPickerPostId + if (pickerPostId != null) { + val pickerPost = items.itemSnapshotList.items.find { + it.id == pickerPostId || it.sharedPost?.id == pickerPostId + } + val targetPost = pickerPost?.sharedPost ?: pickerPost + ModalBottomSheet( + onDismissRequest = { viewModel.hideReactionPicker() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + ReactionPicker( + reactionGroups = targetPost?.reactionGroups ?: emptyList(), + isSubmitting = false, + onEmojiSelect = { emoji -> + pickerPost?.let { viewModel.toggleReaction(it, emoji) } + }, + onClose = { viewModel.hideReactionPicker() } + ) + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + LargeTitleHeader( + title = stringResource(R.string.bookmarks), + leadingContent = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = colors.accent + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + BookmarkTab.entries.forEach { tab -> + val isSelected = selectedTab == tab + Column( + modifier = Modifier + .weight(1f) + .clickable { viewModel.selectTab(tab) }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = when (tab) { + BookmarkTab.ALL -> stringResource(R.string.all) + BookmarkTab.ARTICLES -> stringResource(R.string.articles) + BookmarkTab.NOTES -> stringResource(R.string.notes) + }, + style = if (isSelected) typography.bodyLargeSemiBold else typography.bodyLarge, + color = if (isSelected) colors.accent else colors.textSecondary, + modifier = Modifier.padding(vertical = 12.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(if (isSelected) colors.accent else colors.surface) + ) + } + } + } + + HorizontalDivider( + color = colors.divider, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Box(modifier = Modifier.fillMaxSize()) { + val refresh = items.loadState.refresh + when { + refresh is LoadState.Error && items.itemCount == 0 -> { + ErrorMessage( + message = refresh.error.message ?: stringResource(R.string.error_generic), + onRetry = { items.refresh() } + ) + } + + items.itemCount == 0 && refresh is LoadState.NotLoading && refresh.endOfPaginationReached -> { + ErrorMessage( + message = stringResource(R.string.no_bookmarks), + onRefresh = { items.refresh() } + ) + } + + items.itemCount == 0 -> { + FullScreenLoading() + } + + else -> { + PullToRefreshBox( + isRefreshing = refresh is LoadState.Loading, + onRefresh = { items.refresh() } + ) { + LazyColumn(state = listState) { + items( + count = items.itemCount, + key = items.itemKey { it.sharedPost?.id ?: it.id } + ) { index -> + val post = items[index] ?: return@items + PostCard( + post = post, + onClick = { onPostClick(post.sharedPost?.id ?: post.id) }, + onProfileClick = onProfileClick, + onReplyClick = { + onReplyClick(post.sharedPost?.id ?: post.id) + }, + onShareClick = { + val targetId = post.sharedPost?.id ?: post.id + if (post.viewerHasShared) { + viewModel.unsharePost(targetId) + } else { + viewModel.sharePost(targetId) + } + }, + onQuoteClick = { + onQuoteClick(post.sharedPost?.id ?: post.id) + }, + onReactionClick = { + viewModel.toggleFavourite(post) + }, + onReactionLongPress = { + viewModel.showReactionPicker(post.sharedPost?.id ?: post.id) + }, + onBookmarkClick = { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + context.getString( + if (displayPost.viewerHasBookmarked) { + R.string.bookmark_removed + } else { + R.string.bookmarked + } + ), + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + }, + onExternalShareClick = { + val displayPost = post.sharedPost ?: post + val shareUrl = displayPost.url ?: displayPost.iri + if (shareUrl != null) { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareUrl) + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, null)) + } + }, + onQuotedPostClick = onPostClick + ) + HorizontalDivider( + color = colors.divider, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + + if (items.loadState.append is LoadState.Loading) { + item { LoadingItem() } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt new file mode 100644 index 00000000..53a80866 --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt @@ -0,0 +1,198 @@ +package pub.hackers.android.ui.screens.bookmarks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pub.hackers.android.data.paging.PostOverlayStore +import pub.hackers.android.data.paging.applyOverlays +import pub.hackers.android.data.paging.bookmarksPage +import pub.hackers.android.data.paging.cursorPager +import pub.hackers.android.data.paging.distinctByEffectiveId +import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.Post +import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.graphql.type.PostType as GqlPostType +import javax.inject.Inject + +enum class BookmarkTab { + ALL, ARTICLES, NOTES +} + +data class BookmarksUiState( + val reactionPickerPostId: String? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class BookmarksViewModel @Inject constructor( + private val repository: HackersPubRepository, +) : ViewModel() { + + private val _selectedTab = MutableStateFlow(BookmarkTab.ALL) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val _uiState = MutableStateFlow(BookmarksUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val overlayStore = PostOverlayStore() + + val posts: Flow> = _selectedTab + .flatMapLatest { tab -> + cursorPager { after -> + repository.bookmarksPage(after, tab.toGraphqlPostType()) + }.flow.distinctByEffectiveId().cachedIn(viewModelScope) + } + .combine(overlayStore.overlays) { paging, overlays -> + paging + .map { post -> post.applyOverlays(overlays) } + .filter { post -> (post.sharedPost ?: post).viewerHasBookmarked } + } + .cachedIn(viewModelScope) + + fun selectTab(tab: BookmarkTab) { + if (_selectedTab.value != tab) _selectedTab.value = tab + } + + fun sharePost(postId: String) { + overlayStore.mutate(postId) { + it.copy(viewerHasShared = true, shareDelta = it.shareDelta + 1) + } + viewModelScope.launch { + repository.sharePost(postId).onFailure { + overlayStore.mutate(postId) { prev -> + prev.copy( + viewerHasShared = false, + shareDelta = prev.shareDelta - 1, + ) + } + } + } + } + + fun unsharePost(postId: String) { + overlayStore.mutate(postId) { + it.copy(viewerHasShared = false, shareDelta = it.shareDelta - 1) + } + viewModelScope.launch { + repository.unsharePost(postId).onFailure { + overlayStore.mutate(postId) { prev -> + prev.copy( + viewerHasShared = true, + shareDelta = prev.shareDelta + 1, + ) + } + } + } + } + + fun toggleFavourite(post: Post) { + toggleReaction(post, "❤️") + } + + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + val willBookmark = !target.viewerHasBookmarked + + overlayStore.mutate(target.id) { + it.copy(viewerHasBookmarked = willBookmark) + } + + viewModelScope.launch { + val result = if (willBookmark) { + repository.bookmarkPost(target.id) + } else { + repository.unbookmarkPost(target.id) + } + result.onFailure { + overlayStore.mutate(target.id) { prev -> + prev.copy(viewerHasBookmarked = !willBookmark) + } + } + } + } + + fun toggleReaction(post: Post, emoji: String) { + val target = post.sharedPost ?: post + val existing = target.reactionGroups.find { it.emoji == emoji } + val wasReacted = existing?.viewerHasReacted == true + + val updatedGroups = computeToggledReactionGroups(target.reactionGroups, emoji, wasReacted) + + overlayStore.mutate(target.id) { prev -> + prev.copy( + reactionOverride = updatedGroups, + reactionCountOverride = updatedGroups.sumOf { it.count }, + ) + } + _uiState.update { it.copy(reactionPickerPostId = null) } + + viewModelScope.launch { + val result = if (wasReacted) { + repository.removeReactionFromPost(target.id, emoji) + } else { + repository.addReactionToPost(target.id, emoji) + } + result.onFailure { + overlayStore.clear(target.id) + } + } + } + + fun showReactionPicker(postId: String) { + _uiState.update { it.copy(reactionPickerPostId = postId) } + } + + fun hideReactionPicker() { + _uiState.update { it.copy(reactionPickerPostId = null) } + } + + private fun computeToggledReactionGroups( + groups: List, + emoji: String, + wasReacted: Boolean, + ): List { + return if (wasReacted) { + groups.map { group -> + if (group.emoji == emoji) { + group.copy(count = maxOf(0, group.count - 1), viewerHasReacted = false) + } else group + }.filter { it.count > 0 || it.viewerHasReacted } + } else { + val existing = groups.find { it.emoji == emoji } + if (existing != null) { + groups.map { group -> + if (group.emoji == emoji) { + group.copy(count = group.count + 1, viewerHasReacted = true) + } else group + } + } else { + groups + ReactionGroup( + emoji = emoji, + customEmoji = null, + count = 1, + reactors = emptyList(), + viewerHasReacted = true, + ) + } + } + } + + private fun BookmarkTab.toGraphqlPostType(): GqlPostType? = when (this) { + BookmarkTab.ALL -> null + BookmarkTab.ARTICLES -> GqlPostType.ARTICLE + BookmarkTab.NOTES -> GqlPostType.NOTE + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt index 64717636..ed1067d6 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.LibraryBooks import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Info @@ -67,6 +68,7 @@ fun SettingsScreen( onProfileClick: (String) -> Unit, onNavigateBack: () -> Unit, onDraftsClick: () -> Unit = {}, + onBookmarksClick: () -> Unit = {}, onLicensesClick: () -> Unit = {}, isLoggedIn: Boolean, viewModel: SettingsViewModel = hiltViewModel() @@ -232,6 +234,28 @@ fun SettingsScreen( ) } + HorizontalDivider(color = colors.divider, thickness = 1.dp) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onBookmarksClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = null, + tint = colors.textSecondary + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(R.string.bookmarks), + style = typography.bodyLarge, + color = colors.textPrimary + ) + } + if (passkeyEnabled) { HorizontalDivider(color = colors.divider, thickness = 1.dp) From 05bb0e209d892917f6f08a5133888ba3d1a239bc Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:29:08 +0900 Subject: [PATCH 04/10] Use amber tint for bookmarked icons Add a dedicated bookmark color token to the app theme instead of reusing the accent color. Apply it to card and detail bookmark icons so saved items read as filled yellow or amber across light, dark, and dynamic themes. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../main/java/pub/hackers/android/ui/components/ArticleCard.kt | 2 +- .../main/java/pub/hackers/android/ui/components/PostCard.kt | 2 +- .../hackers/android/ui/screens/postdetail/PostDetailScreen.kt | 2 +- app/src/main/java/pub/hackers/android/ui/theme/Colors.kt | 3 +++ app/src/main/java/pub/hackers/android/ui/theme/Theme.kt | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt index 83376c8f..b05264dc 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt @@ -284,7 +284,7 @@ private fun ArticleEngagementBar( Icon( imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, contentDescription = stringResource(R.string.bookmark), - tint = if (isBookmarked) colors.accent else colors.textSecondary + tint = if (isBookmarked) colors.bookmark else colors.textSecondary ) } if (onExternalShareClick != null) { diff --git a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt index 0329f690..1ffdb1f2 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt @@ -635,7 +635,7 @@ private fun BookmarkEngagementButton( onClick: (() -> Unit)?, ) { val colors = LocalAppColors.current - val tint = if (isBookmarked) colors.accent else colors.textSecondary + val tint = if (isBookmarked) colors.bookmark else colors.textSecondary Box( contentAlignment = Alignment.Center, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index af495614..26f2807e 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -999,7 +999,7 @@ internal fun PostDetailContent( Icon( imageVector = if (post.viewerHasBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, contentDescription = stringResource(R.string.bookmark), - tint = if (post.viewerHasBookmarked) colors.accent else colors.textSecondary + tint = if (post.viewerHasBookmarked) colors.bookmark else colors.textSecondary ) } IconButton(onClick = onQuoteClick) { diff --git a/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt b/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt index 3302f73c..4b9fa8d8 100644 --- a/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt +++ b/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt @@ -18,6 +18,7 @@ data class AppColorScheme( val composeAccent: Color, val composeOnAccent: Color, val reaction: Color, + val bookmark: Color, val share: Color, val hashtag: Color, ) @@ -35,6 +36,7 @@ val LightAppColors = AppColorScheme( composeAccent = Color(0xFFEF4444), composeOnAccent = Color(0xFFFFFFFF), reaction = Color(0xFFE8453C), + bookmark = Color(0xFFF59E0B), share = Color(0xFF34D399), hashtag = Color(0xFF0891B2), ) @@ -52,6 +54,7 @@ val DarkAppColors = AppColorScheme( composeAccent = Color(0xFFF87171), composeOnAccent = Color(0xFFFFFFFF), reaction = Color(0xFFE8453C), + bookmark = Color(0xFFFBBF24), share = Color(0xFF34D399), hashtag = Color(0xFF22D3EE), ) diff --git a/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt b/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt index 1e07fd51..97d95b58 100644 --- a/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt +++ b/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt @@ -92,6 +92,7 @@ private fun dynamicAppColors(scheme: ColorScheme, dark: Boolean) = AppColorSchem composeAccent = scheme.primaryContainer, composeOnAccent = scheme.onPrimaryContainer, reaction = if (dark) DarkAppColors.reaction else LightAppColors.reaction, + bookmark = if (dark) DarkAppColors.bookmark else LightAppColors.bookmark, share = if (dark) DarkAppColors.share else LightAppColors.share, hashtag = if (dark) DarkAppColors.hashtag else LightAppColors.hashtag, ) From b9b9696f9d52aa8de2367ce370494b987aa08a0c Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:35:14 +0900 Subject: [PATCH 05/10] Show bookmarks empty state Replace the blank bookmarks result with a dedicated empty state that includes an icon and explanatory copy. Adjust the load-state branching so the screen shows the empty state whenever loading has finished with zero bookmarks. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../ui/screens/bookmarks/BookmarksScreen.kt | 53 +++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt index 3a3b2896..826f9fd0 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt @@ -7,15 +7,19 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -33,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState @@ -158,14 +163,13 @@ fun BookmarksScreen( ) } - items.itemCount == 0 && refresh is LoadState.NotLoading && refresh.endOfPaginationReached -> { - ErrorMessage( - message = stringResource(R.string.no_bookmarks), + items.itemCount == 0 && refresh is LoadState.NotLoading -> { + BookmarksEmptyState( onRefresh = { items.refresh() } ) } - items.itemCount == 0 -> { + refresh is LoadState.Loading && items.itemCount == 0 -> { FullScreenLoading() } @@ -251,3 +255,44 @@ fun BookmarksScreen( } } } + +@Composable +private fun BookmarksEmptyState( + onRefresh: () -> Unit, +) { + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = colors.bookmark, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.no_bookmarks), + style = typography.bodyLargeSemiBold, + color = colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_bookmarks_description), + style = typography.bodyMedium, + color = colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(20.dp)) + androidx.compose.material3.Button(onClick = onRefresh) { + Text(stringResource(R.string.refresh)) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd28dce3..c8630435 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,7 @@ Failed to bookmark Failed to remove bookmark No bookmarks yet + Posts and articles you save will show up here. All Articles Notes From b2d3ef2f3e0f7a4266d5582d85e69cc690234a8d Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:40:18 +0900 Subject: [PATCH 06/10] Fix bookmark toast resource lookups Move bookmark toast strings from LocalContext.getString calls to stringResource in Compose screens. This avoids stale resource reads on configuration changes and clears the Compose lint violations. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../ui/screens/bookmarks/BookmarksScreen.kt | 14 +++++++------- .../android/ui/screens/explore/ExploreScreen.kt | 14 +++++++------- .../ui/screens/postdetail/PostDetailScreen.kt | 14 +++++++------- .../android/ui/screens/timeline/TimelineScreen.kt | 14 +++++++------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt index 826f9fd0..5f023593 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt @@ -68,6 +68,8 @@ fun BookmarksScreen( val uiState by viewModel.uiState.collectAsState() val listState = rememberLazyListState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current val typography = LocalAppTypography.current @@ -212,13 +214,11 @@ fun BookmarksScreen( val displayPost = post.sharedPost ?: post Toast.makeText( context, - context.getString( - if (displayPost.viewerHasBookmarked) { - R.string.bookmark_removed - } else { - R.string.bookmarked - } - ), + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, Toast.LENGTH_SHORT ).show() viewModel.toggleBookmark(post) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt index afa8c82c..f2f92572 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt @@ -61,6 +61,8 @@ fun ExploreScreen( val uiState by viewModel.uiState.collectAsState() val listState = rememberLazyListState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current val typography = LocalAppTypography.current @@ -221,13 +223,11 @@ fun ExploreScreen( val displayPost = post.sharedPost ?: post Toast.makeText( context, - context.getString( - if (displayPost.viewerHasBookmarked) { - R.string.bookmark_removed - } else { - R.string.bookmarked - } - ), + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, Toast.LENGTH_SHORT ).show() viewModel.toggleBookmark(post) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index 26f2807e..82d10972 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -148,6 +148,8 @@ fun PostDetailScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current val confirmBeforeDelete by viewModel.preferencesManager.confirmBeforeDelete.collectAsState( initial = true @@ -491,13 +493,11 @@ fun PostDetailScreen( onBookmarkClick = { Toast.makeText( context, - context.getString( - if (resolvedPost.viewerHasBookmarked) { - R.string.bookmark_removed - } else { - R.string.bookmarked - } - ), + if (resolvedPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, Toast.LENGTH_SHORT ).show() viewModel.toggleBookmark() diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt index d1765dda..34b76b37 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt @@ -73,6 +73,8 @@ fun TimelineScreen( val items = viewModel.posts.collectAsLazyPagingItems() val listState = rememberLazyListState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current // Refresh draft count when screen becomes visible (e.g., returning from Drafts) @@ -269,13 +271,11 @@ fun TimelineScreen( val displayPost = post.sharedPost ?: post Toast.makeText( context, - context.getString( - if (displayPost.viewerHasBookmarked) { - R.string.bookmark_removed - } else { - R.string.bookmarked - } - ), + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, Toast.LENGTH_SHORT ).show() viewModel.toggleBookmark(post) From 2455d993dbf780e180e338f65c114ebaff358194 Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:43:22 +0900 Subject: [PATCH 07/10] Hide bookmark actions for logged-out users Only render bookmark controls when the current surface can actually invoke bookmark actions. This keeps logged-out screens from showing disabled bookmark affordances while preserving the logged-in behavior. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../android/ui/components/ArticleCard.kt | 14 ++++--- .../hackers/android/ui/components/PostCard.kt | 10 +++-- .../ui/screens/postdetail/PostDetailScreen.kt | 42 +++++++++++-------- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt index b05264dc..1d4b5cef 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt @@ -280,12 +280,14 @@ private fun ArticleEngagementBar( tint = if (isReacted) colors.reaction else colors.textSecondary ) } - IconButton(onClick = { onBookmarkClick?.invoke() }, enabled = onBookmarkClick != null) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.bookmark), - tint = if (isBookmarked) colors.bookmark else colors.textSecondary - ) + if (onBookmarkClick != null) { + IconButton(onClick = onBookmarkClick) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (isBookmarked) colors.bookmark else colors.textSecondary + ) + } } if (onExternalShareClick != null) { IconButton(onClick = onExternalShareClick) { diff --git a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt index 1ffdb1f2..0713220f 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt @@ -605,10 +605,12 @@ private fun EngagementBar( onLongClick = onReactionLongPress ) - BookmarkEngagementButton( - isBookmarked = isBookmarked, - onClick = onBookmarkClick, - ) + if (onBookmarkClick != null) { + BookmarkEngagementButton( + isBookmarked = isBookmarked, + onClick = onBookmarkClick, + ) + } Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index 82d10972..cbe0fcc9 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -490,17 +490,21 @@ fun PostDetailScreen( }, onReactionClick = { group -> viewModel.showReactorsSheet(group) }, onReactionPickerClick = { viewModel.toggleReactionPicker() }, - onBookmarkClick = { - Toast.makeText( - context, - if (resolvedPost.viewerHasBookmarked) { - bookmarkRemovedMessage - } else { - bookmarkedMessage - }, - Toast.LENGTH_SHORT - ).show() - viewModel.toggleBookmark() + onBookmarkClick = if (isLoggedIn) { + { + Toast.makeText( + context, + if (resolvedPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark() + } + } else { + null }, onQuoteClick = { onQuoteClick(postId) }, onSharesClick = { viewModel.showSharesSheet() }, @@ -588,7 +592,7 @@ internal fun PostDetailContent( onReplyClick: () -> Unit, onReactionClick: (ReactionGroup) -> Unit, onReactionPickerClick: () -> Unit, - onBookmarkClick: () -> Unit, + onBookmarkClick: (() -> Unit)?, onQuoteClick: () -> Unit, onSharesClick: () -> Unit, onQuotesClick: () -> Unit, @@ -995,12 +999,14 @@ internal fun PostDetailContent( colors.textSecondary ) } - IconButton(onClick = onBookmarkClick) { - Icon( - imageVector = if (post.viewerHasBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.bookmark), - tint = if (post.viewerHasBookmarked) colors.bookmark else colors.textSecondary - ) + if (onBookmarkClick != null) { + IconButton(onClick = onBookmarkClick) { + Icon( + imageVector = if (post.viewerHasBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (post.viewerHasBookmarked) colors.bookmark else colors.textSecondary + ) + } } IconButton(onClick = onQuoteClick) { Icon( From b407331f713cc28f2a31b20be6517fabcf179652 Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:48:26 +0900 Subject: [PATCH 08/10] Document bookmarks in README Add bookmark support to the documented feature list so the README matches the current app capabilities. This also calls out the saved-posts view and its article or note filtering behavior. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c40d2155..c21f025d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An Android client for [Hackers' Pub](https://hackers.pub), a fediverse-compatibl - **Search**: Find posts and users across the fediverse - **Compose**: Create posts with Markdown support, mention autocomplete, and visibility controls - **Drafts**: Save and resume in-progress posts +- **Bookmarks**: Save posts and browse bookmarked articles or notes - **Profiles**: View user profiles and their posts; recommended-actor discovery - **Post Details**: See full posts with replies and reactions - **Translation**: On-device post translation via ML Kit @@ -139,6 +140,7 @@ For the rules reviewers enforce during PRs — null-safety, Paging config, threa | Sign In | Two-step email verification flow | | Compose | New post creation with visibility options and mention autocomplete | | Drafts | Saved drafts list with resume/delete | +| Bookmarks | Saved posts with article/note filtering | | Post Detail | Full post view with replies and reactions | | Profile | User profile with bio and posts | | Recommended Actors | Suggested accounts to follow | From 5b0709fd74af91c51d7cbb0c4b6afa7360f5c31f Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:51:58 +0900 Subject: [PATCH 09/10] Add shared agent instructions Introduce a minimal AGENTS guide that points contributors to the project README and enforced conventions. Keep CLAUDE.md as a symlink so both entry points stay aligned without duplicate content. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- AGENTS.md | 13 +++++++++++++ CLAUDE.md | 1 + 2 files changed, 14 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d13a6e24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS + +This repository keeps its project guidance in two documents: + +- Read [README.md](./README.md) first for product scope, build steps, and required checks. +- Read [CONVENTION.md](./CONVENTION.md) for reviewer-enforced coding rules under `app/src/main/`. + +Minimum expectations for any change: + +- Keep changes consistent with `README.md` and `CONVENTION.md`. +- Run `./gradlew :app:lintDebug` before pushing. +- Do not introduce new `!!` in production code. +- For paged post feeds, keep using the existing Paging overlay and deduplication patterns. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 0d05994088dcb976837fc47df908144441419cd1 Mon Sep 17 00:00:00 2001 From: malkoG Date: Tue, 21 Apr 2026 12:58:46 +0900 Subject: [PATCH 10/10] Serialize bookmark mutations Coordinate bookmark toggles per post so overlapping requests do not clobber newer optimistic state. Apply the same mutation policy across timeline, explore, profile, bookmarks, and post detail to keep bookmark behavior consistent. Co-authored-by: Codex Assisted-By: Codex(GPT-5) --- .../bookmark/BookmarkMutationCoordinator.kt | 52 +++++++++++++++++++ .../screens/bookmarks/BookmarksViewModel.kt | 37 ++++++------- .../ui/screens/explore/ExploreViewModel.kt | 37 ++++++------- .../screens/postdetail/PostDetailViewModel.kt | 44 ++++++++-------- .../ui/screens/profile/ProfileViewModel.kt | 37 ++++++------- .../ui/screens/timeline/TimelineViewModel.kt | 37 ++++++------- 6 files changed, 151 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt diff --git a/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt b/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt new file mode 100644 index 00000000..89324a9c --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt @@ -0,0 +1,52 @@ +package pub.hackers.android.ui.bookmark + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Serializes bookmark mutations per post while preserving optimistic UI state. + * + * The UI is updated immediately to the user's latest desired state. Network + * requests are sent one at a time per post id; if the user toggles again while + * a request is in flight, we remember the last desired state and enqueue a + * follow-up request after the current one completes. + */ +class BookmarkMutationCoordinator( + private val scope: CoroutineScope, + private val requestMutation: suspend (postId: String, shouldBookmark: Boolean) -> Result, + private val applyDesiredState: (postId: String, isBookmarked: Boolean) -> Unit, + private val revertFailedState: (postId: String, attemptedState: Boolean) -> Unit, +) { + private val inFlight = mutableSetOf() + private val lastDesired = mutableMapOf() + + fun toggle(postId: String, currentIsBookmarked: Boolean) { + val desiredState = !currentIsBookmarked + lastDesired[postId] = desiredState + applyDesiredState(postId, desiredState) + + if (!inFlight.add(postId)) return + sync(postId, desiredState) + } + + private fun sync(postId: String, desiredState: Boolean) { + scope.launch { + val result = requestMutation(postId, desiredState) + val latestDesired = lastDesired[postId] + + if (result.isFailure && latestDesired == desiredState) { + revertFailedState(postId, desiredState) + lastDesired.remove(postId) + } else if (result.isSuccess && latestDesired == desiredState) { + lastDesired.remove(postId) + } + + inFlight.remove(postId) + + val nextDesired = lastDesired[postId] + if (nextDesired != null && nextDesired != desiredState && inFlight.add(postId)) { + sync(postId, nextDesired) + } + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt index 53a80866..f042d9e5 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt @@ -25,6 +25,7 @@ import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup import pub.hackers.android.graphql.type.PostType as GqlPostType +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject enum class BookmarkTab { @@ -48,6 +49,23 @@ class BookmarksViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) val posts: Flow> = _selectedTab .flatMapLatest { tab -> @@ -104,24 +122,7 @@ class BookmarksViewModel @Inject constructor( fun toggleBookmark(post: Post) { val target = post.sharedPost ?: post - val willBookmark = !target.viewerHasBookmarked - - overlayStore.mutate(target.id) { - it.copy(viewerHasBookmarked = willBookmark) - } - - viewModelScope.launch { - val result = if (willBookmark) { - repository.bookmarkPost(target.id) - } else { - repository.unbookmarkPost(target.id) - } - result.onFailure { - overlayStore.mutate(target.id) { prev -> - prev.copy(viewerHasBookmarked = !willBookmark) - } - } - } + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) } fun toggleReaction(post: Post, emoji: String) { diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt index 3cc1dc82..21e0d1fc 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt @@ -24,6 +24,7 @@ import pub.hackers.android.data.paging.publicTimelinePage import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject enum class ExploreTab { @@ -48,6 +49,23 @@ class ExploreViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) val posts: Flow> = _selectedTab .flatMapLatest { tab -> @@ -105,24 +123,7 @@ class ExploreViewModel @Inject constructor( fun toggleBookmark(post: Post) { val target = post.sharedPost ?: post - val willBookmark = !target.viewerHasBookmarked - - overlayStore.mutate(target.id) { - it.copy(viewerHasBookmarked = willBookmark) - } - - viewModelScope.launch { - val result = if (willBookmark) { - repository.bookmarkPost(target.id) - } else { - repository.unbookmarkPost(target.id) - } - result.onFailure { - overlayStore.mutate(target.id) { prev -> - prev.copy(viewerHasBookmarked = !willBookmark) - } - } - } + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) } /** diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt index 1ce3e8c7..1167e815 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt @@ -23,6 +23,7 @@ import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup import pub.hackers.android.domain.model.TocItem +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import pub.hackers.android.ui.screens.compose.ReplyPostedSignal import javax.inject.Inject @@ -62,6 +63,27 @@ class PostDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(PostDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { targetPostId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(targetPostId) + else repository.unbookmarkPost(targetPostId) + }, + applyDesiredState = { targetPostId, isBookmarked -> + _uiState.update { state -> + val post = state.post + if (post == null || post.id != targetPostId) state + else state.copy(post = post.copy(viewerHasBookmarked = isBookmarked)) + } + }, + revertFailedState = { targetPostId, attemptedState -> + _uiState.update { state -> + val post = state.post + if (post == null || post.id != targetPostId) state + else state.copy(post = post.copy(viewerHasBookmarked = !attemptedState)) + } + }, + ) // Locally-composed replies appended optimistically after a successful reply // from this screen. Rendered after the paginated replies; cleared on refresh @@ -288,27 +310,7 @@ class PostDetailViewModel @Inject constructor( fun toggleBookmark() { val post = _uiState.value.post ?: return - val willBookmark = !post.viewerHasBookmarked - - _uiState.update { - it.copy(post = post.copy(viewerHasBookmarked = willBookmark)) - } - - viewModelScope.launch { - val result = if (willBookmark) { - repository.bookmarkPost(postId) - } else { - repository.unbookmarkPost(postId) - } - - result.onFailure { - _uiState.update { state -> - state.copy( - post = state.post?.copy(viewerHasBookmarked = !willBookmark) - ) - } - } - } + bookmarkCoordinator.toggle(post.id, post.viewerHasBookmarked) } fun toggleReaction(emoji: String) { diff --git a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt index 4ca5cd0f..3f2dc805 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt @@ -28,6 +28,7 @@ import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.ActorField import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject enum class ProfileTab { @@ -65,6 +66,23 @@ class ProfileViewModel @Inject constructor( val selectedTab: StateFlow = _selectedTab.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) // Each tab has its own pager. Screen side only collects the active tab's // flow, so inactive tabs don't fetch until the user switches to them. @@ -272,24 +290,7 @@ class ProfileViewModel @Inject constructor( fun toggleBookmark(post: Post) { val target = post.sharedPost ?: post - val willBookmark = !target.viewerHasBookmarked - - overlayStore.mutate(target.id) { - it.copy(viewerHasBookmarked = willBookmark) - } - - viewModelScope.launch { - val result = if (willBookmark) { - repository.bookmarkPost(target.id) - } else { - repository.unbookmarkPost(target.id) - } - result.onFailure { - overlayStore.mutate(target.id) { prev -> - prev.copy(viewerHasBookmarked = !willBookmark) - } - } - } + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) } @Suppress("unused") diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt index 0f9148b7..345626a3 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt @@ -26,6 +26,7 @@ import pub.hackers.android.data.paging.personalTimelinePage import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject data class TimelineUiState( @@ -45,6 +46,23 @@ class TimelineViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) private var currentPagingSource: PagingSource? = null @@ -124,24 +142,7 @@ class TimelineViewModel @Inject constructor( fun toggleBookmark(post: Post) { val target = post.sharedPost ?: post - val willBookmark = !target.viewerHasBookmarked - - overlayStore.mutate(target.id) { - it.copy(viewerHasBookmarked = willBookmark) - } - - viewModelScope.launch { - val result = if (willBookmark) { - repository.bookmarkPost(target.id) - } else { - repository.unbookmarkPost(target.id) - } - result.onFailure { - overlayStore.mutate(target.id) { prev -> - prev.copy(viewerHasBookmarked = !willBookmark) - } - } - } + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) } /**