feat: choose boost visibility (#3095) (#4944)

Closes #3095

---

Since I had to add `visibility` on the `onReblog` method, it is possible
that I have broken something. Also, I kept the old method signature
(which calls the new one), but it's possible that it is not needed.

~~I'm not 100% sure that unlisted boost works, as the visibility of the
boost is not shown anywehere.~~ I've confirmed that private
(followers-only) boosts are not visible on private browsing on the
browser.

EDIT: Confirmed visibility in devtools on web browser.

Screenshots:


![image](https://github.com/user-attachments/assets/f5bb8bcd-d13a-4263-8b2f-59f25621a6b1)
![image](https://github.com/user-attachments/assets/4e050e96-151d-45a5-9cd9-a7afd3a42c17)
This commit is contained in:
Elouan Martinet 2025-02-26 19:47:50 +00:00 committed by GitHub
commit d334bd0c40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 97 additions and 66 deletions

View file

@ -678,7 +678,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
showConfirmReblog(listener, buttonState, position);
return false;
} else {
listener.onReblog(!buttonState, position);
listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC);
return true;
}
} else {
@ -739,13 +739,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.inflate(R.menu.status_reblog);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_reblog).setVisible(false);
menu.setGroupVisible(R.id.menu_action_reblog_group, false);
} else {
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onReblog(!buttonState, position);
if (!buttonState) {
if (buttonState) {
listener.onReblog(false, position, Status.Visibility.PUBLIC);
} else {
Status.Visibility visibility;
if (item.getItemId() == R.id.menu_action_reblog_public) {
visibility = Status.Visibility.PUBLIC;
} else if (item.getItemId() == R.id.menu_action_reblog_unlisted) {
visibility = Status.Visibility.UNLISTED;
} else if (item.getItemId() == R.id.menu_action_reblog_private) {
visibility = Status.Visibility.PRIVATE;
} else {
visibility = Status.Visibility.PUBLIC;
}
listener.onReblog(true, position, visibility);
reblogButton.playAnimation();
reblogButton.setChecked(true);
}

View file

@ -41,6 +41,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
@ -231,7 +232,7 @@ class ConversationsFragment :
adapter?.refresh()
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
// its impossible to reblog private messages
}
@ -335,7 +336,7 @@ class ConversationsFragment :
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
override fun onVoteInPoll(position: Int, choices: List<Int>) {
adapter?.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}

View file

@ -55,6 +55,7 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationChanne
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
@ -335,9 +336,9 @@ class NotificationsFragment :
viewModel.remove(notification.id)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
viewModel.reblog(reblog, status, visibility)
}
override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit

View file

@ -45,6 +45,7 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
@ -204,8 +205,8 @@ class NotificationsViewModel @Inject constructor(
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog).onFailure { t ->
fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t ->
ifExpected(t) {
Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
}

View file

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
import com.keylesspalace.tusky.databinding.FragmentNotificationRequestDetailsBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
@ -160,9 +161,9 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
viewModel.remove(notification)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
viewModel.reblog(reblog, status, visibility)
}
override val onMoreTranslate: ((Boolean, Int) -> Unit)?

View file

@ -158,8 +158,8 @@ class NotificationRequestDetailsViewModel @AssistedInject constructor(
currentSource?.invalidate()
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog).onFailure { t ->
fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC) = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t ->
ifExpected(t) {
Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
}

View file

@ -139,9 +139,9 @@ class SearchViewModel @Inject constructor(
updateStatusViewData(statusViewData.copy(isExpanded = expanded))
}
fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean, visibility: Status.Visibility = Status.Visibility.PUBLIC) {
viewModelScope.launch {
timelineCases.reblog(statusViewData.id, reblog).fold({
timelineCases.reblog(statusViewData.id, reblog, visibility).fold({
updateStatus(
statusViewData.status.copy(
reblogged = reblog,
@ -162,7 +162,7 @@ class SearchViewModel @Inject constructor(
updateStatusViewData(statusViewData.copy(isCollapsed = collapsed))
}
fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList<Int>) {
fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: List<Int>) {
val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices)
updateStatus(statusViewData.status.copy(poll = votedPoll))
viewModelScope.launch {

View file

@ -251,7 +251,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
override fun onVoteInPoll(position: Int, choices: List<Int>) {
adapter?.peek(position)?.let {
viewModel.voteInPoll(it, choices)
}
@ -265,9 +265,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
adapter?.peek(position)?.let { status ->
viewModel.reblog(status, reblog)
viewModel.reblog(status, reblog, visibility)
}
}

View file

@ -415,9 +415,9 @@ class TimelineFragment :
super.reply(status.status)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
viewModel.reblog(reblog, status, visibility)
}
private fun onTranslate(position: Int) {

View file

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
@ -107,9 +108,9 @@ abstract class TimelineViewModel(
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch {
try {
timelineCases.reblog(status.actionableId, reblog).getOrThrow()
timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)

View file

@ -40,6 +40,7 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
@ -324,9 +325,9 @@ class ViewThreadFragment :
super.reply(viewData.status)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.reblog(reblog, status)
viewModel.reblog(reblog, status, visibility)
}
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) =

View file

@ -196,9 +196,9 @@ class ViewThreadViewModel @Inject constructor(
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch {
try {
timelineCases.reblog(status.actionableId, reblog).getOrThrow()
timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)

View file

@ -74,7 +74,7 @@ import kotlinx.coroutines.launch
* up what needs to be where. */
abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) {
protected abstract fun removeItem(position: Int)
protected abstract fun onReblog(reblog: Boolean, position: Int)
protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility)
/** `null` if translation is not supported on this screen */
protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)?
@ -318,12 +318,12 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo
}
R.id.status_unreblog_private -> {
onReblog(false, position)
onReblog(false, position, Status.Visibility.PUBLIC)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position)
onReblog(true, position, Status.Visibility.PUBLIC)
return@setOnMenuItemClickListener true
}

View file

@ -12,60 +12,55 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.interfaces
package com.keylesspalace.tusky.interfaces;
import android.view.View
import com.keylesspalace.tusky.entity.Status
import android.view.View;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public interface StatusActionListener extends LinkListener {
void onReply(int position);
void onReblog(final boolean reblog, final int position);
void onFavourite(final boolean favourite, final int position);
void onBookmark(final boolean bookmark, final int position);
void onMore(@NonNull View view, final int position);
void onViewMedia(int position, int attachmentIndex, @Nullable View view);
void onViewThread(int position);
interface StatusActionListener : LinkListener {
fun onReply(position: Int)
fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility = Status.Visibility.PUBLIC)
fun onFavourite(favourite: Boolean, position: Int)
fun onBookmark(bookmark: Boolean, position: Int)
fun onMore(view: View, position: Int)
fun onViewMedia(position: Int, attachmentIndex: Int, view: View?)
fun onViewThread(position: Int)
/**
* Open reblog author for the status.
* @param position At which position in the list status is located
*/
void onOpenReblog(int position);
void onExpandedChange(boolean expanded, int position);
void onContentHiddenChange(boolean isShowing, int position);
void onLoadMore(int position);
fun onOpenReblog(position: Int)
fun onExpandedChange(expanded: Boolean, position: Int)
fun onContentHiddenChange(isShowing: Boolean, position: Int)
fun onLoadMore(position: Int)
/**
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
* status content is interacted with.
*
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
* @param position The position of the status in the list.
*/
void onContentCollapsedChange(boolean isCollapsed, int position);
fun onContentCollapsedChange(isCollapsed: Boolean, position: Int)
/**
* called when the reblog count has been clicked
* @param position The position of the status in the list.
*/
default void onShowReblogs(int position) {}
fun onShowReblogs(position: Int) {}
/**
* called when the favourite count has been clicked
* @param position The position of the status in the list.
*/
default void onShowFavs(int position) {}
fun onShowFavs(position: Int) {}
void onVoteInPoll(int position, @NonNull List<Integer> choices);
fun onVoteInPoll(position: Int, choices: List<Int>)
default void onShowEdits(int position) {}
fun onShowEdits(position: Int) {}
void clearWarningAction(int position);
fun clearWarningAction(position: Int)
void onUntranslate(int position);
fun onUntranslate(position: Int)
}

View file

@ -252,8 +252,9 @@ interface MastodonApi {
@DELETE("api/v1/statuses/{id}")
suspend fun deleteStatus(@Path("id") statusId: String): NetworkResult<DeletedStatus>
@FormUrlEncoded
@POST("api/v1/statuses/{id}/reblog")
suspend fun reblogStatus(@Path("id") statusId: String): NetworkResult<Status>
suspend fun reblogStatus(@Path("id") statusId: String, @Field("visibility") visibility: String?): NetworkResult<Status>
@POST("api/v1/statuses/{id}/unreblog")
suspend fun unreblogStatus(@Path("id") statusId: String): NetworkResult<Status>

View file

@ -44,9 +44,9 @@ class TimelineCases @Inject constructor(
private val eventHub: EventHub
) {
suspend fun reblog(statusId: String, reblog: Boolean): NetworkResult<Status> {
suspend fun reblog(statusId: String, reblog: Boolean, visibility: Status.Visibility = Status.Visibility.PUBLIC): NetworkResult<Status> {
return if (reblog) {
mastodonApi.reblogStatus(statusId)
mastodonApi.reblogStatus(statusId, visibility.stringValue)
} else {
mastodonApi.unreblogStatus(statusId)
}.onSuccess { status ->