From a6b6a40ba6f940f8d612789c92524d5afeff078b Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Thu, 8 Dec 2022 10:18:12 +0100 Subject: [PATCH] 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 --- .../components/compose/ComposeActivity.kt | 40 +- .../components/compose/ComposeViewModel.kt | 12 +- .../components/compose/MediaPreviewAdapter.kt | 11 +- .../tusky/components/drafts/DraftHelper.kt | 2 + .../tusky/components/drafts/DraftsActivity.kt | 2 + .../search/fragments/SearchFragment.kt | 4 + .../fragments/SearchStatusesFragment.kt | 36 ++ .../components/timeline/TimelineFragment.kt | 4 - .../keylesspalace/tusky/db/AppDatabase.java | 9 +- .../com/keylesspalace/tusky/db/DraftEntity.kt | 1 + .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../com/keylesspalace/tusky/entity/Status.kt | 6 +- .../tusky/entity/StatusSource.kt | 24 + .../tusky/fragment/SFragment.java | 491 ----------------- .../keylesspalace/tusky/fragment/SFragment.kt | 511 ++++++++++++++++++ .../tusky/network/MastodonApi.kt | 15 + .../receiver/SendStatusBroadcastReceiver.kt | 3 +- .../tusky/service/SendStatusService.kt | 26 +- .../main/res/menu/status_more_for_user.xml | 3 + app/src/main/res/values/strings.xml | 1 + 20 files changed, 676 insertions(+), 527 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index c8b138b0c..cecf40a27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -238,15 +238,14 @@ class ComposeActivity : binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null - setupButtons() - subscribeToUpdates(mediaAdapter) - /* 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. */ val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) - viewModel.setup(composeOptions) + setupButtons() + subscribeToUpdates(mediaAdapter) + if (accountManager.shouldDisplaySelfUsername(this)) { binding.composeUsernameView.text = getString( R.string.compose_active_account_description, @@ -708,20 +707,25 @@ class ComposeActivity : } private fun updateScheduleButton() { - @ColorInt val color = if (binding.composeScheduleView.time == null) { - ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + if (viewModel.editing) { + // Can't reschedule a published status + enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) } 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.composeToggleVisibilityButton.isClickable = enable + binding.composeToggleVisibilityButton.isClickable = enable && !editing binding.composeEmojiButton.isClickable = enable binding.composeHideMediaButton.isClickable = enable - binding.composeScheduleButton.isClickable = enable + binding.composeScheduleButton.isClickable = enable && !editing binding.composeTootButton.isEnabled = enable } @@ -737,6 +741,10 @@ class ComposeActivity : else -> R.drawable.ic_lock_open_24dp } binding.composeToggleVisibilityButton.setImageResource(iconRes) + if (viewModel.editing) { + // Can't update visibility on published status + enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false) + } } private fun showComposeOptions() { @@ -938,7 +946,7 @@ class ComposeActivity : } private fun sendStatus() { - enableButtons(false) + enableButtons(false, viewModel.editing) val contentText = binding.composeEditField.text.toString() var spoilerText = "" if (viewModel.showContentWarning.value) { @@ -947,7 +955,7 @@ class ComposeActivity : val characterCount = calculateTextLength() if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) - enableButtons(true) + enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { if (viewModel.media.value.isNotEmpty()) { finishingUploadDialog = ProgressDialog.show( @@ -963,7 +971,7 @@ class ComposeActivity : } } else { 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 id: String? = null, val description: String? = null, - val focus: Attachment.Focus? = null + val focus: Attachment.Focus? = null, + val processed: Boolean = false, ) { enum class Type { IMAGE, VIDEO, AUDIO; @@ -1230,6 +1239,7 @@ class ComposeActivity : var poll: NewPoll? = null, var modifiedInitialState: Boolean? = null, var language: String? = null, + var statusId: String? = null, ) : Parcelable companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 71d1ae3fa..5807fb74c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -73,6 +73,7 @@ class ComposeViewModel @Inject constructor( private var scheduledTootId: String? = null private var startingContentWarning: String = "" private var inReplyToId: String? = null + private var originalStatusId: String? = null private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var contentWarningStateChanged: Boolean = false @@ -193,7 +194,8 @@ class ComposeViewModel @Inject constructor( uploadPercent = -1, id = id, description = description, - focus = focus + focus = focus, + processed = true, ) mediaValue + mediaItem } @@ -270,6 +272,7 @@ class ComposeViewModel @Inject constructor( failedToSend = false, scheduledAt = scheduledAt.value, language = postLanguage, + statusId = originalStatusId, ) } @@ -299,7 +302,7 @@ class ComposeViewModel @Inject constructor( mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") mediaFocus.add(item.focus) - mediaProcessed.add(false) + mediaProcessed.add(item.processed) } val tootToSend = StatusToSend( text = content, @@ -321,6 +324,7 @@ class ComposeViewModel @Inject constructor( retries = 0, mediaProcessed = mediaProcessed, language = postLanguage, + statusId = originalStatusId, ) serviceClient.sendToot(tootToSend) @@ -452,6 +456,7 @@ class ComposeViewModel @Inject constructor( draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId + originalStatusId = composeOptions?.statusId startingText = composeOptions?.content postLanguage = composeOptions?.language @@ -497,6 +502,9 @@ class ComposeViewModel @Inject constructor( scheduledAt.value = newScheduledAt } + val editing: Boolean + get() = !originalStatusId.isNullOrEmpty() + private companion object { const val TAG = "ComposeViewModel" } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 2855e6969..fd4219a53 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -48,10 +48,13 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 val removeId = 4 - popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - 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) + if (!item.processed) { + // Already-published items can't have their metadata edited + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + 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.setOnMenuItemClickListener { menuItem -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 61023ddbe..d5ac079c8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -65,6 +65,7 @@ class DraftHelper @Inject constructor( failedToSend: Boolean, scheduledAt: String?, language: String?, + statusId: String?, ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -124,6 +125,7 @@ class DraftHelper @Inject constructor( failedToSend = failedToSend, scheduledAt = scheduledAt, language = language, + statusId = statusId, ) draftDao.insertOrReplace(draft) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 6665981cb..ba0c215d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -111,6 +111,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, + statusId = draft.statusId, ) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN @@ -147,6 +148,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, + statusId = draft.statusId, ) startActivity(ComposeActivity.startIntent(this, composeOptions)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index a77074914..3fe818cb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -22,6 +22,7 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.Flow @@ -38,6 +39,9 @@ abstract class SearchFragment : @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var mastodonApi: MastodonApi + protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } protected val binding by viewBinding(FragmentSearchBinding::bind) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 2c838c775..b261ce311 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -33,13 +33,16 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity @@ -62,6 +65,7 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch class SearchStatusesFragment : SearchFragment(), StatusActionListener { @@ -351,6 +355,10 @@ class SearchStatusesFragment : SearchFragment(), Status showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } + R.id.status_edit -> { + editStatus(id, position, status) + return@setOnMenuItemClickListener true + } R.id.pin -> { viewModel.pinAccount(status, !status.isPinned()) return@setOnMenuItemClickListener true @@ -487,4 +495,32 @@ class SearchStatusesFragment : SearchFragment(), Status .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() + } + ) + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index bdc778128..7c6f33677 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -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.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Status @@ -85,9 +84,6 @@ class TimelineFragment : @Inject lateinit var eventHub: EventHub - @Inject - lateinit var accountManager: AccountManager - private val viewModel: TimelineViewModel by lazy { if (kind == TimelineViewModel.Kind.HOME) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 66c961ad8..fc78aaac6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 45) + }, version = 46) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -632,4 +632,11 @@ public abstract class AppDatabase extends RoomDatabase { 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"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 79b7243f1..d5f9edc9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -42,6 +42,7 @@ data class DraftEntity( val failedToSend: Boolean, val scheduledAt: String?, val language: String?, + val statusId: String?, ) /** diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 76aa3d75a..e304f4928 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -67,7 +67,7 @@ class AppModule { 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_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, - AppDatabase.MIGRATION_44_45, + AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 72ca4e398..b7d74c8be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -137,7 +137,7 @@ data class Status( ) } - private fun getEditableText(): String { + fun getEditableText(): String { val contentSpanned = content.parseAsMastodonHtml() val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { @@ -146,7 +146,9 @@ data class Status( if (url == url1) { val start = builder.getSpanStart(span) val end = builder.getSpanEnd(span) - builder.replace(start, end, "@$username") + if (start >= 0 && end >= 0) { + builder.replace(start, end, "@$username") + } break } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt new file mode 100644 index 000000000..aea6bdd47 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -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 . */ + +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, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java deleted file mode 100644 index a6d806f06..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ /dev/null @@ -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 . */ - -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 mentions = actionableStatus.getMentions(); - Set 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 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 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); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt new file mode 100644 index 000000000..62d18e570 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -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 . */ +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, 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?, 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): Boolean { + return mentions.any { mention -> + account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 1c7a8f6ba..fb0f7d805 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -39,6 +39,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext +import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody @@ -169,11 +170,25 @@ interface MastodonApi { @Path("id") statusId: String ): NetworkResult + @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 + @GET("api/v1/statuses/{id}") suspend fun statusAsync( @Path("id") statusId: String ): NetworkResult + @GET("api/v1/statuses/{id}/source") + suspend fun statusSource( + @Path("id") statusId: String + ): NetworkResult + @GET("api/v1/statuses/{id}/context") suspend fun statusContext( @Path("id") statusId: String diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index c1f5a2cca..ca2358e01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -100,7 +100,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { idempotencyKey = randomAlphanumericString(16), retries = 0, mediaProcessed = mutableListOf(), - null, + language = null, + statusId = null, ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 3bcaf8778..5849fc815 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -168,12 +168,24 @@ class SendStatusService : Service(), Injectable { statusToSend.language, ) - mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus - ).fold({ sentStatus -> + val sendResult = if (statusToSend.statusId == null) { + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } else { + mastodonApi.editStatus( + statusToSend.statusId, + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } + + sendResult.fold({ sentStatus -> statusesToSend.remove(statusId) // If the status was loaded from a draft, delete the draft and associated media files. if (statusToSend.draftId != 0) { @@ -278,6 +290,7 @@ class SendStatusService : Service(), Injectable { failedToSend = true, scheduledAt = status.scheduledAt, language = status.language, + statusId = status.statusId, ) } @@ -387,4 +400,5 @@ data class StatusToSend( var retries: Int, val mediaProcessed: MutableList, val language: String?, + val statusId: String?, ) : Parcelable diff --git a/app/src/main/res/menu/status_more_for_user.xml b/app/src/main/res/menu/status_more_for_user.xml index 9ecedbded..d0d6559e8 100644 --- a/app/src/main/res/menu/status_more_for_user.xml +++ b/app/src/main/res/menu/status_more_for_user.xml @@ -29,6 +29,9 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3de980b67..f0794e4d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ This instance does not support following hashtags. Error muting #%s Error unmuting #%s + Failed to load the status source from the server. Login Home