Skip to content

Add bookmark support and bookmarks screen#144

Merged
malkoG merged 10 commits intohackers-pub:mainfrom
malkoG:feature/bookmark
Apr 21, 2026
Merged

Add bookmark support and bookmarks screen#144
malkoG merged 10 commits intohackers-pub:mainfrom
malkoG:feature/bookmark

Conversation

@malkoG
Copy link
Copy Markdown
Collaborator

@malkoG malkoG commented Apr 21, 2026

Summary

Add Android bookmark support for posts and articles, including optimistic toggles across feed/detail surfaces, a dedicated bookmarks screen, and the supporting GraphQL/domain/repository plumbing. Follow-up commits refine the UX with bookmark-state tinting, empty-state handling, logged-out gating, toast fixes, and mutation serialization for more predictable bookmark toggles.

Why

  • Add bookmark data support end to end
    • Introduce bookmark GraphQL fields, queries, and mutations, then map them through domain models, repository code, and paging overlays so the app can read and optimistically reflect bookmark state.
  • Expose bookmark actions where users expect them
    • Add bookmark buttons next to reactions in cards and post detail, with optimistic updates so the UI responds immediately.
  • Make saved items discoverable and usable for signed-in users
    • Add a Bookmarks entry in Settings for logged-in users and a screen with All, Articles, and Notes tabs for browsing saved content.
  • Tighten the bookmark UX after the initial implementation
    • Add an empty state, bookmarked-icon tint, correct toast message lookups, hide bookmark actions for logged-out users, and serialize bookmark mutations to avoid conflicting rapid toggles.
  • Keep bookmark reads fresh
    • The bookmarks query intentionally uses FetchPolicy.NetworkOnly so stale cached bookmark state does not leave the saved-items screen out of sync right after bookmark toggles.

Before and After (if applicable)

Before After
No bookmark controls in cards or detail screens Bookmark controls appear next to reaction actions on supported surfaces for logged-in users
No way to browse saved posts in-app Logged-in users can open a dedicated Bookmarks screen from Settings with All, Articles, and Notes tabs
No empty-state feedback for saved items Bookmarks screen shows an explicit empty state and refresh affordance
Rapid repeated bookmark taps could race optimistic state updates Bookmark mutations are serialized so toggle state resolves more predictably
Logged-out users could see bookmark affordances that they cannot use Logged-out users no longer see bookmark actions or the Settings bookmark entry

Commit History

  • Add bookmark data support (ff0641b): Add bookmark GraphQL operations, domain fields, repository support, and paging overlays.
  • Add bookmark actions to post surfaces (54ca6db): Add bookmark UI and optimistic toggles to timeline, explore, profile, and post detail screens.
  • Add bookmarks screen and settings entry (b34566f): Add the logged-in Settings entry, bookmarks navigation, and the new tabbed bookmarks screen.
  • Use amber tint for bookmarked icons (05bb0e2): Differentiate active bookmark state visually.
  • Show bookmarks empty state (b9b9696): Add an explicit empty state and refresh action for the bookmarks screen.
  • Fix bookmark toast resource lookups (b2d3ef2): Correct toast strings used when toggling bookmark state.
  • Hide bookmark actions for logged-out users (2455d99): Remove bookmark controls for unauthenticated viewers and keep the Settings bookmark entry logged-in only.
  • Serialize bookmark mutations (0d05994): Serialize bookmark toggle requests across bookmark surfaces so rapid repeated taps do not leave optimistic state out of order.

Test Plan

  • Run ./gradlew :app:compileDebugKotlin
  • Run ./gradlew :app:testDebugUnitTest --tests pub.hackers.android.ui.components.PostCardTest --tests pub.hackers.android.ui.screens.settings.SettingsScreenTest --tests pub.hackers.android.ui.screens.postdetail.PostDetailContentTest
  • Verify bookmark toggle behavior manually on a server that supports the bookmark GraphQL fields and mutations.
  • Verify the Settings > Bookmarks flow manually with All, Articles, and Notes tabs.
  • Verify rapid repeated bookmark taps on the same post settle into the expected final state.
  • Verify logged-out surfaces do not show bookmark actions or the Settings bookmark entry.

Assisted-By: Codex(GPT-5)

malkoG and others added 3 commits April 21, 2026 12:16
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete bookmarking feature for posts. It adds GraphQL operations and schema definitions for bookmark queries and mutations, extends the data layer with repository methods and paging support, updates the domain model with a viewerHasBookmarked property, adds a new BookmarksScreen with pagination, integrates bookmark actions across multiple UI screens with optimistic updates, and includes supporting strings resources.

Changes

