Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions CLAUDE.md
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/graphql/pub/hackers/android/operations.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ fragment PostFields on Post {
url
iri
viewerHasShared
viewerHasBookmarked
actor {
...ActorFields
}
Expand Down Expand Up @@ -102,6 +103,7 @@ fragment SharedPostFields on Post {
url
iri
viewerHasShared
viewerHasBookmarked
actor {
...ActorFields
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
57 changes: 56 additions & 1 deletion app/src/main/graphql/pub/hackers/android/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ type Article implements Node & Post & Reactable {

uuid: UUID!

viewerHasBookmarked: Boolean!

viewerHasShared: Boolean!

visibility: PostVisibility!
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

"""
Expand Down Expand Up @@ -939,6 +957,8 @@ type Mutation {

unblockActor(input: UnblockActorInput!): UnblockActorResult!

unbookmarkPost(input: UnbookmarkPostInput!): UnbookmarkPostResult!

unfollowActor(input: UnfollowActorInput!): UnfollowActorResult!

unregisterApnsDeviceToken(input: UnregisterApnsDeviceTokenInput!): UnregisterApnsDeviceTokenResult!
Expand Down Expand Up @@ -1011,6 +1031,8 @@ type Note implements Node & Post & Reactable {

uuid: UUID!

viewerHasBookmarked: Boolean!

viewerHasShared: Boolean!

visibility: PostVisibility!
Expand Down Expand Up @@ -1207,6 +1229,8 @@ interface Post implements Node & Reactable {

uuid: UUID!

viewerHasBookmarked: Boolean!

viewerHasShared: Boolean!

visibility: PostVisibility!
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1429,6 +1455,18 @@ type Query {
viewer: Account
}

type QueryBookmarksConnection {
edges: [QueryBookmarksConnectionEdge!]!

pageInfo: PageInfo!
}

type QueryBookmarksConnectionEdge {
cursor: String!

node: Post!
}

type QueryPersonalTimelineConnection {
edges: [QueryPersonalTimelineConnectionEdge!]!

Expand Down Expand Up @@ -1530,6 +1568,8 @@ type Question implements Node & Post & Reactable {

uuid: UUID!

viewerHasBookmarked: Boolean!

viewerHasShared: Boolean!

visibility: PostVisibility!
Expand Down Expand Up @@ -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!

Expand Down Expand Up @@ -2023,4 +2079,3 @@ type WebFingerResult {

url: URL
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactionGroup>? = null, // full replacement when we touched reactions
val reactionCountOverride: Int? = null, // engagementStats.reactions override
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -196,6 +199,36 @@ class HackersPubRepository @Inject constructor(
}
}

suspend fun bookmarkPost(postId: String): Result<Unit> {
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<Unit> {
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<String>
Expand All @@ -221,6 +254,39 @@ class HackersPubRepository @Inject constructor(
}
}

suspend fun getBookmarks(
after: String? = null,
postType: pub.hackers.android.graphql.type.PostType? = null,
): Result<TimelineResult> {
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<PostDetailResult> {
return try {
val response = apolloClient.query(
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Media>,
val link: PostLink? = null,
Expand Down
Loading
Loading