* cleanup warnings, reorganize some code

* move ComposeAutoCompleteAdapter to compose package

* composeOptions doesn't need to be a class member

* add DraftsActivity and DraftsViewModel

* drafts

* remove unnecessary Unit in ComposeViewModel

* add schema/25.json

* fix db migration

* drafts

* cleanup code

* fix compose activity rotation bug

* fix media descriptions getting lost when restoring a draft

* improve deleting drafts

* fix ComposeActivityTest

* improve draft layout for almost empty drafts

* reformat code

* show toast when opening reply to deleted toot

* improve item_draft layout
This commit is contained in:
Konrad Pozniak 2021-01-21 18:57:09 +01:00 committed by GitHub
commit 940d6d395a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 2032 additions and 381 deletions

View file

@ -30,7 +30,6 @@ import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
@ -57,13 +56,13 @@ import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
@ -81,7 +80,6 @@ import java.io.File
import java.io.IOException
import java.util.*
import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.math.max
import kotlin.math.min
@ -104,10 +102,10 @@ class ComposeActivity : BaseActivity(),
// this only exists when a status is trying to be sent, but uploads are still occurring
private var finishingUploadDialog: ProgressDialog? = null
private var photoUploadUri: Uri? = null
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
private var composeOptions: ComposeOptions? = null
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private val maxUploadMediaNumber = 4
@ -148,17 +146,17 @@ class ComposeActivity : BaseActivity(),
/* 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. */
if (intent != null) {
this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
@ -169,38 +167,24 @@ class ComposeActivity : BaseActivity(),
viewModel.setupComplete.value = true
}
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) {
private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
/* Get incoming images being sent through a share action from another app. Only do this
* when savedInstanceState is null, otherwise both the images from the intent and the
* instance state will be re-queued. */
val type = intent.type
if (type != null) {
intent.type?.also { type ->
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
val uriList = ArrayList<Uri>()
if (intent.action != null) {
when (intent.action) {
Intent.ACTION_SEND -> {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (uri != null) {
uriList.add(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
val list = intent.getParcelableArrayListExtra<Uri>(
Intent.EXTRA_STREAM)
if (list != null) {
for (uri in list) {
if (uri != null) {
uriList.add(uri)
}
}
}
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
pickMedia(uri)
}
}
}
for (uri in uriList) {
pickMedia(uri)
}
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) {
@ -224,7 +208,7 @@ class ComposeActivity : BaseActivity(),
}
}
private fun setupReplyViews(replyingStatusAuthor: String?) {
private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
if (replyingStatusAuthor != null) {
composeReplyView.show()
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
@ -248,7 +232,7 @@ class ComposeActivity : BaseActivity(),
}
}
}
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it }
replyingStatusContent?.let { composeReplyContentView.text = it }
}
private fun setupContentWarningField(startingContentWarning: String?) {
@ -651,7 +635,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun removePoll() {
viewModel.poll.value = null
pollPreview.hide()
@ -835,22 +818,22 @@ class ComposeActivity : BaseActivity(),
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
if(intent.data != null){
if (intent.data != null) {
// Single media, upload it and done.
pickMedia(intent.data!!)
}else if(intent.clipData != null){
} else if (intent.clipData != null) {
val clipData = intent.clipData!!
val count = clipData.itemCount
if(mediaCount + count > maxUploadMediaNumber){
if (mediaCount + count > maxUploadMediaNumber) {
// check if exist media + upcoming media > 4, then prob error message.
Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
}else{
} else {
// if not grater then 4, upload all multiple media.
for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
}
}
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!)
@ -1018,8 +1001,9 @@ class ComposeActivity : BaseActivity(),
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootUid: String? = null,
var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
@ -1031,6 +1015,7 @@ class ComposeActivity : BaseActivity(),
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
@ -1057,7 +1042,6 @@ class ComposeActivity : BaseActivity(),
}
}
@JvmStatic
fun canHandleMimeType(mimeType: String?): Boolean {
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
}

View file

@ -0,0 +1,320 @@
/* 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.components.compose;
import android.content.Context;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Created by charlag on 12/11/17.
*/
public class ComposeAutoCompleteAdapter extends BaseAdapter
implements Filterable {
private static final int ACCOUNT_VIEW_TYPE = 1;
private static final int HASHTAG_VIEW_TYPE = 2;
private static final int EMOJI_VIEW_TYPE = 3;
private static final int SEPARATOR_VIEW_TYPE = 0;
private final ArrayList<AutocompleteResult> resultList;
private final AutocompletionProvider autocompletionProvider;
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) {
super();
resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public AutocompleteResult getItem(int index) {
return resultList.get(index);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
if (resultValue instanceof AccountResult) {
return formatUsername(((AccountResult) resultValue));
} else if (resultValue instanceof HashtagResult) {
return formatHashtag((HashtagResult) resultValue);
} else if (resultValue instanceof EmojiResult) {
return formatEmoji((EmojiResult) resultValue);
} else {
return "";
}
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<AutocompleteResult> results =
autocompletionProvider.search(constraint.toString());
filterResults.values = results;
filterResults.count = results.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
resultList.addAll((List<AutocompleteResult>) results.values);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
final Context context = parent.getContext();
switch (getItemViewType(position)) {
case ACCOUNT_VIEW_TYPE:
AccountViewHolder accountViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_account, parent, false);
}
if (view.getTag() == null) {
view.setTag(new AccountViewHolder(view));
}
accountViewHolder = (AccountViewHolder) view.getTag();
AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) {
Account account = accountResult.account;
String formattedUsername = context.getString(
R.string.status_username_format,
account.getUsername()
);
accountViewHolder.username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
account.getEmojis(), accountViewHolder.displayName);
accountViewHolder.displayName.setText(emojifiedName);
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
boolean animateAvatar = PreferenceManager.getDefaultSharedPreferences(accountViewHolder.avatar.getContext())
.getBoolean("animateGifAvatars", false);
ImageLoadingHelper.loadAvatar(
account.getAvatar(),
accountViewHolder.avatar,
avatarRadius,
animateAvatar
);
}
break;
case HASHTAG_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
}
HashtagResult result = (HashtagResult) getItem(position);
if (result != null) {
((TextView) view).setText(formatHashtag(result));
}
break;
case EMOJI_VIEW_TYPE:
EmojiViewHolder emojiViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_emoji, parent, false);
}
if (view.getTag() == null) {
view.setTag(new EmojiViewHolder(view));
}
emojiViewHolder = (EmojiViewHolder) view.getTag();
EmojiResult emojiResult = ((EmojiResult) getItem(position));
if (emojiResult != null) {
Emoji emoji = emojiResult.emoji;
String formattedShortcode = context.getString(
R.string.emoji_shortcode_format,
emoji.getShortcode()
);
emojiViewHolder.shortcode.setText(formattedShortcode);
Glide.with(emojiViewHolder.preview)
.load(emoji.getUrl())
.into(emojiViewHolder.preview);
}
break;
case SEPARATOR_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_divider, parent, false);
}
break;
default:
throw new AssertionError("unknown view type");
}
return view;
}
private static String formatUsername(AccountResult result) {
return String.format("@%s", result.account.getUsername());
}
private static String formatHashtag(HashtagResult result) {
return String.format("#%s", result.hashtag);
}
private static String formatEmoji(EmojiResult result) {
return String.format(":%s:", result.emoji.getShortcode());
}
@Override
public int getViewTypeCount() {
return 4;
}
@Override
public int getItemViewType(int position) {
AutocompleteResult item = getItem(position);
if (item instanceof AccountResult) {
return ACCOUNT_VIEW_TYPE;
} else if (item instanceof HashtagResult) {
return HASHTAG_VIEW_TYPE;
} else if (item instanceof EmojiResult) {
return EMOJI_VIEW_TYPE;
} else {
return SEPARATOR_VIEW_TYPE;
}
}
@Override
public boolean areAllItemsEnabled() {
// there may be separators
return false;
}
@Override
public boolean isEnabled(int position) {
return !(getItem(position) instanceof ResultSeparator);
}
public abstract static class AutocompleteResult {
AutocompleteResult() {
}
}
public final static class AccountResult extends AutocompleteResult {
private final Account account;
public AccountResult(Account account) {
this.account = account;
}
}
public final static class HashtagResult extends AutocompleteResult {
private final String hashtag;
public HashtagResult(HashTag hashtag) {
this.hashtag = hashtag.getName();
}
}
public final static class EmojiResult extends AutocompleteResult {
private final Emoji emoji;
public EmojiResult(Emoji emoji) {
this.emoji = emoji;
}
}
public final static class ResultSeparator extends AutocompleteResult {}
public interface AutocompletionProvider {
List<AutocompleteResult> search(String mention);
}
private class AccountViewHolder {
final TextView username;
final TextView displayName;
final ImageView avatar;
private AccountViewHolder(View view) {
username = view.findViewById(R.id.username);
displayName = view.findViewById(R.id.display_name);
avatar = view.findViewById(R.id.avatar);
}
}
private class EmojiViewHolder {
final TextView shortcode;
final ImageView preview;
private EmojiViewHolder(View view) {
shortcode = view.findViewById(R.id.shortcode);
preview = view.findViewById(R.id.preview);
}
}
}