Cohort / File(s) Summary
GraphQL Schema and Operations
app/src/main/graphql/pub/hackers/android/operations.graphql, app/src/main/graphql/pub/hackers/android/schema.graphqls
Added viewerHasBookmarked field to Post-like types. Introduced bookmark/unbookmark mutations with payload types and a bookmarks query with cursor-based pagination. New types: BookmarkPostInput/Payload/Result, UnbookmarkPostInput/Payload/Result, QueryBookmarksConnection/Edge.
Data Repository and Overlay
app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt, app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt, app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt
Added repository methods for bookmark mutations and fetching paginated bookmarks. Extended PostOverlay with viewerHasBookmarked field and overlay application logic. Added bookmarksPage helper for cursor paging.
Domain Model
app/src/main/java/pub/hackers/android/domain/model/Models.kt
Added viewerHasBookmarked boolean property to Post data class with default value false.
Bookmarks Feature Screen
app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt, app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt
Introduced new BookmarksScreen composable with tab-based filtering (All/Articles/Notes), pull-to-refresh, pagination, and reaction picker integration. BookmarksViewModel manages tab selection, paging, optimistic bookmark/favourite/share toggles with rollback on failure, and reaction toggling.
Navigation
app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt, app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt
Added DetailScreen.Bookmarks navigation route and composable destination. Extended SettingsScreen with optional onBookmarksClick callback and bookmark settings row.
UI Components
app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt, app/src/main/java/pub/hackers/android/ui/components/PostCard.kt
Added optional onBookmarkClick callback to ArticleCard, PostCard, and internal engagement bars. New BookmarkEngagementButton displays toggle state via filled/outlined icon with conditional tinting.
Post Detail Screen
app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt, app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt
Added onBookmarkClick handler to PostDetailContent with toast feedback and bookmark icon button. PostDetailViewModel.toggleBookmark() performs optimistic update and async persistence with rollback.
Explore Screen
app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt, app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt
Integrated bookmark toggle with toast feedback (conditionally available when logged in). ExploreViewModel.toggleBookmark() updates overlay optimistically then persists via repository.
Profile and Timeline Screens
app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt, app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt, app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt
Added toggleBookmark(post: Post) method to both ViewModels with consistent optimistic update and rollback patterns. Timeline screen shows toast feedback before toggling bookmark state.
Resources and Tests
app/src/main/res/values/strings.xml, app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt
Added bookmark-related string resources (bookmark, bookmarked, bookmark_removed, etc.) and updated test setup with onBookmarkClick callback parameter.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant UI as Post Card<br/>UI
    participant VM as ViewModel<br/>(e.g., Timeline)
    participant Overlay as Overlay<br/>Store
    participant Repo as Repository
    participant Apollo as Apollo<br/>Client
    
    User->>UI: Tap Bookmark Button
    UI->>VM: toggleBookmark(post)
    
    rect rgba(0, 150, 136, 0.5)
    Note over VM,Overlay: Optimistic Update
    VM->>Overlay: mutate(postId)
    Overlay->>Overlay: Set viewerHasBookmarked = true
    end
    
    Note over UI: UI Re-composes<br/>with New State
    
    rect rgba(33, 150, 243, 0.5)
    Note over Repo,Apollo: Async Persistence
    VM->>Repo: bookmarkPost(postId)
    Repo->>Apollo: Execute Mutation
    Apollo->>Repo: Return Result
    end
    
    alt Success
        Repo->>VM: Result.success(Unit)
        VM->>VM: Overlay persisted
    else Failure
        Repo->>VM: Result.failure(error)
        rect rgba(244, 67, 54, 0.5)
        Note over VM,Overlay: Rollback
        VM->>Overlay: mutate(postId)
        Overlay->>Overlay: Set viewerHasBookmarked = false
        end
        Overlay->>UI: Notify Observers
        UI->>UI: Re-compose with Reverted State
    end
Loading
sequenceDiagram
    participant User
    participant Screen as Bookmarks<br/>Screen
    participant VM as Bookmarks<br/>ViewModel
    participant Pager as Cursor<br/>Pager
    participant Repo as Repository
    participant Apollo as Apollo<br/>Client
    
    User->>Screen: Open Bookmarks
    Screen->>VM: Select Tab (ALL/ARTICLES/NOTES)
    
    VM->>Pager: Load Page 1
    Pager->>Repo: bookmarksPage(after=null, postType)
    Repo->>Apollo: Execute BookmarksQuery
    Apollo->>Repo: Return Edges + PageInfo
    Repo->>VM: Return Posts + endCursor + hasNextPage
    
    VM->>Screen: Emit PagingData<Post>
    Screen->>Screen: Render LazyColumn with Posts
    
    User->>Screen: Scroll to Bottom (Pagination)
    Pager->>Repo: bookmarksPage(after=cursor, postType)
    Repo->>Apollo: Execute BookmarksQuery
    Apollo->>Repo: Return Next Edges + PageInfo
    Repo->>VM: Return Additional Posts
    
    VM->>Screen: Emit Updated PagingData
    Screen->>Screen: Append Posts to List
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: adding bookmark functionality and a dedicated bookmarks screen, which aligns perfectly with the comprehensive set of GraphQL schema updates, repository methods, UI components, and new Bookmarks screen introduced in this PR.
Description check ✅ Passed The pull request description comprehensively details bookmark feature implementation including GraphQL, UI, optimistic updates, and a new Bookmarks screen.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@malkoG malkoG marked this pull request as draft April 21, 2026 03:24
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt (1)

23-29: ⚠️ Potential issue | 🟠 Major

Avoid clearing unrelated optimistic bookmark state.

Adding viewerHasBookmarked makes one overlay entry hold independent fields. Existing reaction rollback paths that call overlayStore.clear(target.id) will now also drop bookmark/share overlays, so a failed reaction can visually undo an unrelated bookmark action. Add field-specific clearing for reactions and use it from reaction failure handlers.

Proposed field-specific rollback helper
 class PostOverlayStore {
     private val _overlays = MutableStateFlow<Map<String, PostOverlay>>(emptyMap())
     val overlays: StateFlow<Map<String, PostOverlay>> = _overlays
 
     fun mutate(postId: String, block: (PostOverlay) -> PostOverlay) {
         _overlays.update { current ->
             current + (postId to block(current[postId] ?: PostOverlay()))
         }
     }
 
+    fun clearReaction(postId: String) {
+        _overlays.update { current ->
+            val existing = current[postId] ?: return@update current
+            val next = existing.copy(
+                reactionOverride = null,
+                reactionCountOverride = null,
+            )
+            if (next == PostOverlay()) current - postId else current + (postId to next)
+        }
+    }
+
     fun clear(postId: String) {
         _overlays.update { it - postId }
     }
 }

Then replace reaction failure rollbacks like:

- overlayStore.clear(target.id)
+ overlayStore.clearReaction(target.id)

Also applies to: 74-76

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt` around
lines 23 - 29, The PostOverlay data class now contains independent fields
(viewerHasBookmarked, viewerHasShared, reactionOverride, reactionCountOverride,
shareDelta) but existing rollback code calls overlayStore.clear(target.id) which
removes all overlay fields and can unintentionally clear bookmarks/shares; add a
field-specific clearing method on the overlay store (e.g., clearReactionOverlay
or removeReactionFields) that only clears reactionOverride and
reactionCountOverride (and any reaction-related deltas), then replace calls to
overlayStore.clear(target.id) from reaction failure handlers with this new
method so only reaction overlays are rolled back while
viewerHasBookmarked/viewerHasShared remain intact.
🧹 Nitpick comments (5)
app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt (1)

118-122: Nit: import PostType instead of fully-qualifying.

The other adapters in this file use short type names; inline pub.hackers.android.graphql.type.PostType breaks the pattern. Add an import pub.hackers.android.graphql.type.PostType at the top and use postType: PostType?.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt`
around lines 118 - 122, The function signature in CursorPagingSource.kt uses a
fully-qualified type for postType; update it to use a short import by adding
import pub.hackers.android.graphql.type.PostType at the top of the file and
change the bookmarksPage parameter from postType:
pub.hackers.android.graphql.type.PostType? to postType: PostType? in the suspend
fun HackersPubRepository.bookmarksPage(...) declaration so it matches the other
adapters' style.
app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt (1)

219-235: Duplicate of TimelineScreen bookmark handler; same premature-toast issue.

This is byte-for-byte the same handler as TimelineScreen.kt lines 268-282 and has the same defect: toast fires before the repository call resolves, so on failure the user sees a success message while the optimistic update is rolled back, and the failed_to_bookmark / failed_to_remove_bookmark strings added in this PR are never surfaced.

