From a0988dc6c6fb51a58e957ba50f57fbe44a0a664b Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Wed, 3 Oct 2018 21:27:52 +0200 Subject: [PATCH] Add ability to pin/unpin statuses (#872) --- app/build.gradle | 2 ++ .../keylesspalace/tusky/di/NetworkModule.kt | 2 ++ .../com/keylesspalace/tusky/entity/Status.kt | 3 +- .../tusky/fragment/SFragment.java | 30 ++++++++++++------- .../tusky/network/MastodonApi.java | 7 +++++ .../tusky/network/TimelineCases.kt | 21 ++++++++++++- app/src/main/res/values/ids.xml | 4 +++ app/src/main/res/values/strings.xml | 2 ++ .../tusky/BottomSheetActivityTest.kt | 3 +- 9 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 app/src/main/res/values/ids.xml diff --git a/app/build.gradle b/app/build.gradle index 6697eb5c..92c4d336 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -113,6 +113,8 @@ dependencies { debugImplementation 'im.dino:dbinspector:3.4.1@aar' implementation 'io.reactivex.rxjava2:rxjava:2.2.1' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' + implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' + implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0' implementation 'com.uber.autodispose:autodispose-android-archcomponents:1.0.0-RC2' implementation 'com.uber.autodispose:autodispose-ktx:1.0.0-RC2' } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index d7418ad6..473058ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -36,6 +36,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Converter import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton @@ -93,6 +94,7 @@ class NetworkModule { converters.fold(builder) { b, c -> b.addConverterFactory(c) } + builder.addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync()) } .build() diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 3a1ddd70..94636774 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -38,7 +38,8 @@ data class Status( val visibility: Visibility, @SerializedName("media_attachments") var attachments: List, val mentions: Array, - val application: Application? + val application: Application?, + var pinned: Boolean? ) { val actionableId: String? diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index fdd50a00..ac34f2e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -20,6 +20,7 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.view.ViewCompat; @@ -62,6 +63,7 @@ public abstract class SFragment extends BaseFragment { protected String loggedInUsername; protected abstract TimelineCases timelineCases(); + protected abstract void removeItem(int position); protected abstract void onReblog(final boolean reblog, final int position); @@ -92,8 +94,8 @@ public abstract class SFragment extends BaseFragment { @Override public void onAttach(Context context) { super.onAttach(context); - if(context instanceof BottomSheetActivity) { - bottomSheetActivity = (BottomSheetActivity)context; + if (context instanceof BottomSheetActivity) { + bottomSheetActivity = (BottomSheetActivity) context; } else { throw new IllegalStateException("Fragment must be attached to a BottomSheetActivity!"); } @@ -139,7 +141,7 @@ public abstract class SFragment extends BaseFragment { getActivity().startActivity(intent); } - protected void more(final Status status, View view, final int position) { + protected void more(@NonNull final Status status, View view, final int position) { final String id = status.getActionableId(); final String accountId = status.getActionableStatus().getAccount().getId(); final String accountUsename = status.getActionableStatus().getAccount().getUsername(); @@ -157,6 +159,10 @@ public abstract class SFragment extends BaseFragment { if (status.getReblog() != null) reblogged = status.getReblog().getReblogged(); menu.findItem(R.id.status_reblog_private).setVisible(!reblogged); menu.findItem(R.id.status_unreblog_private).setVisible(reblogged); + } else { + final String textId = + getString(status.getPinned() ? R.string.unpin_action : R.string.pin_action); + menu.add(0, R.id.pin, 1, textId); } } popup.setOnMenuItemClickListener(item -> { @@ -213,6 +219,10 @@ public abstract class SFragment extends BaseFragment { showConfirmDeleteDialog(id, position); return true; } + case R.id.pin: { + timelineCases().pin(status, !status.getPinned()); + return true; + } } return false; }); @@ -276,12 +286,12 @@ public abstract class SFragment extends BaseFragment { protected void showConfirmDeleteDialog(final String id, final int position) { new AlertDialog.Builder(getActivity()) - .setMessage(R.string.dialog_delete_toot_warning) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { - timelineCases().delete(id); - removeItem(position); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); + .setMessage(R.string.dialog_delete_toot_warning) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + timelineCases().delete(id); + removeItem(position); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 0e56aced..497a9745 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -33,6 +33,7 @@ import com.keylesspalace.tusky.entity.StatusContext; import java.util.List; +import io.reactivex.Single; import okhttp3.MultipartBody; import okhttp3.RequestBody; import okhttp3.ResponseBody; @@ -156,6 +157,12 @@ public interface MastodonApi { @POST("api/v1/statuses/{id}/unfavourite") Call unfavouriteStatus(@Path("id") String statusId); + @POST("api/v1/statuses/{id}/pin") + Single pinStatus(@Path("id") String statusId); + + @POST("api/v1/statuses/{id}/unpin") + Single unpinStatus(@Path("id") String statusId); + @GET("api/v1/accounts/verify_credentials") Call accountVerifyCredentials(); diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index 7ceaa9a9..fe900870 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -15,12 +15,14 @@ package com.keylesspalace.tusky.network -import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Callback @@ -36,12 +38,20 @@ interface TimelineCases { fun mute(id: String) fun block(id: String) fun delete(id: String) + fun pin(status: Status, pin: Boolean) } class TimelineCasesImpl( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : TimelineCases { + + /** + * Unused yet but can be use for cancellation later. It's always a good idea to save + * Disposables. + */ + private val cancelDisposable = CompositeDisposable() + override fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback) { val id = status.actionableId @@ -95,4 +105,13 @@ class TimelineCasesImpl( eventHub.dispatch(StatusDeletedEvent(id)) } + override fun pin(status: Status, pin: Boolean) { + // Replace with extension method if we use RxKotlin + (if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id)) + .subscribe({ updatedStatus -> + status.pinned = updatedStatus.pinned + }, {}) + .addTo(this.cancelDisposable) + } + } \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 00000000..55ca7d93 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46c15b24..a912b4fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -353,5 +353,7 @@ Content Use absolute time + Unpin + Pin diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 916ab937..146682de 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -83,7 +83,8 @@ class BottomSheetActivityTest { Status.Visibility.PUBLIC, listOf(), arrayOf(), - null + null, + pinned = false ) private val statusCallback = FakeSearchResults(status)