View file

@ -21,8 +21,8 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
@ -39,18 +39,12 @@ import io.reactivex.rxkotlin.Singles
import java.util.*
import javax.inject.Inject
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
class ComposeViewModel
@Inject constructor(
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
) : RxAwareViewModel() {
@ -59,7 +53,8 @@ class ComposeViewModel
private var replyingStatusContent: String? = null
internal var startingText: String? = null
private var savedTootUid: Int = 0
private var scheduledTootUid: String? = null
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
private var inReplyToId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
@ -81,10 +76,6 @@ class ComposeViewModel
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!!
}
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false)
val setupComplete = mutableLiveData(false)
@ -96,7 +87,7 @@ class ComposeViewModel
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
private val isEditingScheduledToot get() = !scheduledTootUid.isNullOrEmpty()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
init {
@ -116,7 +107,7 @@ class ComposeViewModel
.onErrorResumeNext(
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
)
.subscribe ({ instanceEntity ->
.subscribe({ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
}, { throwable ->
@ -126,7 +117,7 @@ class ComposeViewModel
.autoDispose()
}
fun pickMedia(uri: Uri): LiveData<Either<Throwable, QueuedMedia>> {
fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> {
// We are not calling .toLiveData() here because we don't want to stop the process when
// the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
@ -138,7 +129,7 @@ class ComposeViewModel
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size)
addMediaToQueue(type, uri, size, description)
}
}
.subscribe({ queuedMedia ->
@ -150,12 +141,23 @@ class ComposeViewModel
return liveData
}
private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize)
private fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null
): QueuedMedia {
val mediaItem = QueuedMedia(
localId = System.currentTimeMillis(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
)
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem)
.subscribe ({ event ->
.subscribe({ event ->
val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe
val newMediaItem = when (event) {
@ -190,6 +192,10 @@ class ComposeViewModel
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
}
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
}
fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = !(content.isNullOrEmpty()
@ -210,29 +216,37 @@ class ComposeViewModel
}
fun deleteDraft() {
saveTootHelper.deleteDraft(this.savedTootUid)
if (savedTootUid != 0) {
saveTootHelper.deleteDraft(savedTootUid)
}
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
}
}
fun saveDraft(content: String, contentWarning: String) {
val mediaUris = mutableListOf<String>()
val mediaDescriptions = mutableListOf<String?>()
for (item in media.value!!) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
saveTootHelper.saveToot(
content,
contentWarning,
null,
mediaUris,
mediaDescriptions,
savedTootUid,
inReplyToId,
replyingStatusContent,
replyingStatusAuthor,
statusVisibility.value!!,
poll.value
)
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!,
visibility = statusVisibility.value!!,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false
).subscribe()
}
/**
@ -246,7 +260,7 @@ class ComposeViewModel
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootUid.toString()).toObservable().map { Unit }
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else {
just(Unit)
}.toLiveData()
@ -257,28 +271,30 @@ class ComposeViewModel
val mediaIds = ArrayList<String>()
val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>()
val mediaTypes = ArrayList<QueuedMedia.Type>()
for (item in media.value!!) {
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaTypes.add(item.type)
}
val tootToSend = TootToSend(
content,
spoilerText,
statusVisibility.value!!.serverString(),
mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds,
mediaUris.map { it.toString() },
mediaDescriptions,
text = content,
warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
savedJsonUrls = null,
accountId = accountManager.activeAccount!!.id,
savedTootUid = 0,
savedTootUid = savedTootUid,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0
)
@ -286,9 +302,7 @@ class ComposeViewModel
serviceClient.sendToot(tootToSend)
}
return combineLiveData(deletionObservable, sendObservable) { _, _ -> Unit }
return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
}
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
@ -319,7 +333,6 @@ class ComposeViewModel
return completedCaptioningLiveData
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
when (token[0]) {
'@' -> {
@ -370,14 +383,12 @@ class ComposeViewModel
}
}
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
if (setupComplete.value == true) {
return
}
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
@ -385,6 +396,7 @@ class ComposeViewModel
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
@ -396,10 +408,11 @@ class ComposeViewModel
}
// recreate media list
// when coming from SavedTootActivity
val loadedDraftMediaUris = composeOptions?.mediaUrls
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
val draftAttachments = composeOptions?.draftAttachments
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
// when coming from SavedTootActivity
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
.forEach { (uri, description) ->
pickMedia(uri.toUri()).observeForever { errorOrItem ->
@ -408,23 +421,24 @@ class ComposeViewModel
}
}
}
} else if (draftAttachments != null) {
// when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
} else composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft
// when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
else -> QueuedMedia.Type.IMAGE
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
}
savedTootUid = composeOptions?.savedTootUid ?: 0
scheduledTootUid = composeOptions?.scheduledTootUid
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
startingVisibility = tootVisibility
@ -441,7 +455,6 @@ class ComposeViewModel
startingText = builder.toString()
}
scheduledAt.value = composeOptions?.scheduledAt
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
@ -462,6 +475,13 @@ class ComposeViewModel
scheduledAt.value = newScheduledAt
}
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private companion object {
const val TAG = "ComposeViewModel"
}
@ -479,4 +499,9 @@ data class ComposeInstanceParams(
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
)
/**
* Thrown when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()

View file

@ -173,7 +173,13 @@ class MediaUploaderImpl(
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
val uploadDisposable = mastodonApi.uploadMedia(body)
val description = if (media.description != null) {
MultipartBody.Part.createFormData("description", media.description)
} else {
null
}
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe({ attachment ->
emitter.onNext(UploadEvent.FinishedEvent(attachment))
emitter.onComplete()

View file

@ -0,0 +1,159 @@
/* Copyright 2021 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.components.drafts
import android.content.Context
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.Completable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
class DraftHelper @Inject constructor(
val context: Context,
db: AppDatabase
) {
private val draftDao = db.draftDao()
fun saveDraft(
draftId: Int,
accountId: Long,
inReplyToId: String?,
content: String?,
contentWarning: String?,
sensitive: Boolean,
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean
): Completable {
return Single.fromCallable {
val draftDirectory = context.getExternalFilesDir("Tusky")
if (draftDirectory == null || !(draftDirectory.exists())) {
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception()
}
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri
}
}
val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
when (mimeType?.substring(0, mimeType.indexOf('/'))) {
"video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
}
}
val attachments: MutableList<DraftAttachment> = mutableListOf()
for (i in mediaUris.indices) {
attachments.add(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
type = types[i]
)
)
}
DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
)
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
}
fun deleteDraftAndAttachments(draftId: Int): Completable {
return draftDao.find(draftId)
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft)
}
}
fun deleteDraftAndAttachments(draft: DraftEntity): Completable {
return deleteAttachments(draft)
.andThen(draftDao.delete(draft.id))
}
fun deleteAttachments(draft: DraftEntity): Completable {
return Completable.fromCallable {
draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
}
}
}.subscribeOn(Schedulers.io())
}
private fun Uri.isNotInFolder(folder: File): Boolean {
val filePath = path ?: return true
return File(filePath).parentFile == folder
}
private fun Uri.copyToFolder(folder: File): Uri {
val contentResolver = context.contentResolver
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val mimeType = contentResolver.getType(this)
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension)
val file = File(folder, filename)
IOUtils.copyToFile(contentResolver, this, file)
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
}
}

View file

@ -0,0 +1,81 @@
/* Copyright 2020 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.components.drafts
import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
class DraftMediaAdapter(
private val attachmentClick: () -> Unit
) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>(
object: DiffUtil.ItemCallback<DraftAttachment>() {
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
return DraftMediaViewHolder(AppCompatImageView(parent.context))
}
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
getItem(position)?.let { attachment ->
if (attachment.type == DraftAttachment.Type.AUDIO) {
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
}
}
}
inner class DraftMediaViewHolder(val imageView: ImageView)
: RecyclerView.ViewHolder(imageView) {
init {
val thumbnailViewSize =
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom)
imageView.layoutParams = layoutParams
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
imageView.setOnClickListener {
attachmentClick()
}
}
}
}

View file

@ -0,0 +1,197 @@
/* Copyright 2020 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.components.drafts
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SavedTootActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import retrofit2.HttpException
import javax.inject.Inject
class DraftsActivity : BaseActivity(), DraftActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
private var oldDraftsButton: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
title = getString(R.string.title_drafts)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status)
val adapter = DraftsAdapter(this)
binding.draftsRecyclerView.adapter = adapter
binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this)
binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList ->
if (draftList.isEmpty()) {
binding.draftsRecyclerView.hide()
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.drafts, menu)
oldDraftsButton = menu.findItem(R.id.action_old_drafts)
viewModel.showOldDraftsButton()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { showOldDraftsButton ->
oldDraftsButton?.isVisible = showOldDraftsButton
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_old_drafts -> {
val intent = Intent(this, SavedTootActivity::class.java)
startActivityWithSlideInAnimation(intent)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onOpenDraft(draft: DraftEntity) {
if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this)
.subscribe({ status ->
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions))
}, { throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
Log.w(TAG, "failed loading reply information", throwable)
if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information
Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show()
openDraftWithoutReply(draft)
} else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
.show()
}
})
} else {
openDraftWithoutReply(draft)
}
}
private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
startActivity(ComposeActivity.startIntent(this, composeOptions))
}
override fun onDeleteDraft(draft: DraftEntity) {
viewModel.deleteDraft(draft)
Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
viewModel.restoreDraft(draft)
}
.show()
}
companion object {
const val TAG = "DraftsActivity"
fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java)
}
}

View file

@ -0,0 +1,92 @@
/* Copyright 2021 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.components.drafts
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemDraftBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.util.BindingViewHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.visible
interface DraftActionListener {
fun onOpenDraft(draft: DraftEntity)
fun onDeleteDraft(draft: DraftEntity)
}
class DraftsAdapter(
private val listener: DraftActionListener
) : PagedListAdapter<DraftEntity, BindingViewHolder<ItemDraftBinding>>(
object : DiffUtil.ItemCallback<DraftEntity>() {
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem == newItem
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder<ItemDraftBinding> {
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val viewHolder = BindingViewHolder(binding)
binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false)
binding.draftMediaPreview.adapter = DraftMediaAdapter {
getItem(viewHolder.adapterPosition)?.let { draft ->
listener.onOpenDraft(draft)
}
}
return viewHolder
}
override fun onBindViewHolder(holder: BindingViewHolder<ItemDraftBinding>, position: Int) {
getItem(position)?.let { draft ->
holder.binding.root.setOnClickListener {
listener.onOpenDraft(draft)
}
holder.binding.deleteButton.setOnClickListener {
listener.onDeleteDraft(draft)
}
holder.binding.draftSendingInfo.visible(draft.failedToSend)
holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty())
holder.binding.contentWarning.text = draft.contentWarning
holder.binding.content.text = draft.content
holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty())
(holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments)
if (draft.poll != null) {
holder.binding.draftPoll.show()
holder.binding.draftPoll.setPoll(draft.poll)
} else {
holder.binding.draftPoll.hide()
}
}
}
}

View file

@ -0,0 +1,69 @@
/* Copyright 2020 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.components.drafts
import androidx.lifecycle.ViewModel
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.Observable
import io.reactivex.Single
import javax.inject.Inject
class DraftsViewModel @Inject constructor(
val database: AppDatabase,
val accountManager: AccountManager,
val api: MastodonApi,
val draftHelper: DraftHelper
) : ViewModel() {
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20)
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun showOldDraftsButton(): Observable<Boolean> {
return database.tootDao().savedTootCount()
.map { count -> count > 0 }
}
fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft
database.draftDao().delete(draft.id)
.subscribe()
deletedDrafts.add(draft)
}
fun restoreDraft(draft: DraftEntity) {
database.draftDao().insertOrReplace(draft)
.subscribe()
deletedDrafts.remove(draft)
}
fun getToot(tootId: String): Single<Status> {
return api.statusSingle(tootId)
}
override fun onCleared() {
deletedDrafts.forEach {
draftHelper.deleteAttachments(it).subscribe()
}
}
}

View file

@ -120,7 +120,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
override fun edit(item: ScheduledStatus) {
val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions(
scheduledTootUid = item.id,
scheduledTootId = item.id,
tootText = item.params.text,
contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments,