A fork of the Mastodon Android client with Bluesky/ATProto support.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): add bookmarks support

+142 -4
+1 -1
README.md
··· 25 25 - [X] Like, repost, reply, quote, and pin 26 26 - [X] Search 27 27 - [ ] Mute, block, and report 28 - - [ ] Bookmarks 28 + - [X] Bookmarks 29 29 - [ ] Lists 30 30 - [ ] Filters 31 31 - [ ] Trending posts
+36
mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java
··· 32 32 import app.bsky.actor.Profile; 33 33 import app.bsky.feed.Like; 34 34 import app.bsky.feed.Repost; 35 + import kotlin.Unit; 35 36 import kotlin.time.Clock; 36 37 import me.grishka.appkit.api.Callback; 37 38 import me.grishka.appkit.api.ErrorResponse; ··· 138 139 public void setBookmarked(Status status, boolean bookmarked){ 139 140 if(!Looper.getMainLooper().isCurrentThread()) 140 141 throw new IllegalStateException("Can only be called from main thread"); 142 + 143 + AccountSession session=AccountSessionManager.get(accountID); 144 + if(session.protocol==Protocol.ATPROTO){ 145 + setBookmarkedAtProto(session, status, bookmarked); 146 + return; 147 + } 141 148 142 149 SetStatusBookmarked current=runningBookmarkRequests.remove(status.id); 143 150 if(current!=null){ ··· 278 285 else 279 286 status.reblogsCount++; 280 287 E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.REBLOGS)); 288 + Toast.makeText(MastodonApp.context, "Action failed: "+e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); 289 + }); 290 + } 291 + }); 292 + } 293 + 294 + private void setBookmarkedAtProto(AccountSession session, Status status, boolean bookmarked){ 295 + status.bookmarked=bookmarked; 296 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.BOOKMARKS)); 297 + 298 + MastodonAPIController.runInBackground(()->{ 299 + try{ 300 + if(bookmarked){ 301 + AtpResponse<Unit> resp = session.executeBluesky((client)->{ 302 + return AtProtoClient.createBookmark(client, AtProtoClient.createCreateBookmarkRequest(status.uri, status.cid)); 303 + }); 304 + if(!(resp instanceof AtpResponse.Success)){ 305 + throw new Exception("Failed to create bookmark: "+resp); 306 + } 307 + }else{ 308 + session.executeBluesky((client)->{ 309 + return AtProtoClient.deleteBookmark(client, AtProtoClient.createDeleteBookmarkRequest(status.uri)); 310 + }); 311 + } 312 + }catch(Exception e){ 313 + Handler uiHandler=new Handler(Looper.getMainLooper()); 314 + uiHandler.post(()->{ 315 + status.bookmarked=!bookmarked; 316 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.BOOKMARKS)); 281 317 Toast.makeText(MastodonApp.context, "Action failed: "+e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); 282 318 }); 283 319 }
+36
mastodon/src/main/java/org/joinmastodon/android/api/atproto/AtProtoClient.kt
··· 28 28 import app.bsky.graph.* 29 29 import app.bsky.notification.* 30 30 import app.bsky.richtext.* 31 + import app.bsky.bookmark.* 31 32 import kotlin.time.Instant 32 33 import kotlin.time.Clock 33 34 import io.ktor.utils.io.ByteReadChannel ··· 534 535 @JvmStatic 535 536 @JvmOverloads 536 537 fun createRepost(subject: StrongRef, createdAt: Instant, via: StrongRef? = null): Repost = Repost(subject, createdAt, via) 538 + 539 + @JvmStatic 540 + fun createCreateBookmarkRequest(uri: String, cid: String): CreateBookmarkRequest = CreateBookmarkRequest(AtUri(uri), Cid(cid)) 541 + 542 + @JvmStatic 543 + fun createDeleteBookmarkRequest(uri: String): DeleteBookmarkRequest = DeleteBookmarkRequest(AtUri(uri)) 544 + 545 + @JvmStatic 546 + fun createGetBookmarksQueryParams(limit: Long?, cursor: String?): GetBookmarksQueryParams = GetBookmarksQueryParams(limit, cursor) 547 + 548 + @JvmStatic 549 + fun createGetActorLikesQueryParams(actor: String, limit: Long?, cursor: String?): GetActorLikesQueryParams = GetActorLikesQueryParams(createAtIdentifier(actor), limit, cursor) 550 + 551 + @JvmStatic 552 + fun getActorLikes(client: XrpcBlueskyApi, params: GetActorLikesQueryParams): AtpResponse<GetActorLikesResponse> = runBlocking { 553 + client.getActorLikes(params) 554 + } 555 + 556 + @JvmStatic 557 + fun createBookmark(client: XrpcBlueskyApi, request: CreateBookmarkRequest): AtpResponse<Unit> = runBlocking { 558 + client.createBookmark(request) 559 + } 560 + 561 + @JvmStatic 562 + fun deleteBookmark(client: XrpcBlueskyApi, request: DeleteBookmarkRequest): AtpResponse<Unit> = runBlocking { 563 + client.deleteBookmark(request) 564 + } 565 + 566 + @JvmStatic 567 + fun getBookmarks(client: XrpcBlueskyApi, params: GetBookmarksQueryParams): AtpResponse<GetBookmarksResponse> = runBlocking { 568 + client.getBookmarks(params) 569 + } 570 + 571 + @JvmStatic 572 + fun getBookmarksList(response: GetBookmarksResponse): List<BookmarkView> = response.bookmarks 537 573 }
+2
mastodon/src/main/java/org/joinmastodon/android/api/atproto/AtProtoMapper.kt
··· 347 347 post.viewer?.let { viewer -> 348 348 status.favourited = viewer.like != null 349 349 status.reblogged = viewer.repost != null 350 + status.bookmarked = viewer.bookmarked ?: false 351 + status.pinned = viewer.pinned ?: false 350 352 status.atProtoLikeUri = viewer.like?.atUri 351 353 status.atProtoRepostUri = viewer.repost?.atUri 352 354
+4 -3
mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java
··· 41 41 private GetAccountStatuses.Filter filter; 42 42 private HorizontalScrollView filtersBar; 43 43 private FilterChipView defaultFilter, withRepliesFilter, mediaFilter; 44 + private String atProtoNextCursor; 44 45 45 46 public AccountTimelineFragment(){ 46 47 setListLayoutId(R.layout.recycler_fragment_no_refresh); ··· 106 107 default->null; 107 108 }; 108 109 AtpResponse<GetAuthorFeedResponse> resp=session.executeBluesky((client)->{ 109 - GetAuthorFeedQueryParams params=AtProtoClient.createGetAuthorFeedQueryParams(user.username, (long)count, offset>0 ? getMaxID() : null, filterVal, false); 110 + GetAuthorFeedQueryParams params=AtProtoClient.createGetAuthorFeedQueryParams(user.username, (long)count, offset>0 ? atProtoNextCursor : null, filterVal, false); 110 111 return AtProtoClient.getAuthorFeed(client,params); 111 112 }); 112 113 if(resp instanceof AtpResponse.Success<GetAuthorFeedResponse> success){ 113 114 GetAuthorFeedResponse data=success.getResponse(); 114 115 List<Status> result=AtProtoMapper.mapFeed(data.getFeed()); 115 - String nextCursor=data.getCursor(); 116 + atProtoNextCursor=data.getCursor(); 116 117 117 118 getActivity().runOnUiThread(()->{ 118 119 session.filterStatuses(result, FilterContext.ACCOUNT); 119 - onDataLoaded(result, nextCursor!=null); 120 + onDataLoaded(result, atProtoNextCursor!=null); 120 121 }); 121 122 }else{ 122 123 throw new Exception("Failed to load author feed: "+resp);
+63
mastodon/src/main/java/org/joinmastodon/android/fragments/SavedPostsTimelineFragment.java
··· 10 10 import android.widget.TextView; 11 11 12 12 import org.joinmastodon.android.R; 13 + import org.joinmastodon.android.api.MastodonErrorResponse; 13 14 import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses; 14 15 import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses; 16 + import org.joinmastodon.android.api.atproto.AtProtoClient; 17 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 18 + import org.joinmastodon.android.api.session.AccountSessionManager; 19 + import org.joinmastodon.android.api.MastodonAPIController; 20 + 21 + import app.bsky.feed.FeedViewPost; 22 + import app.bsky.feed.GetActorLikesResponse; 23 + import sh.christian.ozone.api.response.AtpResponse; 24 + import app.bsky.bookmark.GetBookmarksResponse; 25 + import app.bsky.bookmark.BookmarkView; 26 + import app.bsky.bookmark.BookmarkViewItemUnion; 27 + import app.bsky.feed.GetLikesResponse; 28 + import app.bsky.feed.GetLikesLike; 29 + import app.bsky.feed.GetLikesQueryParams; 30 + 31 + import java.util.ArrayList; 32 + import java.util.List; 15 33 import org.joinmastodon.android.events.RemoveAccountPostsEvent; 16 34 import org.joinmastodon.android.model.Account; 17 35 import org.joinmastodon.android.model.HeaderPaginationList; 36 + import org.joinmastodon.android.model.Protocol; 18 37 import org.joinmastodon.android.model.Status; 19 38 import org.joinmastodon.android.ui.drawables.EmptyDrawable; 20 39 import org.joinmastodon.android.ui.views.FilterChipView; ··· 66 85 67 86 @Override 68 87 protected void doLoadData(int offset, int count){ 88 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 89 + doLoadDataATProto(offset, count); 90 + return; 91 + } 69 92 currentRequest=(switch(mode){ 70 93 case FAVORITES -> new GetFavoritedStatuses(offset>0 ? nextPageMaxID : null, count); 71 94 case BOOKMARKS -> new GetBookmarkedStatuses(offset>0 ? nextPageMaxID : null, count); ··· 78 101 nextPageMaxID=result.getNextPageMaxID(); 79 102 } 80 103 }).exec(accountID); 104 + } 105 + 106 + protected void doLoadDataATProto(int offset, int count){ 107 + MastodonAPIController.runInBackground(()->{ 108 + try{ 109 + if(mode==Mode.BOOKMARKS){ 110 + var params=AtProtoClient.createGetBookmarksQueryParams((long)count, offset==0 ? null : nextPageMaxID); 111 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getBookmarks(client, params)); 112 + if(resp instanceof AtpResponse.Success<GetBookmarksResponse> success){ 113 + GetBookmarksResponse result=success.getResponse(); 114 + List<BookmarkView> bookmarks=AtProtoClient.getBookmarksList(result); 115 + List<Status> statuses=new ArrayList<>(); 116 + for(BookmarkView b:bookmarks){ 117 + if(b.getItem() instanceof BookmarkViewItemUnion.PostView item){ 118 + Status s=AtProtoMapper.mapPost(item.getValue()); 119 + statuses.add(s); 120 + } 121 + } 122 + nextPageMaxID=result.getCursor(); 123 + getActivity().runOnUiThread(()->onDataLoaded(statuses, nextPageMaxID!=null)); 124 + }else{ 125 + throw new Exception("Failed to load bookmarks: "+resp); 126 + } 127 + }else{ 128 + // FAVORITES 129 + var params=AtProtoClient.createGetActorLikesQueryParams(user.id, (long)count, offset==0 ? null : nextPageMaxID); 130 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getActorLikes(client, params)); 131 + if(resp instanceof AtpResponse.Success<GetActorLikesResponse> success){ 132 + GetActorLikesResponse result=success.getResponse(); 133 + List<Status> statuses=AtProtoMapper.mapFeed(result.getFeed()); 134 + nextPageMaxID=result.getCursor(); 135 + getActivity().runOnUiThread(()->onDataLoaded(statuses, nextPageMaxID!=null)); 136 + }else{ 137 + throw new Exception("Failed to load likes: "+resp); 138 + } 139 + } 140 + }catch(Exception x){ 141 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 142 + } 143 + }); 81 144 } 82 145 83 146 @Override