Extract a shared helper (e.g., a composable/extension that takes post + a toggle lambda) and route failure feedback through the ViewModel so both screens stay in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt`
around lines 219 - 235, The onBookmarkClick handler in ExploreScreen
(byte-for-byte duplicate of TimelineScreen's handler) shows a success Toast
immediately before viewModel.toggleBookmark completes, so failures never
surface; extract a shared helper (e.g., a composable or extension function) that
takes the Post (use post/sharedPost resolution logic currently in the handler)
and a toggle lambda, replace the inline handlers in both ExploreScreen and
TimelineScreen with this helper, and move success/failure Toast emission into
the ViewModel's result path so viewModel.toggleBookmark publishes success or
error states (use the existing failed_to_bookmark / failed_to_remove_bookmark
strings) which the helper observes to show the correct Toast. Ensure the helper
calls viewModel.toggleBookmark only once and does not optimistic-toast prior to
the repository response.
app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt (1)

161-161: Consider adding a dedicated test covering onBookmarkClick wiring.

Passing {} keeps the existing tests compiling, but there's no assertion that the bookmark button actually invokes the callback. A small test that finds the bookmark action and verifies the lambda fires would prevent regressions in the new UI wiring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt`
at line 161, Add a test in PostDetailContentTest that verifies the
onBookmarkClick lambda is invoked: render the PostDetailContent composable with
a side-effecting lambda (e.g., set an AtomicBoolean or mutableState flag) passed
as onBookmarkClick, locate the bookmark button node via its test tag or content
description (e.g., onNodeWithTag("bookmark") or
onNodeWithContentDescription("Bookmark")), call performClick() on that node, and
assert the flag changed to true; reference the onBookmarkClick parameter in the
PostDetailContent invocation and the test node selector you use to wire the
assertion.
app/src/main/graphql/pub/hackers/android/operations.graphql (1)

542-565: Consider selecting the error fragments on the bookmark result unions for clearer error surfacing.

Per schema, BookmarkPostResult and UnbookmarkPostResult also include InvalidInputError and NotAuthenticatedError (lines 1937 and 1953 in schema.graphqls). Peer mutations like SharePost/UnsharePost (lines 609–650) explicitly match on those variants so the repository can return a meaningful message. Here, HackersPubRepository.bookmarkPost/unbookmarkPost has to fall back to a generic "Failed to bookmark" / "Failed to remove bookmark" message whenever the union resolves to an error type, which makes auth failures indistinguishable from validation failures in the UI.

