Add post editing capability (#2828)

* Add post editing capability

* Don't try to reprocess already uploaded attachments.
Fixes editing posts with existing media

* Don't mark post edits as modified until editing occurs

* Disable UI for things that can't be edited when editing a post

* Finally convert SFragment to kotlin

* Use api endpoint for fetching status source for editing

* Apply review feedback
This commit is contained in:
Levi Bard 2022-12-08 10:18:12 +01:00 committed by GitHub
commit a6b6a40ba6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 676 additions and 527 deletions

View file

@ -1,491 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* 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.fragment;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import android.Manifest;
import android.app.DownloadManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.StatusListActivity;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
import com.keylesspalace.tusky.components.report.ReportActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.usecase.TimelineCases;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusParsingHelper;
import com.keylesspalace.tusky.view.MuteAccountDialog;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
public abstract class SFragment extends Fragment implements Injectable {
protected abstract void removeItem(int position);
protected abstract void onReblog(final boolean reblog, final int position);
private BottomSheetActivity bottomSheetActivity;
@Inject
public MastodonApi mastodonApi;
@Inject
public AccountManager accountManager;
@Inject
public TimelineCases timelineCases;
private static final String TAG = "SFragment";
@Override
public void startActivity(Intent intent) {
super.startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (context instanceof BottomSheetActivity) {
bottomSheetActivity = (BottomSheetActivity) context;
} else {
throw new IllegalStateException("Fragment must be attached to a BottomSheetActivity!");
}
}
protected void openReblog(@Nullable final Status status) {
if (status == null) return;
bottomSheetActivity.viewAccount(status.getAccount().getId());
}
protected void viewThread(String statusId, @Nullable String statusUrl) {
bottomSheetActivity.viewThread(statusId, statusUrl);
}
protected void viewAccount(String accountId) {
bottomSheetActivity.viewAccount(accountId);
}
public void onViewUrl(String url) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER);
}
protected void reply(Status status) {
String inReplyToId = status.getActionableId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
String loggedInUsername = null;
AccountEntity activeAccount = accountManager.getActiveAccount();
if (activeAccount != null) {
loggedInUsername = activeAccount.getUsername();
}
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.remove(loggedInUsername);
ComposeOptions composeOptions = new ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString());
composeOptions.setLanguage(actionableStatus.getLanguage());
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
getActivity().startActivity(intent);
}
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 accountUsername = status.getActionableStatus().getAccount().getUsername();
final String statusUrl = status.getActionableStatus().getUrl();
String loggedInAccountId = null;
AccountEntity activeAccount = accountManager.getActiveAccount();
if (activeAccount != null) {
loggedInAccountId = activeAccount.getAccountId();
}
PopupMenu popup = new PopupMenu(getContext(), view);
// Give a different menu depending on whether this is the user's own toot or not.
boolean statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId.equals(accountId);
if (statusIsByCurrentUser) {
popup.inflate(R.menu.status_more_for_user);
Menu menu = popup.getMenu();
switch (status.getVisibility()) {
case PUBLIC:
case UNLISTED: {
final String textId =
getString(status.isPinned() ? R.string.unpin_action : R.string.pin_action);
menu.add(0, R.id.pin, 1, textId);
break;
}
case PRIVATE: {
boolean reblogged = status.getReblogged();
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);
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();
MenuItem openAsItem = menu.findItem(R.id.status_open_as);
String openAsText = ((BaseActivity)getActivity()).getOpenAsText();
if (openAsText == null) {
openAsItem.setVisible(false);
} else {
openAsItem.setTitle(openAsText);
}
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.post_share_content: {
Status statusToShare = status;
if (statusToShare.getReblog() != null)
statusToShare = statusToShare.getReblog();
Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND);
String stringToShare = statusToShare.getAccount().getUsername() +
" - " +
StatusParsingHelper.parseAsMastodonHtml(statusToShare.getContent()).toString();
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare);
sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl);
sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_content_to)));
return true;
}
case R.id.post_share_link: {
Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl);
sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_link_to)));
return true;
}
case R.id.status_copy_link: {
ClipboardManager clipboard = (ClipboardManager)
getActivity().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(null, statusUrl);
clipboard.setPrimaryClip(clip);
return true;
}
case R.id.status_open_as: {
showOpenAsDialog(statusUrl, item.getTitle());
return true;
}
case R.id.status_download_media: {
requestDownloadAllMedia(status);
return true;
}
case R.id.status_mute: {
onMute(accountId, accountUsername);
return true;
}
case R.id.status_block: {
onBlock(accountId, accountUsername);
return true;
}
case R.id.status_report: {
openReportPage(accountId, accountUsername, id);
return true;
}
case R.id.status_unreblog_private: {
onReblog(false, position);
return true;
}
case R.id.status_reblog_private: {
onReblog(true, position);
return true;
}
case R.id.status_delete: {
showConfirmDeleteDialog(id, position);
return true;
}
case R.id.status_delete_and_redraft: {
showConfirmEditDialog(id, position, status);
return true;
}
case R.id.pin: {
timelineCases.pin(status.getId(), !status.isPinned())
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
String message = e.getMessage();
if (message == null) {
message = getString(status.isPinned() ? R.string.failed_to_unpin : R.string.failed_to_pin);
}
Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
})
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe();
return true;
}
case R.id.status_mute_conversation: {
timelineCases.muteConversation(status.getId(), status.getMuted() == null || !status.getMuted())
.onErrorReturnItem(status)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe();
return true;
}
}
return false;
});
popup.show();
}
private void onMute(String accountId, String accountUsername) {
MuteAccountDialog.showMuteAccountDialog(
this.getActivity(),
accountUsername,
(notifications, duration) -> {
timelineCases.mute(accountId, notifications, duration);
return Unit.INSTANCE;
}
);
}
private void onBlock(String accountId, String accountUsername) {
new AlertDialog.Builder(requireContext())
.setMessage(getString(R.string.dialog_block_warning, accountUsername))
.setPositiveButton(android.R.string.ok, (__, ___) -> timelineCases.block(accountId))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private static boolean accountIsInMentions(AccountEntity account, List<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, List<AttachmentViewData> attachments, @Nullable View view) {
final AttachmentViewData active = attachments.get(urlIndex);
Attachment.Type type = active.getAttachment().getType();
switch (type) {
case GIFV:
case VIDEO:
case IMAGE:
case AUDIO: {
final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments,
urlIndex);
if (view != null) {
String url = active.getAttachment().getUrl();
view.setTransitionName(url);
ActivityOptionsCompat options =
ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(),
view, url);
startActivity(intent, options.toBundle());
} else {
startActivity(intent);
}
break;
}
default:
case UNKNOWN: {
LinkHelper.openLink(requireContext(), active.getAttachment().getUrl());
break;
}
}
}
protected void viewTag(String tag) {
Intent intent = StatusListActivity.newHashtagIntent(requireContext(), tag);
startActivity(intent);
}
protected void openReportPage(String accountId, String accountUsername, String statusId) {
startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId));
}
protected void showConfirmDeleteDialog(final String id, final int position) {
new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
deletedStatus -> {
},
error -> {
Log.w("SFragment", "error deleting status", error);
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
});
removeItem(position);
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void showConfirmEditDialog(final String id, final int position, final Status status) {
if (getActivity() == null) {
return;
}
new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(deletedStatus -> {
removeItem(position);
if (deletedStatus.isEmpty()) {
deletedStatus = status.toDeletedStatus();
}
ComposeOptions composeOptions = new ComposeOptions();
composeOptions.setContent(deletedStatus.getText());
composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
composeOptions.setVisibility(deletedStatus.getVisibility());
composeOptions.setContentWarning(deletedStatus.getSpoilerText());
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
composeOptions.setSensitive(deletedStatus.getSensitive());
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(deletedStatus.getLanguage());
if (deletedStatus.getPoll() != null) {
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
}
Intent intent = ComposeActivity
.startIntent(getContext(), composeOptions);
startActivity(intent);
},
error -> {
Log.w("SFragment", "error deleting status", error);
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
});
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) {
BaseActivity activity = (BaseActivity) getActivity();
activity.showAccountChooserDialog(dialogTitle, false, account -> activity.openAsAccount(statusUrl, account));
}
private void downloadAllMedia(Status status) {
Toast.makeText(getContext(), R.string.downloading_media, Toast.LENGTH_SHORT).show();
for (Attachment attachment : status.getAttachments()) {
String url = attachment.getUrl();
Uri uri = Uri.parse(url);
String filename = uri.getLastPathSegment();
DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(uri);
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename);
downloadManager.enqueue(request);
}
}
private void requestDownloadAllMedia(Status status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadAllMedia(status);
} else {
Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show();
}
});
} else {
downloadAllMedia(status);
}
}
}

View file

@ -0,0 +1,511 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* 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.fragment
import android.Manifest
import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import autodispose2.AutoDispose
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity.Companion.newHashtagIntent
import com.keylesspalace.tusky.ViewMediaActivity.Companion.newIntent
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import java.lang.IllegalStateException
import java.util.LinkedHashSet
import javax.inject.Inject
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
abstract class SFragment : Fragment(), Injectable {
protected abstract fun removeItem(position: Int)
protected abstract fun onReblog(reblog: Boolean, position: Int)
private lateinit var bottomSheetActivity: BottomSheetActivity
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var timelineCases: TimelineCases
override fun startActivity(intent: Intent) {
super.startActivity(intent)
requireActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
protected fun openReblog(status: Status?) {
if (status == null) return
bottomSheetActivity.viewAccount(status.account.id)
}
protected fun viewThread(statusId: String?, statusUrl: String?) {
bottomSheetActivity.viewThread(statusId!!, statusUrl)
}
protected fun viewAccount(accountId: String?) {
bottomSheetActivity.viewAccount(accountId!!)
}
open fun onViewUrl(url: String) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
}
protected fun reply(status: Status) {
val actionableStatus = status.actionableStatus
val account = actionableStatus.account
var loggedInUsername: String? = null
val activeAccount = accountManager.activeAccount
if (activeAccount != null) {
loggedInUsername = activeAccount.username
}
val mentionedUsernames = LinkedHashSet(
listOf(account.username) + actionableStatus.mentions.map { it.username }
).apply { remove(loggedInUsername) }
val composeOptions = ComposeOptions(
inReplyToId = status.actionableId,
replyVisibility = actionableStatus.visibility,
contentWarning = actionableStatus.spoilerText,
mentionedUsernames = mentionedUsernames,
replyingStatusAuthor = account.localUsername,
replyingStatusContent = actionableStatus.content.parseAsMastodonHtml().toString(),
language = actionableStatus.language,
)
val intent = startIntent(requireContext(), composeOptions)
requireActivity().startActivity(intent)
}
protected fun more(status: Status, view: View, position: Int) {
val id = status.actionableId
val accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
val statusUrl = status.actionableStatus.url
var loggedInAccountId: String? = null
val activeAccount = accountManager.activeAccount
if (activeAccount != null) {
loggedInAccountId = activeAccount.accountId
}
val popup = PopupMenu(requireContext(), view)
// Give a different menu depending on whether this is the user's own toot or not.
val statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId == accountId
if (statusIsByCurrentUser) {
popup.inflate(R.menu.status_more_for_user)
val menu = popup.menu
when (status.visibility) {
Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> {
menu.add(0, R.id.pin, 1, getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action))
}
Status.Visibility.PRIVATE -> {
val reblogged = status.reblog?.reblogged ?: status.reblogged
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
}
else -> {}
}
} else {
popup.inflate(R.menu.status_more)
popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
}
val menu = popup.menu
val openAsItem = menu.findItem(R.id.status_open_as)
val openAsText = (activity as BaseActivity?)?.openAsText
if (openAsText == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = openAsText
}
val muteConversationItem = menu.findItem(R.id.status_mute_conversation)
val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions)
muteConversationItem.isVisible = mutable
if (mutable) {
muteConversationItem.setTitle(
if (status.muted != true) {
R.string.action_mute_conversation
} else {
R.string.action_unmute_conversation
}
)
}
popup.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.post_share_content -> {
val statusToShare = status.reblog ?: status
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
"${statusToShare.account.username} - ${statusToShare.content.parseAsMastodonHtml()}"
)
putExtra(Intent.EXTRA_SUBJECT, statusUrl)
}
startActivity(
Intent.createChooser(
sendIntent,
resources.getText(R.string.send_post_content_to)
)
)
return@setOnMenuItemClickListener true
}
R.id.post_share_link -> {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, statusUrl)
type = "text/plain"
}
startActivity(
Intent.createChooser(
sendIntent,
resources.getText(R.string.send_post_link_to)
)
)
return@setOnMenuItemClickListener true
}
R.id.status_copy_link -> {
(requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply {
setPrimaryClip(ClipData.newPlainText(null, statusUrl))
}
return@setOnMenuItemClickListener true
}
R.id.status_open_as -> {
showOpenAsDialog(statusUrl, item.title)
return@setOnMenuItemClickListener true
}
R.id.status_download_media -> {
requestDownloadAllMedia(status)
return@setOnMenuItemClickListener true
}
R.id.status_mute -> {
onMute(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_block -> {
onBlock(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_report -> {
openReportPage(accountId, accountUsername, id)
return@setOnMenuItemClickListener true
}
R.id.status_unreblog_private -> {
onReblog(false, position)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete -> {
showConfirmDeleteDialog(id, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete_and_redraft -> {
showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.status_edit -> {
editStatus(id, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> {
timelineCases.pin(status.id, !status.isPinned())
.observeOn(AndroidSchedulers.mainThread())
.doOnError { e: Throwable ->
val message = e.message ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin)
Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
}
.to(
AutoDispose.autoDisposable(
AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)
)
)
.subscribe()
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
timelineCases.muteConversation(status.id, status.muted != true)
.onErrorReturnItem(status)
.observeOn(AndroidSchedulers.mainThread())
.to(
AutoDispose.autoDisposable(
AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)
)
)
.subscribe()
return@setOnMenuItemClickListener true
}
}
false
}
popup.show()
}
private fun onMute(accountId: String, accountUsername: String) {
showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? ->
timelineCases.mute(accountId, notifications == true, duration)
}
}
private fun onBlock(accountId: String, accountUsername: String) {
AlertDialog.Builder(requireContext())
.setMessage(getString(R.string.dialog_block_warning, accountUsername))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
timelineCases.block(accountId)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
protected fun viewMedia(urlIndex: Int, attachments: List<AttachmentViewData>, view: View?) {
val (attachment) = attachments[urlIndex]
when (attachment.type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val intent = newIntent(context, attachments, urlIndex)
if (view != null) {
val url = attachment.url
view.transitionName = url
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
view, url
)
startActivity(intent, options.toBundle())
} else {
startActivity(intent)
}
}
Attachment.Type.UNKNOWN -> {
requireContext().openLink(attachment.url)
}
}
}
protected fun viewTag(tag: String) {
startActivity(newHashtagIntent(requireContext(), tag))
}
private fun openReportPage(accountId: String, accountUsername: String, statusId: String) {
startActivity(getIntent(requireContext(), accountId, accountUsername, statusId))
}
private fun showConfirmDeleteDialog(id: String, position: Int) {
AlertDialog.Builder(requireActivity())
.setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.to(
AutoDispose.autoDisposable(
AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)
)
)
.subscribe({ }) { error: Throwable? ->
Log.w("SFragment", "error deleting status", error)
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
}
removeItem(position)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
if (activity == null) {
return
}
AlertDialog.Builder(requireActivity())
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.to(
AutoDispose.autoDisposable(
AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)
)
)
.subscribe(
{ deletedStatus ->
removeItem(position)
val sourceStatus = if (deletedStatus.isEmpty()) {
status.toDeletedStatus()
} else {
deletedStatus
}
val composeOptions = ComposeOptions(
content = sourceStatus.text,
inReplyToId = sourceStatus.inReplyToId,
visibility = sourceStatus.visibility,
contentWarning = sourceStatus.spoilerText,
mediaAttachments = sourceStatus.attachments,
sensitive = sourceStatus.sensitive,
modifiedInitialState = true,
language = sourceStatus.language,
poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt),
)
startActivity(startIntent(requireContext(), composeOptions))
}
) { error: Throwable? ->
Log.w("SFragment", "error deleting status", error)
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun editStatus(id: String, status: Status) {
lifecycleScope.launch {
mastodonApi.statusSource(id).fold(
{ source ->
val composeOptions = ComposeOptions(
content = source.text,
inReplyToId = status.inReplyToId,
visibility = status.visibility,
contentWarning = source.spoilerText,
mediaAttachments = status.attachments,
sensitive = status.sensitive,
language = status.language,
statusId = source.id,
poll = status.poll?.toNewPoll(status.createdAt),
)
startActivity(startIntent(requireContext(), composeOptions))
},
{
Snackbar.make(
requireView(),
getString(R.string.error_status_source_load),
Snackbar.LENGTH_SHORT
).show()
}
)
}
}
private fun showOpenAsDialog(statusUrl: String?, dialogTitle: CharSequence?) {
if (statusUrl == null) {
return
}
(activity as BaseActivity).apply {
showAccountChooserDialog(
dialogTitle,
false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(statusUrl, account)
}
}
)
}
}
private fun downloadAllMedia(status: Status) {
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
for ((_, url) in status.attachments) {
val uri = Uri.parse(url)
downloadManager.enqueue(
DownloadManager.Request(uri).apply {
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment)
}
)
}
}
private fun requestDownloadAllMedia(status: Status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
(activity as BaseActivity).requestPermissions(permissions) { _: Array<String?>?, grantResults: IntArray ->
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadAllMedia(status)
} else {
Toast.makeText(
context,
R.string.error_media_download_permission,
Toast.LENGTH_SHORT
).show()
}
}
} else {
downloadAllMedia(status)
}
}
companion object {
private const val TAG = "SFragment"
private fun accountIsInMentions(account: AccountEntity?, mentions: List<Status.Mention>): Boolean {
return mentions.any { mention ->
account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host
}
}
}
}