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
parent 51d02388b9
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

@ -238,15 +238,14 @@ class ComposeActivity :
binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null binding.composeMediaPreviewBar.itemAnimator = null
setupButtons()
subscribeToUpdates(mediaAdapter)
/* If the composer is started up as a reply to another post, override the "starting" state /* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */ * based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions) viewModel.setup(composeOptions)
setupButtons()
subscribeToUpdates(mediaAdapter)
if (accountManager.shouldDisplaySelfUsername(this)) { if (accountManager.shouldDisplaySelfUsername(this)) {
binding.composeUsernameView.text = getString( binding.composeUsernameView.text = getString(
R.string.compose_active_account_description, R.string.compose_active_account_description,
@ -708,20 +707,25 @@ class ComposeActivity :
} }
private fun updateScheduleButton() { private fun updateScheduleButton() {
@ColorInt val color = if (binding.composeScheduleView.time == null) { if (viewModel.editing) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary) // Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else { } else {
getColor(R.color.tusky_blue) @ColorInt val color = if (binding.composeScheduleView.time == null) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
} else {
getColor(R.color.tusky_blue)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
} }
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
} }
private fun enableButtons(enable: Boolean) { private fun enableButtons(enable: Boolean, editing: Boolean) {
binding.composeAddMediaButton.isClickable = enable binding.composeAddMediaButton.isClickable = enable
binding.composeToggleVisibilityButton.isClickable = enable binding.composeToggleVisibilityButton.isClickable = enable && !editing
binding.composeEmojiButton.isClickable = enable binding.composeEmojiButton.isClickable = enable
binding.composeHideMediaButton.isClickable = enable binding.composeHideMediaButton.isClickable = enable
binding.composeScheduleButton.isClickable = enable binding.composeScheduleButton.isClickable = enable && !editing
binding.composeTootButton.isEnabled = enable binding.composeTootButton.isEnabled = enable
} }
@ -737,6 +741,10 @@ class ComposeActivity :
else -> R.drawable.ic_lock_open_24dp else -> R.drawable.ic_lock_open_24dp
} }
binding.composeToggleVisibilityButton.setImageResource(iconRes) binding.composeToggleVisibilityButton.setImageResource(iconRes)
if (viewModel.editing) {
// Can't update visibility on published status
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
}
} }
private fun showComposeOptions() { private fun showComposeOptions() {
@ -938,7 +946,7 @@ class ComposeActivity :
} }
private fun sendStatus() { private fun sendStatus() {
enableButtons(false) enableButtons(false, viewModel.editing)
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
var spoilerText = "" var spoilerText = ""
if (viewModel.showContentWarning.value) { if (viewModel.showContentWarning.value) {
@ -947,7 +955,7 @@ class ComposeActivity :
val characterCount = calculateTextLength() val characterCount = calculateTextLength()
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
binding.composeEditField.error = getString(R.string.error_empty) binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true) enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) { } else if (characterCount <= maximumTootCharacters) {
if (viewModel.media.value.isNotEmpty()) { if (viewModel.media.value.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show( finishingUploadDialog = ProgressDialog.show(
@ -963,7 +971,7 @@ class ComposeActivity :
} }
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true, viewModel.editing)
} }
} }
@ -1179,7 +1187,8 @@ class ComposeActivity :
val uploadPercent: Int = 0, val uploadPercent: Int = 0,
val id: String? = null, val id: String? = null,
val description: String? = null, val description: String? = null,
val focus: Attachment.Focus? = null val focus: Attachment.Focus? = null,
val processed: Boolean = false,
) { ) {
enum class Type { enum class Type {
IMAGE, VIDEO, AUDIO; IMAGE, VIDEO, AUDIO;
@ -1230,6 +1239,7 @@ class ComposeActivity :
var poll: NewPoll? = null, var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null, var modifiedInitialState: Boolean? = null,
var language: String? = null, var language: String? = null,
var statusId: String? = null,
) : Parcelable ) : Parcelable
companion object { companion object {

View File

@ -73,6 +73,7 @@ class ComposeViewModel @Inject constructor(
private var scheduledTootId: String? = null private var scheduledTootId: String? = null
private var startingContentWarning: String = "" private var startingContentWarning: String = ""
private var inReplyToId: String? = null private var inReplyToId: String? = null
private var originalStatusId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
@ -193,7 +194,8 @@ class ComposeViewModel @Inject constructor(
uploadPercent = -1, uploadPercent = -1,
id = id, id = id,
description = description, description = description,
focus = focus focus = focus,
processed = true,
) )
mediaValue + mediaItem mediaValue + mediaItem
} }
@ -270,6 +272,7 @@ class ComposeViewModel @Inject constructor(
failedToSend = false, failedToSend = false,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
language = postLanguage, language = postLanguage,
statusId = originalStatusId,
) )
} }
@ -299,7 +302,7 @@ class ComposeViewModel @Inject constructor(
mediaUris.add(item.uri) mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
mediaFocus.add(item.focus) mediaFocus.add(item.focus)
mediaProcessed.add(false) mediaProcessed.add(item.processed)
} }
val tootToSend = StatusToSend( val tootToSend = StatusToSend(
text = content, text = content,
@ -321,6 +324,7 @@ class ComposeViewModel @Inject constructor(
retries = 0, retries = 0,
mediaProcessed = mediaProcessed, mediaProcessed = mediaProcessed,
language = postLanguage, language = postLanguage,
statusId = originalStatusId,
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
@ -452,6 +456,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content startingText = composeOptions?.content
postLanguage = composeOptions?.language postLanguage = composeOptions?.language
@ -497,6 +502,9 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt scheduledAt.value = newScheduledAt
} }
val editing: Boolean
get() = !originalStatusId.isNullOrEmpty()
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }

View File

@ -48,10 +48,13 @@ class MediaPreviewAdapter(
val addFocusId = 2 val addFocusId = 2
val editImageId = 3 val editImageId = 3
val removeId = 4 val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) if (!item.processed) {
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { // Already-published items can't have their metadata edited
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image) if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
} }
popup.menu.add(0, removeId, 0, R.string.action_remove) popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->

View File

@ -65,6 +65,7 @@ class DraftHelper @Inject constructor(
failedToSend: Boolean, failedToSend: Boolean,
scheduledAt: String?, scheduledAt: String?,
language: String?, language: String?,
statusId: String?,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky") val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -124,6 +125,7 @@ class DraftHelper @Inject constructor(
failedToSend = failedToSend, failedToSend = failedToSend,
scheduledAt = scheduledAt, scheduledAt = scheduledAt,
language = language, language = language,
statusId = statusId,
) )
draftDao.insertOrReplace(draft) draftDao.insertOrReplace(draft)

View File

@ -111,6 +111,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility, visibility = draft.visibility,
scheduledAt = draft.scheduledAt, scheduledAt = draft.scheduledAt,
language = draft.language, language = draft.language,
statusId = draft.statusId,
) )
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -147,6 +148,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility, visibility = draft.visibility,
scheduledAt = draft.scheduledAt, scheduledAt = draft.scheduledAt,
language = draft.language, language = draft.language,
statusId = draft.statusId,
) )
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(this, composeOptions))

View File

@ -22,6 +22,7 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -38,6 +39,9 @@ abstract class SearchFragment<T : Any> :
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var mastodonApi: MastodonApi
protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory }
protected val binding by viewBinding(FragmentSearchBinding::bind) protected val binding by viewBinding(FragmentSearchBinding::bind)

View File

@ -33,13 +33,16 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
@ -62,6 +65,7 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener { class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
@ -351,6 +355,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
showConfirmEditDialog(id, position, status) showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_edit -> {
editStatus(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> { R.id.pin -> {
viewModel.pinAccount(status, !status.isPinned()) viewModel.pinAccount(status, !status.isPinned())
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
@ -487,4 +495,32 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
.show() .show()
} }
} }
private fun editStatus(id: String, position: Int, 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(ComposeActivity.startIntent(requireContext(), composeOptions))
},
{
Snackbar.make(
requireView(),
getString(R.string.error_status_source_load),
Snackbar.LENGTH_SHORT
).show()
}
)
}
}
} }

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewM
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -85,9 +84,6 @@ class TimelineFragment :
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject
lateinit var accountManager: AccountManager
private val viewModel: TimelineViewModel by lazy { private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) { if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 45) }, version = 46)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -632,4 +632,11 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER");
} }
}; };
public static final Migration MIGRATION_45_46 = new Migration(45, 46) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `statusId` TEXT");
}
};
} }

View File

@ -42,6 +42,7 @@ data class DraftEntity(
val failedToSend: Boolean, val failedToSend: Boolean,
val scheduledAt: String?, val scheduledAt: String?,
val language: String?, val language: String?,
val statusId: String?,
) )
/** /**

View File

@ -67,7 +67,7 @@ class AppModule {
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46,
) )
.build() .build()
} }

View File

@ -137,7 +137,7 @@ data class Status(
) )
} }
private fun getEditableText(): String { fun getEditableText(): String {
val contentSpanned = content.parseAsMastodonHtml() val contentSpanned = content.parseAsMastodonHtml()
val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
@ -146,7 +146,9 @@ data class Status(
if (url == url1) { if (url == url1) {
val start = builder.getSpanStart(span) val start = builder.getSpanStart(span)
val end = builder.getSpanEnd(span) val end = builder.getSpanEnd(span)
builder.replace(start, end, "@$username") if (start >= 0 && end >= 0) {
builder.replace(start, end, "@$username")
}
break break
} }
} }

View File

@ -0,0 +1,24 @@
/* Copyright 2022 Tusky Contributors
*
* 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.entity
import com.google.gson.annotations.SerializedName
data class StatusSource(
val id: String,
val text: String,
@SerializedName("spoiler_text") val spoilerText: String,
)

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
}
}
}
}

View File

@ -39,6 +39,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.StatusSource
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -169,11 +170,25 @@ interface MastodonApi {
@Path("id") statusId: String @Path("id") statusId: String
): NetworkResult<Status> ): NetworkResult<Status>
@PUT("api/v1/statuses/{id}")
suspend fun editStatus(
@Path("id") statusId: String,
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Body editedStatus: NewStatus,
): NetworkResult<Status>
@GET("api/v1/statuses/{id}") @GET("api/v1/statuses/{id}")
suspend fun statusAsync( suspend fun statusAsync(
@Path("id") statusId: String @Path("id") statusId: String
): NetworkResult<Status> ): NetworkResult<Status>
@GET("api/v1/statuses/{id}/source")
suspend fun statusSource(
@Path("id") statusId: String
): NetworkResult<StatusSource>
@GET("api/v1/statuses/{id}/context") @GET("api/v1/statuses/{id}/context")
suspend fun statusContext( suspend fun statusContext(
@Path("id") statusId: String @Path("id") statusId: String

View File

@ -100,7 +100,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0, retries = 0,
mediaProcessed = mutableListOf(), mediaProcessed = mutableListOf(),
null, language = null,
statusId = null,
) )
) )

View File

@ -168,12 +168,24 @@ class SendStatusService : Service(), Injectable {
statusToSend.language, statusToSend.language,
) )
mastodonApi.createStatus( val sendResult = if (statusToSend.statusId == null) {
"Bearer " + account.accessToken, mastodonApi.createStatus(
account.domain, "Bearer " + account.accessToken,
statusToSend.idempotencyKey, account.domain,
newStatus statusToSend.idempotencyKey,
).fold({ sentStatus -> newStatus
)
} else {
mastodonApi.editStatus(
statusToSend.statusId,
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus
)
}
sendResult.fold({ sentStatus ->
statusesToSend.remove(statusId) statusesToSend.remove(statusId)
// If the status was loaded from a draft, delete the draft and associated media files. // If the status was loaded from a draft, delete the draft and associated media files.
if (statusToSend.draftId != 0) { if (statusToSend.draftId != 0) {
@ -278,6 +290,7 @@ class SendStatusService : Service(), Injectable {
failedToSend = true, failedToSend = true,
scheduledAt = status.scheduledAt, scheduledAt = status.scheduledAt,
language = status.language, language = status.language,
statusId = status.statusId,
) )
} }
@ -387,4 +400,5 @@ data class StatusToSend(
var retries: Int, var retries: Int,
val mediaProcessed: MutableList<Boolean>, val mediaProcessed: MutableList<Boolean>,
val language: String?, val language: String?,
val statusId: String?,
) : Parcelable ) : Parcelable

View File

@ -29,6 +29,9 @@
<item <item
android:id="@+id/status_mute_conversation" android:id="@+id/status_mute_conversation"
android:title="@string/action_mute_conversation" /> android:title="@string/action_mute_conversation" />
<item
android:id="@+id/status_edit"
android:title="@string/action_edit" />
<item <item
android:id="@+id/status_delete" android:id="@+id/status_delete"
android:title="@string/action_delete" /> android:title="@string/action_delete" />

View File

@ -27,6 +27,7 @@
<string name="error_following_hashtags_unsupported">This instance does not support following hashtags.</string> <string name="error_following_hashtags_unsupported">This instance does not support following hashtags.</string>
<string name="error_muting_hashtag_format">Error muting #%s</string> <string name="error_muting_hashtag_format">Error muting #%s</string>
<string name="error_unmuting_hashtag_format">Error unmuting #%s</string> <string name="error_unmuting_hashtag_format">Error unmuting #%s</string>
<string name="error_status_source_load">Failed to load the status source from the server.</string>
<string name="title_login">Login</string> <string name="title_login">Login</string>
<string name="title_home">Home</string> <string name="title_home">Home</string>