♻️ Suggested additions
 mutation BookmarkPost($postId: ID!) {
     bookmarkPost(input: { postId: $postId }) {
         __typename
         ... on BookmarkPostPayload {
             post {
                 id
                 viewerHasBookmarked
             }
         }
+        ... on InvalidInputError {
+            inputPath
+        }
+        ... on NotAuthenticatedError {
+            notAuthenticated
+        }
     }
 }

 mutation UnbookmarkPost($postId: ID!) {
     unbookmarkPost(input: { postId: $postId }) {
         __typename
         ... on UnbookmarkPostPayload {
             post {
                 id
                 viewerHasBookmarked
             }
             unbookmarkedPostId
         }
+        ... on InvalidInputError {
+            inputPath
+        }
+        ... on NotAuthenticatedError {
+            notAuthenticated
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/graphql/pub/hackers/android/operations.graphql` around lines 542
- 565, Add explicit selections for the error union variants to the BookmarkPost
and UnbookmarkPost mutations so the client can distinguish InvalidInputError and
NotAuthenticatedError; update the BookmarkPost/UnbookmarkPost operations
(mutations named BookmarkPost and UnbookmarkPost in
app/src/main/graphql/pub/hackers/android/operations.graphql) to match on the
union variants (InvalidInputError and NotAuthenticatedError) and return their
message/code fields, and then propagate those fields through
HackersPubRepository.bookmarkPost and HackersPubRepository.unbookmarkPost so
callers can show specific error messages instead of the generic "Failed to
bookmark"/"Failed to remove bookmark".
app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt (1)

257-288: Minor consistency nits on getBookmarks.

Two small points, both non-blocking:

  1. The signature uses the fully qualified pub.hackers.android.graphql.type.PostType?, while the rest of this file imports GraphQL types (e.g. GqlPostVisibility alias at line 66). Consider adding an import such as import pub.hackers.android.graphql.type.PostType as GqlPostType for consistency with the conventions already in use.
  2. Peer methods like getActorPosts (L496–498) and getPostReplies (L459–461) defensively apply .distinctBy { it.id } on edges because the server can return duplicates across pages. getBookmarks skips this. It is probably fine because BookmarksViewModel applies distinctByEffectiveId() at the paging layer, but matching peer methods here would keep repository-level behavior uniform.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt`
around lines 257 - 288, Add a GraphQL PostType import alias and apply the same
deduplication as other repo methods: import
pub.hackers.android.graphql.type.PostType as GqlPostType and update the
getBookmarks(...) implementation to deduplicate edges before mapping (e.g. call
.distinctBy { it.node.postFields.id } or .distinctBy { it.id } on data?.edges)
so TimelineResult(posts = ...) produces unique posts like
getActorPosts/getPostReplies; keep the rest of the logic (mapping to
TimelineResult and using postFields.toPost(...)) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt`:
- Around line 207-221: The Toast is shown optimistically in the composable
(inside onBookmarkClick) before the mutation (viewModel.toggleBookmark)
resolves, causing UI/Toast mismatch on rollback; remove the synchronous
Toast.makeText(...) call from the onBookmarkClick handler and instead emit a
one-shot success/failure event from BookmarksViewModel.toggleBookmark (e.g., a
SharedFlow/Channel/SingleLiveEvent) after the repository call completes or after
rollback, carrying enough info to pick the correct string (use
displayPost.viewerHasBookmarked or the result state). In the composable
subscribe/collect that one-shot event and show the Toast there so the toast
reflects the final resolved outcome.

In
`@app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt`:
- Around line 142-152: The failure handler in toggleReaction currently calls
overlayStore.clear(target.id) which wipes all optimistic state; instead, in
result.onFailure replace that clear call with a scoped rollback using
overlayStore.mutate(target.id) { prev -> prev.copy(...) } so only
reaction-related fields are reverted: set reactionOverride back to null (or to
its previous value) and adjust reactionCountOverride to undo the optimistic
increment/decrement applied when toggling the reaction, leaving other fields
(viewerHasBookmarked, viewerHasShared, shareDelta, etc.) untouched.

In
`@app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt`:
- Around line 106-126: toggleBookmark currently fires requests immediately and
can interleave; introduce a per-post in-flight guard + “last desired state”
store keyed by target.id (e.g., a MutableMap<Long, Boolean> in the ViewModel) to
serialize mutations. On toggleBookmark update the overlayStore immediately and
set lastDesired[target.id] = willBookmark; if inFlight[target.id] is true
return; otherwise set inFlight[target.id]=true and launch the network call using
repository.bookmarkPost/repository.unbookmarkPost; on completion (success or
failure) clear inFlight[target.id], compare lastDesired[target.id] with the
state you just applied and if they differ enqueue/send another request to
converge to lastDesired, and on failure only revert overlayStore if lastDesired
indicates the opposite state (to avoid clobbering newer toggles). Use the same
symbols: toggleBookmark, overlayStore.mutate, repository.bookmarkPost,
repository.unbookmarkPost, viewModelScope.launch, and per-post maps (inFlight
and lastDesired) to locate where to implement this.

In
`@app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt`:
- Around line 491-504: The bookmark tap currently always shows a success toast
and calls viewModel.toggleBookmark() even when the user is not logged in; update
the onBookmarkClick handler in PostDetailScreen to first check the screen's
isLoggedIn flag and only proceed with the toast and viewModel.toggleBookmark()
when isLoggedIn is true, otherwise short-circuit (e.g., show a login-required
message or open auth flow) so unauthenticated users cannot trigger the mutation;
locate the onBookmarkClick block that references
resolvedPost.viewerHasBookmarked and viewModel.toggleBookmark() and add the
guard around those actions.

In
`@app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt`:
- Around line 289-312: toggleBookmark currently performs an optimistic update
but neither surfaces errors to the UI nor prevents reentrant taps; add an
isBookmarking Boolean in PostDetailViewModel to early-return while a request is
in flight, update it around the network call, and emit a transient error
event/string resource on failure so PostDetailScreen can show a snackbar;
specifically: in toggleBookmark guard with isBookmarking, set isBookmarking =
true before calling repository.bookmarkPost/post.unbookmarkPost and false in
finally, keep the optimistic _uiState update using post.copy(viewerHasBookmarked
= willBookmark), and on result.onFailure emit the appropriate
R.string.failed_to_bookmark or R.string.failed_to_remove_bookmark via the
ViewModel’s event/transient error channel while rolling back the optimistic
change as already implemented.

In `@app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt`:
- Around line 237-258: Wrap the Bookmarks Row in a feature-flag check so it only
renders when bookmarks are supported: add a boolean like bookmarksEnabled =
FeatureFlags.BOOKMARKS_ENABLED (mirroring the existing passkeyEnabled pattern)
and change the unconditional Row that calls onBookmarksClick() to be inside if
(bookmarksEnabled) { ... }. Ensure you reference FeatureFlags.BOOKMARKS_ENABLED,
keep the existing onBookmarksClick() callback and UI contents intact, and follow
the same conditional/gating style used for passkeyEnabled in SettingsScreen.kt.

In `@app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt`:
- Around line 268-282: The toast in TimelineScreen's onBookmarkClick handler is
shown before TimelineViewModel.toggleBookmark completes, causing misleading
success messages; move toast/snackbar emission into the ViewModel (or expose a
failure Flow) so success vs failure messages are emitted after
repository.bookmarkPost/unbookmarkPost returns, using a one-shot event (e.g.,
Channel/SharedFlow) or a SnackbarHostState event for R.string.bookmarked,
R.string.bookmark_removed, R.string.failed_to_bookmark, and
R.string.failed_to_remove_bookmark; update TimelineViewModel.toggleBookmark to
emit the appropriate success/failure event after the mutation (rollback still
allowed) and have TimelineScreen observe that event to show the toast/snackbar,
and also extract the shared onBookmarkClick handler into a common function used
by ExploreScreen.kt to avoid duplication.

---

Outside diff comments:
In `@app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt`:
- Around line 23-29: The PostOverlay data class now contains independent fields
(viewerHasBookmarked, viewerHasShared, reactionOverride, reactionCountOverride,
shareDelta) but existing rollback code calls overlayStore.clear(target.id) which
removes all overlay fields and can unintentionally clear bookmarks/shares; add a
field-specific clearing method on the overlay store (e.g., clearReactionOverlay
or removeReactionFields) that only clears reactionOverride and
reactionCountOverride (and any reaction-related deltas), then replace calls to
overlayStore.clear(target.id) from reaction failure handlers with this new
method so only reaction overlays are rolled back while
viewerHasBookmarked/viewerHasShared remain intact.

---

Nitpick comments:
In `@app/src/main/graphql/pub/hackers/android/operations.graphql`:
- Around line 542-565: Add explicit selections for the error union variants to
the BookmarkPost and UnbookmarkPost mutations so the client can distinguish
InvalidInputError and NotAuthenticatedError; update the
BookmarkPost/UnbookmarkPost operations (mutations named BookmarkPost and
UnbookmarkPost in app/src/main/graphql/pub/hackers/android/operations.graphql)
to match on the union variants (InvalidInputError and NotAuthenticatedError) and
return their message/code fields, and then propagate those fields through
HackersPubRepository.bookmarkPost and HackersPubRepository.unbookmarkPost so
callers can show specific error messages instead of the generic "Failed to
bookmark"/"Failed to remove bookmark".

In `@app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt`:
- Around line 118-122: The function signature in CursorPagingSource.kt uses a
fully-qualified type for postType; update it to use a short import by adding
import pub.hackers.android.graphql.type.PostType at the top of the file and
change the bookmarksPage parameter from postType:
pub.hackers.android.graphql.type.PostType? to postType: PostType? in the suspend
fun HackersPubRepository.bookmarksPage(...) declaration so it matches the other
adapters' style.

In
`@app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt`:
- Around line 257-288: Add a GraphQL PostType import alias and apply the same
deduplication as other repo methods: import
pub.hackers.android.graphql.type.PostType as GqlPostType and update the
getBookmarks(...) implementation to deduplicate edges before mapping (e.g. call
.distinctBy { it.node.postFields.id } or .distinctBy { it.id } on data?.edges)
so TimelineResult(posts = ...) produces unique posts like
getActorPosts/getPostReplies; keep the rest of the logic (mapping to
TimelineResult and using postFields.toPost(...)) unchanged.

In `@app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt`:
- Around line 219-235: The onBookmarkClick handler in ExploreScreen
(byte-for-byte duplicate of TimelineScreen's handler) shows a success Toast
immediately before viewModel.toggleBookmark completes, so failures never
surface; extract a shared helper (e.g., a composable or extension function) that
takes the Post (use post/sharedPost resolution logic currently in the handler)
and a toggle lambda, replace the inline handlers in both ExploreScreen and
TimelineScreen with this helper, and move success/failure Toast emission into
the ViewModel's result path so viewModel.toggleBookmark publishes success or
error states (use the existing failed_to_bookmark / failed_to_remove_bookmark
strings) which the helper observes to show the correct Toast. Ensure the helper
calls viewModel.toggleBookmark only once and does not optimistic-toast prior to
the repository response.

In
`@app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt`:
- Line 161: Add a test in PostDetailContentTest that verifies the
onBookmarkClick lambda is invoked: render the PostDetailContent composable with
a side-effecting lambda (e.g., set an AtomicBoolean or mutableState flag) passed
as onBookmarkClick, locate the bookmark button node via its test tag or content
description (e.g., onNodeWithTag("bookmark") or
onNodeWithContentDescription("Bookmark")), call performClick() on that node, and
assert the flag changed to true; reference the onBookmarkClick parameter in the
PostDetailContent invocation and the test node selector you use to wire the
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0f5797b-fe77-45de-94c7-e1a9a4b0ee2a

📥 Commits

Reviewing files that changed from the base of the PR and between 83f05c2 and b34566f.

📒 Files selected for processing (21)
  • app/src/main/graphql/pub/hackers/android/operations.graphql
  • app/src/main/graphql/pub/hackers/android/schema.graphqls
  • app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt
  • app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt
  • app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt
  • app/src/main/java/pub/hackers/android/domain/model/Models.kt
  • app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt
  • app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt
  • app/src/main/java/pub/hackers/android/ui/components/PostCard.kt
  • app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt
  • app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt
  • app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt
  • app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt
  • app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt
  • app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt
  • app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt
  • app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt
  • app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt
  • app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt

Comment on lines +207 to +221
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)
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Toast fires before the mutation resolves; it can contradict the final state on rollback.

Toast.makeText(...).show() is called synchronously before viewModel.toggleBookmark(post) runs its repository call. If the network mutation fails, BookmarksViewModel.toggleBookmark rolls the overlay back, but the user will have already seen a "Bookmarked" / "Bookmark removed" toast. Because this screen also filters the list on viewerHasBookmarked, a failed unbookmark will briefly remove the row and then restore it, compounding the mismatch.

Consider driving the toast from the ViewModel (e.g., a one-shot event Flow / Channel emitted on success/failure) instead of firing it optimistically from the composable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt`
around lines 207 - 221, The Toast is shown optimistically in the composable
(inside onBookmarkClick) before the mutation (viewModel.toggleBookmark)
resolves, causing UI/Toast mismatch on rollback; remove the synchronous
Toast.makeText(...) call from the onBookmarkClick handler and instead emit a
one-shot success/failure event from BookmarksViewModel.toggleBookmark (e.g., a
SharedFlow/Channel/SingleLiveEvent) after the repository call completes or after
rollback, carrying enough info to pick the correct string (use
displayPost.viewerHasBookmarked or the result state). In the composable
subscribe/collect that one-shot event and show the Toast there so the toast
reflects the final resolved outcome.

Comment on lines +142 to +152
viewModelScope.launch {
val result = if (wasReacted) {
repository.removeReactionFromPost(target.id, emoji)
} else {
repository.addReactionToPost(target.id, emoji)
}
result.onFailure {
overlayStore.clear(target.id)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify PostOverlay field names and the semantics of `clear` vs `mutate` so the fix uses the correct "no override" value.
fd -t f 'PostOverlay*.kt' -x cat {}

Repository: hackers-pub/android

Length of output: 4227


🏁 Script executed:

cat -n app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt | head -200

Repository: hackers-pub/android

Length of output: 8197


overlayStore.clear(target.id) wipes unrelated optimistic state on reaction failure.

On reaction mutation failure, clear(target.id) removes the entire overlay entry for this post — including any optimistic viewerHasBookmarked, viewerHasShared, shareDelta, etc. that the user set independently via toggleBookmark / sharePost and that are still in-flight or already confirmed locally. A failed reaction should only revert reactionOverride / reactionCountOverride.

Unlike toggleBookmark and sharePost / unsharePost, which correctly revert only their specific fields via mutate(...) { prev -> prev.copy(...) }, toggleReaction incorrectly blanks the entire overlay on failure.

🐛 Suggested rollback scoped to reaction fields
             result.onFailure {
-                overlayStore.clear(target.id)
+                overlayStore.mutate(target.id) { prev ->
+                    prev.copy(
+                        reactionOverride = null,
+                        reactionCountOverride = null,
+                    )
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
viewModelScope.launch {
val result = if (wasReacted) {
repository.removeReactionFromPost(target.id, emoji)
} else {
repository.addReactionToPost(target.id, emoji)
}
result.onFailure {
overlayStore.clear(target.id)
}
}
}
viewModelScope.launch {
val result = if (wasReacted) {
repository.removeReactionFromPost(target.id, emoji)
} else {
repository.addReactionToPost(target.id, emoji)
}
result.onFailure {
overlayStore.mutate(target.id) { prev ->
prev.copy(
reactionOverride = null,
reactionCountOverride = null,
)
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt`
around lines 142 - 152, The failure handler in toggleReaction currently calls
overlayStore.clear(target.id) which wipes all optimistic state; instead, in
result.onFailure replace that clear call with a scoped rollback using
overlayStore.mutate(target.id) { prev -> prev.copy(...) } so only
reaction-related fields are reverted: set reactionOverride back to null (or to
its previous value) and adjust reactionCountOverride to undo the optimistic
increment/decrement applied when toggling the reaction, leaving other fields
(viewerHasBookmarked, viewerHasShared, shareDelta, etc.) untouched.

Comment thread app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt Outdated
Comment on lines +289 to +312
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)
)
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

No failure feedback on bookmark toggle; inconsistent with other screens.

Two concerns:

  1. Silent failure: Unlike sharePost/unsharePost here (which only act on success) or the toast in Timeline/Explore screens, toggleBookmark rolls back the optimistic change on failure but never surfaces an error to the UI. The PR adds R.string.failed_to_bookmark / R.string.failed_to_remove_bookmark strings — consider emitting those via a transient error/event state so PostDetailScreen can show a snackbar on failure.

  2. Reentrancy: There's no guard against rapid re-taps while a mutation is in flight (compare with isReacting on toggleReaction). Rapid toggles can produce interleaved bookmark/unbookmark calls with out-of-order rollbacks leaving UI state inconsistent with the server. Consider an isBookmarking flag to no-op during a pending request.

Proposed sketch
 fun toggleBookmark() {
+    if (_uiState.value.isBookmarking) return
     val post = _uiState.value.post ?: return
     val willBookmark = !post.viewerHasBookmarked

     _uiState.update {
-        it.copy(post = post.copy(viewerHasBookmarked = willBookmark))
+        it.copy(post = post.copy(viewerHasBookmarked = willBookmark), isBookmarking = true)
     }

     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),
+                    isBookmarking = false,
+                    bookmarkError = if (willBookmark) R.string.failed_to_bookmark
+                                    else R.string.failed_to_remove_bookmark,
                 )
             }
-        }
+        }.onSuccess {
+            _uiState.update { it.copy(isBookmarking = false) }
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt`
around lines 289 - 312, toggleBookmark currently performs an optimistic update
but neither surfaces errors to the UI nor prevents reentrant taps; add an
isBookmarking Boolean in PostDetailViewModel to early-return while a request is
in flight, update it around the network call, and emit a transient error
event/string resource on failure so PostDetailScreen can show a snackbar;
specifically: in toggleBookmark guard with isBookmarking, set isBookmarking =
true before calling repository.bookmarkPost/post.unbookmarkPost and false in
finally, keep the optimistic _uiState update using post.copy(viewerHasBookmarked
= willBookmark), and on result.onFailure emit the appropriate
R.string.failed_to_bookmark or R.string.failed_to_remove_bookmark via the
ViewModel’s event/transient error channel while rolling back the optimistic
change as already implemented.

Comment on lines +268 to +282
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)
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Toast is shown before the mutation completes, so failures silently lie to the user.

TimelineViewModel.toggleBookmark optimistically flips viewerHasBookmarked and rolls back on failure, but this toast fires unconditionally based on the pre-toggle state. If repository.bookmarkPost/unbookmarkPost fails, the user has already seen "Bookmarked" / "Bookmark removed" while the UI silently reverts — there's no corresponding failed_to_bookmark / failed_to_remove_bookmark feedback (despite those strings being added in this PR).

Consider either:

  • Moving the toast to the ViewModel so it can emit success vs. the failed_to_* string via a one-shot event/SnackbarHostState, or
  • Exposing a failure flow the screen can observe and surface the existing R.string.failed_to_bookmark / R.string.failed_to_remove_bookmark strings.

Also note this handler is duplicated verbatim in ExploreScreen.kt (lines 219-235) — worth extracting once the failure path is added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt`
around lines 268 - 282, The toast in TimelineScreen's onBookmarkClick handler is
shown before TimelineViewModel.toggleBookmark completes, causing misleading
success messages; move toast/snackbar emission into the ViewModel (or expose a
failure Flow) so success vs failure messages are emitted after
repository.bookmarkPost/unbookmarkPost returns, using a one-shot event (e.g.,
Channel/SharedFlow) or a SnackbarHostState event for R.string.bookmarked,
R.string.bookmark_removed, R.string.failed_to_bookmark, and
R.string.failed_to_remove_bookmark; update TimelineViewModel.toggleBookmark to
emit the appropriate success/failure event after the mutation (rollback still
allowed) and have TimelineScreen observe that event to show the toast/snackbar,
and also extract the shared onBookmarkClick handler into a common function used
by ExploreScreen.kt to avoid duplication.

malkoG and others added 6 commits April 21, 2026 12:29
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
@malkoG malkoG marked this pull request as ready for review April 21, 2026 03:52
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 <noreply@openai.com>
Assisted-By: Codex(GPT-5)
@malkoG malkoG changed the title Add bookmark support behind a feature flag Add bookmark support and bookmarks screen Apr 21, 2026
@malkoG malkoG merged commit 14679b6 into hackers-pub:main Apr 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant