Add support for muting conversations (#1732)
* Add support for muting conversations Implements #1731 * Fix CI * Apply code review feedback
This commit is contained in:
parent
8e54e4ae16
commit
8cb83050ac
21 changed files with 904 additions and 19 deletions
|
@ -8,6 +8,7 @@ import com.keylesspalace.tusky.entity.Status
|
|||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
|
||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable
|
||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable
|
||||
data class UnfollowEvent(val accountId: String) : Dispatchable
|
||||
data class BlockEvent(val accountId: String) : Dispatchable
|
||||
data class MuteEvent(val accountId: String) : Dispatchable
|
||||
|
|
|
@ -157,6 +157,7 @@ data class ConversationStatusEntity(
|
|||
mentions = mentions,
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = poll,
|
||||
card = null)
|
||||
}
|
||||
|
|
|
@ -213,6 +213,18 @@ class SearchViewModel @Inject constructor(
|
|||
search(currentQuery)
|
||||
}
|
||||
|
||||
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setMuted(mute).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
timelineCases.muteConversation(status.first, mute)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SearchViewModel"
|
||||
|
|
|
@ -49,6 +49,7 @@ import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
|||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.Status.Mention
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
|
@ -228,12 +229,9 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
val loggedInAccountId = viewModel.activeAccount?.accountId
|
||||
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val statusIsByCurrentUser = loggedInAccountId?.equals(accountId) == true
|
||||
// Give a different menu depending on whether this is the user's own toot or not.
|
||||
if (loggedInAccountId == null || loggedInAccountId != accountId) {
|
||||
popup.inflate(R.menu.status_more)
|
||||
val menu = popup.menu
|
||||
menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
||||
} else {
|
||||
if (statusIsByCurrentUser) {
|
||||
popup.inflate(R.menu.status_more_for_user)
|
||||
val menu = popup.menu
|
||||
menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank()
|
||||
|
@ -251,6 +249,10 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
|
||||
} //Ignore
|
||||
}
|
||||
} else {
|
||||
popup.inflate(R.menu.status_more)
|
||||
val menu = popup.menu
|
||||
menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
||||
}
|
||||
|
||||
val openAsItem = popup.menu.findItem(R.id.status_open_as)
|
||||
|
@ -266,6 +268,19 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
}
|
||||
openAsItem.title = openAsTitle
|
||||
|
||||
val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
|
||||
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
|
||||
isVisible = mutable
|
||||
}
|
||||
if (mutable) {
|
||||
muteConversationItem.setTitle(
|
||||
if (status.muted == true) {
|
||||
R.string.action_unmute_conversation
|
||||
} else {
|
||||
R.string.action_mute_conversation
|
||||
})
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.status_share_content -> {
|
||||
|
@ -303,6 +318,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
requestDownloadAllMedia(status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_mute_conversation -> {
|
||||
searchAdapter.getItem(position)?.let { foundStatus ->
|
||||
viewModel.muteConversation(foundStatus, status.muted != true)
|
||||
}
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_mute -> {
|
||||
viewModel.muteAcount(accountId)
|
||||
return@setOnMenuItemClickListener true
|
||||
|
@ -341,6 +362,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
popup.show()
|
||||
}
|
||||
|
||||
private fun accountIsInMentions(account: AccountEntity?, mentions: Array<Mention>): Boolean {
|
||||
return mentions.firstOrNull {
|
||||
account?.username == it.username && account.domain == Uri.parse(it.url)?.host
|
||||
} != null
|
||||
}
|
||||
|
||||
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) {
|
||||
bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener {
|
||||
override fun onAccountSelected(account: AccountEntity) {
|
||||
|
|
|
@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
|
|||
|
||||
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 22)
|
||||
}, version = 23)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract TootDao tootDao();
|
||||
|
@ -333,4 +333,11 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_22_23 = new Migration(22, 23) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER");
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -51,7 +51,8 @@ data class TimelineStatusEntity(
|
|||
val application: String?,
|
||||
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
|
||||
val reblogAccountId: String?,
|
||||
val poll: String?
|
||||
val poll: String?,
|
||||
val muted: Boolean?
|
||||
)
|
||||
|
||||
@Entity(
|
||||
|
|
|
@ -79,8 +79,9 @@ class AppModule {
|
|||
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
|
||||
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
|
||||
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22)
|
||||
.build()
|
||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
||||
AppDatabase.MIGRATION_22_23)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
@ -43,6 +43,7 @@ data class Status(
|
|||
val mentions: Array<Mention>,
|
||||
val application: Application?,
|
||||
var pinned: Boolean?,
|
||||
var muted: Boolean?,
|
||||
val poll: Poll?,
|
||||
val card: Card?
|
||||
) {
|
||||
|
|
|
@ -185,11 +185,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
|
||||
PopupMenu popup = new PopupMenu(getContext(), view);
|
||||
// Give a different menu depending on whether this is the user's own toot or not.
|
||||
if (loggedInAccountId == null || !loggedInAccountId.equals(accountId)) {
|
||||
popup.inflate(R.menu.status_more);
|
||||
Menu menu = popup.getMenu();
|
||||
menu.findItem(R.id.status_download_media).setVisible(!status.getAttachments().isEmpty());
|
||||
} else {
|
||||
boolean statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId.equals(accountId);
|
||||
if (statusIsByCurrentUser) {
|
||||
popup.inflate(R.menu.status_more_for_user);
|
||||
Menu menu = popup.getMenu();
|
||||
switch (status.getVisibility()) {
|
||||
|
@ -208,6 +205,10 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
popup.inflate(R.menu.status_more);
|
||||
Menu menu = popup.getMenu();
|
||||
menu.findItem(R.id.status_download_media).setVisible(!status.getAttachments().isEmpty());
|
||||
}
|
||||
|
||||
Menu menu = popup.getMenu();
|
||||
|
@ -231,6 +232,15 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
}
|
||||
openAsItem.setTitle(openAsTitle);
|
||||
|
||||
MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation);
|
||||
boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions());
|
||||
muteConversationItem.setVisible(mutable);
|
||||
if (mutable) {
|
||||
muteConversationItem.setTitle((status.getMuted() == null || !status.getMuted()) ?
|
||||
R.string.action_mute_conversation :
|
||||
R.string.action_unmute_conversation);
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.status_share_content: {
|
||||
|
@ -305,12 +315,35 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
timelineCases.pin(status, !status.isPinned());
|
||||
return true;
|
||||
}
|
||||
case R.id.status_mute_conversation: {
|
||||
timelineCases.muteConversation(status, status.getMuted() == null || !status.getMuted())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) {
|
||||
if (account == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Status.Mention mention : mentions) {
|
||||
if (account.getUsername().equals(mention.getUsername())) {
|
||||
Uri uri = Uri.parse(mention.getUrl());
|
||||
if (uri != null && account.getDomain().equals(uri.getHost())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected void viewMedia(int urlIndex, Status status, @Nullable View view) {
|
||||
final Status actionable = status.getActionableStatus();
|
||||
final Attachment active = actionable.getAttachments().get(urlIndex);
|
||||
|
|
|
@ -53,6 +53,7 @@ import com.keylesspalace.tusky.appstore.BookmarkEvent;
|
|||
import com.keylesspalace.tusky.appstore.DomainMuteEvent;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent;
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent;
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent;
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||
|
@ -503,6 +504,9 @@ public class TimelineFragment extends SFragment implements
|
|||
} else if (event instanceof BookmarkEvent) {
|
||||
BookmarkEvent bookmarkEvent = (BookmarkEvent) event;
|
||||
handleBookmarkEvent(bookmarkEvent);
|
||||
} else if (event instanceof MuteConversationEvent) {
|
||||
MuteConversationEvent muteEvent = (MuteConversationEvent) event;
|
||||
handleMuteConversationEvent(muteEvent);
|
||||
} else if (event instanceof UnfollowEvent) {
|
||||
if (kind == Kind.HOME) {
|
||||
String id = ((UnfollowEvent) event).getAccountId();
|
||||
|
@ -1313,6 +1317,10 @@ public class TimelineFragment extends SFragment implements
|
|||
setBookmarkForStatus(pos, status, bookmarkEvent.getBookmark());
|
||||
}
|
||||
|
||||
private void handleMuteConversationEvent(@NonNull MuteConversationEvent event) {
|
||||
fullyRefresh();
|
||||
}
|
||||
|
||||
private void handleStatusComposeEvent(@NonNull Status status) {
|
||||
switch (kind) {
|
||||
case HOME:
|
||||
|
|
|
@ -200,6 +200,16 @@ interface MastodonApi {
|
|||
@Path("id") statusId: String
|
||||
): Single<Status>
|
||||
|
||||
@POST("api/v1/statuses/{id}/mute")
|
||||
fun muteConversation(
|
||||
@Path("id") statusId: String
|
||||
): Single<Status>
|
||||
|
||||
@POST("api/v1/statuses/{id}/unmute")
|
||||
fun unmuteConversation(
|
||||
@Path("id") statusId: String
|
||||
): Single<Status>
|
||||
|
||||
@GET("api/v1/scheduled_statuses")
|
||||
fun scheduledStatuses(
|
||||
@Query("limit") limit: Int? = null,
|
||||
|
|
|
@ -41,7 +41,7 @@ interface TimelineCases {
|
|||
fun delete(id: String): Single<DeletedStatus>
|
||||
fun pin(status: Status, pin: Boolean)
|
||||
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
|
||||
|
||||
fun muteConversation(status: Status, mute: Boolean): Single<Status>
|
||||
}
|
||||
|
||||
class TimelineCasesImpl(
|
||||
|
@ -94,6 +94,19 @@ class TimelineCasesImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override fun muteConversation(status: Status, mute: Boolean): Single<Status> {
|
||||
val id = status.actionableId
|
||||
|
||||
val call = if (mute) {
|
||||
mastodonApi.muteConversation(id)
|
||||
} else {
|
||||
mastodonApi.unmuteConversation(id)
|
||||
}
|
||||
return call.doAfterSuccess {
|
||||
eventHub.dispatch(MuteConversationEvent(status.id, mute))
|
||||
}
|
||||
}
|
||||
|
||||
override fun mute(id: String) {
|
||||
val call = mastodonApi.muteAccount(id)
|
||||
call.enqueue(object : Callback<Relationship> {
|
||||
|
|
|
@ -229,6 +229,7 @@ class TimelineRepositoryImpl(
|
|||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
|
@ -256,6 +257,7 @@ class TimelineRepositoryImpl(
|
|||
mentions = arrayOf(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
|
@ -282,6 +284,7 @@ class TimelineRepositoryImpl(
|
|||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
|
@ -353,7 +356,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
|||
application = null,
|
||||
reblogServerId = null,
|
||||
reblogAccountId = null,
|
||||
poll = null
|
||||
poll = null,
|
||||
muted = false
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -384,7 +388,8 @@ fun Status.toEntity(timelineUserId: Long,
|
|||
application = actionable.let(gson::toJson),
|
||||
reblogServerId = reblog?.id,
|
||||
reblogAccountId = reblog?.let { this.account.id },
|
||||
poll = actionable.poll.let(gson::toJson)
|
||||
poll = actionable.poll.let(gson::toJson),
|
||||
muted = actionable.muted
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -57,6 +57,7 @@ public abstract class StatusViewData {
|
|||
final boolean reblogged;
|
||||
final boolean favourited;
|
||||
final boolean bookmarked;
|
||||
private final boolean muted;
|
||||
@Nullable
|
||||
private final String spoilerText;
|
||||
private final Status.Visibility visibility;
|
||||
|
@ -92,7 +93,7 @@ public abstract class StatusViewData {
|
|||
private final PollViewData poll;
|
||||
private final boolean isBot;
|
||||
|
||||
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked,
|
||||
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, boolean muted,
|
||||
@Nullable String spoilerText, Status.Visibility visibility, List<Attachment> attachments,
|
||||
@Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded,
|
||||
boolean isShowingContent, String userFullName, String nickname, String avatar,
|
||||
|
@ -115,6 +116,7 @@ public abstract class StatusViewData {
|
|||
this.reblogged = reblogged;
|
||||
this.favourited = favourited;
|
||||
this.bookmarked = bookmarked;
|
||||
this.muted = muted;
|
||||
this.visibility = visibility;
|
||||
this.attachments = attachments;
|
||||
this.rebloggedByUsername = rebloggedByUsername;
|
||||
|
@ -161,6 +163,10 @@ public abstract class StatusViewData {
|
|||
return bookmarked;
|
||||
}
|
||||
|
||||
public boolean isMuted() {
|
||||
return muted;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getSpoilerText() {
|
||||
return spoilerText;
|
||||
|
@ -401,6 +407,7 @@ public abstract class StatusViewData {
|
|||
private boolean reblogged;
|
||||
private boolean favourited;
|
||||
private boolean bookmarked;
|
||||
private boolean muted;
|
||||
private String spoilerText;
|
||||
private Status.Visibility visibility;
|
||||
private List<Attachment> attachments;
|
||||
|
@ -437,6 +444,7 @@ public abstract class StatusViewData {
|
|||
reblogged = viewData.reblogged;
|
||||
favourited = viewData.favourited;
|
||||
bookmarked = viewData.bookmarked;
|
||||
muted = viewData.muted;
|
||||
spoilerText = viewData.spoilerText;
|
||||
visibility = viewData.visibility;
|
||||
attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments);
|
||||
|
@ -490,6 +498,11 @@ public abstract class StatusViewData {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder setMuted(boolean muted) {
|
||||
this.muted = muted;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setSpoilerText(String spoilerText) {
|
||||
this.spoilerText = spoilerText;
|
||||
return this;
|
||||
|
@ -639,7 +652,7 @@ public abstract class StatusViewData {
|
|||
if (this.accountEmojis == null) accountEmojis = Collections.emptyList();
|
||||
if (this.createdAt == null) createdAt = new Date();
|
||||
|
||||
return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, spoilerText,
|
||||
return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, muted, spoilerText,
|
||||
visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
|
||||
isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount,
|
||||
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue