1575 lines
64 KiB
Kotlin
1575 lines
64 KiB
Kotlin
/*
|
|
* Husky -- A Pleroma client for Android
|
|
*
|
|
* Copyright (C) 2021 The Husky Developers
|
|
* Copyright (C) 2019 Tusky Contributors
|
|
*
|
|
* 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.
|
|
*
|
|
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package com.keylesspalace.tusky.components.compose
|
|
|
|
import android.Manifest
|
|
import android.app.Activity
|
|
import android.app.ProgressDialog
|
|
import android.app.TimePickerDialog
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.content.pm.PackageManager
|
|
import android.graphics.PorterDuff
|
|
import android.graphics.PorterDuffColorFilter
|
|
import android.graphics.drawable.Drawable
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.os.Parcelable
|
|
import android.provider.MediaStore
|
|
import android.view.KeyEvent
|
|
import android.view.MenuItem
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.ImageButton
|
|
import android.widget.LinearLayout
|
|
import android.widget.TimePicker
|
|
import android.widget.Toast
|
|
import androidx.activity.viewModels
|
|
import androidx.annotation.ColorInt
|
|
import androidx.annotation.StringRes
|
|
import androidx.annotation.VisibleForTesting
|
|
import androidx.appcompat.app.AlertDialog
|
|
import androidx.appcompat.widget.PopupMenu
|
|
import androidx.core.app.ActivityCompat
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.content.FileProvider
|
|
import androidx.core.net.toUri
|
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
|
import androidx.core.view.inputmethod.InputContentInfoCompat
|
|
import androidx.core.view.isGone
|
|
import androidx.core.view.isVisible
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.preference.PreferenceManager
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.transition.TransitionManager
|
|
import com.bumptech.glide.Glide
|
|
import com.bumptech.glide.request.target.CustomTarget
|
|
import com.bumptech.glide.request.transition.Transition
|
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
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.EmojiAdapter
|
|
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
|
import com.keylesspalace.tusky.appstore.Event
|
|
import com.keylesspalace.tusky.appstore.EventHub
|
|
import com.keylesspalace.tusky.appstore.StatusPreviewEvent
|
|
import com.keylesspalace.tusky.components.common.AudioSizeException
|
|
import com.keylesspalace.tusky.components.common.DEFAULT_CHARACTER_LIMIT
|
|
import com.keylesspalace.tusky.components.common.MediaSizeException
|
|
import com.keylesspalace.tusky.components.common.VideoOrImageException
|
|
import com.keylesspalace.tusky.components.common.VideoSizeException
|
|
import com.keylesspalace.tusky.components.common.createNewImageFile
|
|
import com.keylesspalace.tusky.components.common.toFileName
|
|
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.core.extensions.composeWithZwsp
|
|
import com.keylesspalace.tusky.core.extensions.viewBinding
|
|
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
|
|
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
|
|
import com.keylesspalace.tusky.entity.Emoji
|
|
import com.keylesspalace.tusky.entity.NewPoll
|
|
import com.keylesspalace.tusky.entity.Status
|
|
import com.keylesspalace.tusky.settings.PrefKeys
|
|
import com.keylesspalace.tusky.util.BBCodeEdit
|
|
import com.keylesspalace.tusky.util.ComposeTokenizer
|
|
import com.keylesspalace.tusky.util.HTMLEdit
|
|
import com.keylesspalace.tusky.util.ThemeUtils
|
|
import com.keylesspalace.tusky.util.afterTextChanged
|
|
import com.keylesspalace.tusky.util.combineLiveData
|
|
import com.keylesspalace.tusky.util.combineOptionalLiveData
|
|
import com.keylesspalace.tusky.util.hide
|
|
import com.keylesspalace.tusky.util.highlightSpans
|
|
import com.keylesspalace.tusky.util.loadAvatar
|
|
import com.keylesspalace.tusky.util.observeOnce
|
|
import com.keylesspalace.tusky.util.onTextChanged
|
|
import com.keylesspalace.tusky.util.show
|
|
import com.keylesspalace.tusky.util.visible
|
|
import com.keylesspalace.tusky.util.withLifecycleContext
|
|
import com.keylesspalace.tusky.view.EmojiKeyboard
|
|
import com.mikepenz.iconics.IconicsDrawable
|
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
|
import com.mikepenz.iconics.utils.colorInt
|
|
import com.mikepenz.iconics.utils.sizeDp
|
|
import com.uber.autodispose.android.lifecycle.autoDispose
|
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
|
import java.io.File
|
|
import java.io.IOException
|
|
import java.util.Locale
|
|
import javax.inject.Inject
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlinx.android.parcel.Parcelize
|
|
import me.thanel.markdownedit.MarkdownEdit
|
|
import timber.log.Timber
|
|
|
|
class ComposeActivity : BaseActivity(),
|
|
ComposeOptionsListener,
|
|
ComposeAutoCompleteAdapter.AutocompletionProvider,
|
|
OnEmojiSelectedListener,
|
|
Injectable,
|
|
InputConnectionCompat.OnCommitContentListener,
|
|
TimePickerDialog.OnTimeSetListener,
|
|
EmojiKeyboard.OnEmojiSelectedListener {
|
|
|
|
private val binding by viewBinding(ActivityComposeBinding::inflate)
|
|
|
|
@Inject
|
|
lateinit var viewModelFactory: ViewModelFactory
|
|
|
|
@Inject
|
|
lateinit var eventHub: EventHub
|
|
|
|
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
|
|
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
|
|
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
|
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
|
private lateinit var stickerBehavior: BottomSheetBehavior<*>
|
|
private lateinit var previewBehavior: BottomSheetBehavior<*>
|
|
|
|
// 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
|
|
|
|
@VisibleForTesting
|
|
val viewModel: ComposeViewModel by viewModels { viewModelFactory }
|
|
private var suggestFormattingSyntax: String = "text/markdown"
|
|
|
|
private val maxUploadMediaNumber = 4
|
|
private var mediaCount = 0
|
|
|
|
private lateinit var preferences: SharedPreferences
|
|
|
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
|
if(theme == "black") {
|
|
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
|
}
|
|
setContentView(binding.root)
|
|
|
|
setupActionBar()
|
|
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
|
val activeAccount = accountManager.activeAccount ?: return
|
|
|
|
viewModel.tryFetchStickers = preferences.getBoolean(PrefKeys.STICKERS, false)
|
|
viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false)
|
|
setupAvatar(preferences, activeAccount)
|
|
val mediaAdapter = MediaPreviewAdapter(
|
|
this,
|
|
onAddCaption = { item ->
|
|
makeCaptionDialog(item.description, item.uri) { newDescription ->
|
|
viewModel.updateDescription(item.localId, newDescription)
|
|
}
|
|
},
|
|
onRemove = this::removeMediaFromQueue
|
|
)
|
|
binding.composeMediaPreviewBar.layoutManager =
|
|
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
|
binding.composeMediaPreviewBar.adapter = mediaAdapter
|
|
binding.composeMediaPreviewBar.itemAnimator = null
|
|
|
|
// set before subscribing to updates to not accidentally catch it
|
|
viewModel.formattingSyntax.value = activeAccount.defaultFormattingSyntax
|
|
|
|
subscribeToUpdates(mediaAdapter, activeAccount)
|
|
setupButtons()
|
|
|
|
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
|
|
|
|
/* 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 = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
|
|
|
|
suggestFormattingSyntax = if(!composeOptions?.formattingSyntax.isNullOrEmpty()) {
|
|
composeOptions?.formattingSyntax!!
|
|
} else {
|
|
activeAccount.defaultFormattingSyntax
|
|
}
|
|
|
|
viewModel.setup(composeOptions)
|
|
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
|
|
val tootText = composeOptions?.tootText
|
|
if(!tootText.isNullOrEmpty()) {
|
|
binding.composeEditField.setText(tootText)
|
|
}
|
|
|
|
if(!composeOptions?.scheduledAt.isNullOrEmpty()) {
|
|
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
|
}
|
|
|
|
viewModel.composeWithZwsp.value =
|
|
preferences.getBoolean(PrefKeys.COMPOSING_ZWSP_CHAR, false)
|
|
|
|
setupComposeField(viewModel.startingText)
|
|
setupContentWarningField(composeOptions?.contentWarning)
|
|
setupPollView()
|
|
applyShareIntent(intent, savedInstanceState)
|
|
viewModel.setupComplete.value = true
|
|
|
|
binding.stickerKeyboard.isSticky = true
|
|
|
|
eventHub.events.observeOn(AndroidSchedulers.mainThread())
|
|
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
|
.subscribe { event: Event? ->
|
|
when(event) {
|
|
is StatusPreviewEvent -> onStatusPreviewReady(event.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
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. */
|
|
intent.type?.also { type ->
|
|
if(type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
} else if(type == "text/plain" && intent.action == Intent.ACTION_SEND) {
|
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
|
|
val shareBody = if(!subject.isNullOrBlank() && subject !in text) {
|
|
subject + '\n' + text
|
|
} else {
|
|
text
|
|
}
|
|
|
|
if(shareBody.isNotBlank()) {
|
|
val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
|
|
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
|
|
val left = min(start, end)
|
|
val right = max(start, end)
|
|
binding.composeEditField.text.replace(
|
|
left,
|
|
right,
|
|
shareBody,
|
|
0,
|
|
shareBody.length
|
|
)
|
|
// move edittext cursor to first when shareBody parsed
|
|
binding.composeEditField.text.insert(0, "\n")
|
|
binding.composeEditField.setSelection(0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
|
|
if(replyingStatusAuthor != null) {
|
|
binding.composeReplyView.show()
|
|
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
|
val arrowDownIcon =
|
|
IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
|
|
|
|
ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
|
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
null,
|
|
null,
|
|
arrowDownIcon,
|
|
null
|
|
)
|
|
|
|
binding.composeReplyView.setOnClickListener {
|
|
TransitionManager.beginDelayedTransition(binding.composeReplyContentView.parent as ViewGroup)
|
|
|
|
if(binding.composeReplyContentView.isVisible) {
|
|
binding.composeReplyContentView.hide()
|
|
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
null,
|
|
null,
|
|
arrowDownIcon,
|
|
null
|
|
)
|
|
} else {
|
|
binding.composeReplyContentView.show()
|
|
val arrowUpIcon = IconicsDrawable(
|
|
this,
|
|
GoogleMaterial.Icon.gmd_arrow_drop_up
|
|
).apply { sizeDp = 12 }
|
|
|
|
ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
|
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
null,
|
|
null,
|
|
arrowUpIcon,
|
|
null
|
|
)
|
|
}
|
|
}
|
|
}
|
|
replyingStatusContent?.let { binding.composeReplyContentView.text = it }
|
|
}
|
|
|
|
private fun setupContentWarningField(startingContentWarning: String?) {
|
|
if(startingContentWarning != null) {
|
|
binding.composeContentWarningField.setText(startingContentWarning)
|
|
}
|
|
|
|
binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
|
}
|
|
|
|
private fun setupComposeField(startingText: String?) {
|
|
binding.composeEditField.setOnCommitContentListener(this)
|
|
|
|
binding.composeEditField.setOnKeyListener { _, keyCode, event ->
|
|
this.onKeyDown(
|
|
keyCode,
|
|
event
|
|
)
|
|
}
|
|
|
|
binding.composeEditField.setAdapter(
|
|
ComposeAutoCompleteAdapter(this)
|
|
)
|
|
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
|
|
|
binding.composeEditField.setText(startingText)
|
|
binding.composeEditField.setSelection(binding.composeEditField.length())
|
|
|
|
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
|
|
highlightSpans(binding.composeEditField.text, mentionColour)
|
|
binding.composeEditField.afterTextChanged { editable ->
|
|
highlightSpans(editable, mentionColour)
|
|
updateVisibleCharactersLeft()
|
|
}
|
|
|
|
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
|
if(Build.VERSION.SDK_INT == Build.VERSION_CODES.O
|
|
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
|
|
) {
|
|
binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
|
}
|
|
}
|
|
|
|
private fun reenableAttachments() {
|
|
// in case of we already had disabled attachments
|
|
// but got information about extension later
|
|
enableButton(binding.composeAddMediaButton, clickable = true, colorActive = true)
|
|
enablePollButton(true)
|
|
}
|
|
|
|
@VisibleForTesting
|
|
var supportedFormattingSyntax = arrayListOf<String>()
|
|
|
|
private fun subscribeToUpdates(
|
|
mediaAdapter: MediaPreviewAdapter,
|
|
activeAccount: AccountEntity
|
|
) {
|
|
withLifecycleContext {
|
|
viewModel.instanceParams.observe { instanceData ->
|
|
maximumTootCharacters = instanceData.maxChars
|
|
updateVisibleCharactersLeft()
|
|
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
|
|
}
|
|
|
|
viewModel.instanceMetadata.observe { instanceData ->
|
|
if(instanceData.supportsMarkdown) {
|
|
supportedFormattingSyntax.add("text/markdown")
|
|
}
|
|
|
|
if(instanceData.supportsBBcode) {
|
|
supportedFormattingSyntax.add("text/bbcode")
|
|
}
|
|
|
|
if(instanceData.supportsHTML) {
|
|
supportedFormattingSyntax.add("text/html")
|
|
}
|
|
|
|
if(supportedFormattingSyntax.size != 0) {
|
|
binding.composeFormattingSyntax.visible(true)
|
|
|
|
val supportsPrefferedSyntax =
|
|
supportedFormattingSyntax.contains(viewModel.formattingSyntax.value!!)
|
|
|
|
if(!supportsPrefferedSyntax) {
|
|
suggestFormattingSyntax =
|
|
if(supportedFormattingSyntax.contains(activeAccount.defaultFormattingSyntax))
|
|
activeAccount.defaultFormattingSyntax
|
|
else supportedFormattingSyntax[0]
|
|
|
|
viewModel.formattingSyntax.value = ""
|
|
}
|
|
}
|
|
|
|
if(instanceData.software == "pleroma") {
|
|
binding.composePreviewButton.visibility = View.VISIBLE
|
|
reenableAttachments()
|
|
}
|
|
}
|
|
|
|
viewModel.haveStickers.observe { haveStickers ->
|
|
if(haveStickers) {
|
|
binding.composeStickerButton.visibility = View.VISIBLE
|
|
}
|
|
}
|
|
|
|
viewModel.instanceStickers.observe { stickers ->
|
|
if(stickers.isNotEmpty()) {
|
|
binding.composeStickerButton.visibility = View.VISIBLE
|
|
enableButton(
|
|
binding.composeStickerButton,
|
|
clickable = true,
|
|
colorActive = true
|
|
)
|
|
binding.stickerKeyboard.setupStickerKeyboard(this@ComposeActivity, stickers)
|
|
}
|
|
}
|
|
|
|
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
|
combineLiveData(
|
|
viewModel.markMediaAsSensitive,
|
|
viewModel.showContentWarning
|
|
) { markSensitive, showContentWarning ->
|
|
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
|
showContentWarning(showContentWarning)
|
|
}.subscribe()
|
|
|
|
viewModel.statusVisibility.observe { visibility ->
|
|
setStatusVisibility(visibility)
|
|
}
|
|
|
|
viewModel.media.observe { media ->
|
|
mediaAdapter.submitList(media)
|
|
if(media.size != mediaCount) {
|
|
mediaCount = media.size
|
|
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
|
|
updateSensitiveMediaToggle(
|
|
viewModel.markMediaAsSensitive.value != false,
|
|
viewModel.showContentWarning.value != false
|
|
)
|
|
}
|
|
}
|
|
|
|
viewModel.poll.observe { poll ->
|
|
binding.pollPreview.visible(poll != null)
|
|
poll?.let(binding.pollPreview::setPoll)
|
|
}
|
|
|
|
viewModel.scheduledAt.observe { scheduledAt ->
|
|
if(scheduledAt == null) {
|
|
binding.composeScheduleView.resetSchedule()
|
|
} else {
|
|
binding.composeScheduleView.setDateTime(scheduledAt)
|
|
}
|
|
updateScheduleButton()
|
|
}
|
|
|
|
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
|
|
if(!viewModel.hasNoAttachmentLimits) {
|
|
val active = poll == null && media!!.size != 4
|
|
&& (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
|
|
enableButton(binding.composeAddMediaButton, active, active)
|
|
enablePollButton(media.isNullOrEmpty())
|
|
}
|
|
}.subscribe()
|
|
|
|
viewModel.uploadError.observe {
|
|
displayTransientError(R.string.error_media_upload_sending)
|
|
}
|
|
|
|
viewModel.setupComplete.observe {
|
|
// Focus may have changed during view model setup, ensure initial focus is on the edit field
|
|
binding.composeEditField.requestFocus()
|
|
}
|
|
|
|
viewModel.formattingSyntax.observe {
|
|
if(it.isEmpty()) {
|
|
enableFormattingSyntaxButton(suggestFormattingSyntax, false)
|
|
setIconForSyntax(suggestFormattingSyntax, false)
|
|
} else {
|
|
val enable = it == suggestFormattingSyntax
|
|
|
|
enableFormattingSyntaxButton(it, enable)
|
|
setIconForSyntax(it, enable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupButtons() {
|
|
binding.composeOptionsBottomSheet.listener = this
|
|
|
|
composeOptionsBehavior = BottomSheetBehavior.from(binding.composeOptionsBottomSheet)
|
|
addMediaBehavior = BottomSheetBehavior.from(binding.addMediaBottomSheet)
|
|
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
|
|
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
|
|
stickerBehavior = BottomSheetBehavior.from(binding.stickerKeyboard)
|
|
previewBehavior = BottomSheetBehavior.from(binding.previewScroll)
|
|
|
|
enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
|
|
enableButton(binding.composeStickerButton, clickable = false, colorActive = false)
|
|
|
|
// Setup the interface buttons.
|
|
binding.composeTootButton.setOnClickListener { onSendClicked(false) }
|
|
binding.composePreviewButton.setOnClickListener { onSendClicked(true) }
|
|
binding.composeAddMediaButton.setOnClickListener { openPickDialog() }
|
|
binding.composeToggleVisibilityButton.setOnClickListener { showComposeOptions() }
|
|
binding.composeContentWarningButton.setOnClickListener { onContentWarningChanged() }
|
|
binding.composeEmojiButton.setOnClickListener { showEmojis() }
|
|
binding.composeHideMediaButton.setOnClickListener { toggleHideMedia() }
|
|
binding.composeScheduleButton.setOnClickListener { onScheduleClick() }
|
|
binding.composeScheduleView.setResetOnClickListener { resetSchedule() }
|
|
binding.composeFormattingSyntax.setOnClickListener { toggleFormattingMode() }
|
|
binding.composeFormattingSyntax.setOnLongClickListener { selectFormattingSyntax() }
|
|
binding.composeStickerButton.setOnClickListener { showStickers() }
|
|
binding.atButton.setOnClickListener { atButtonClicked() }
|
|
binding.hashButton.setOnClickListener { hashButtonClicked() }
|
|
binding.codeButton.setOnClickListener { codeButtonClicked() }
|
|
binding.linkButton.setOnClickListener { linkButtonClicked() }
|
|
binding.strikethroughButton.setOnClickListener { strikethroughButtonClicked() }
|
|
binding.italicButton.setOnClickListener { italicButtonClicked() }
|
|
binding.boldButton.setOnClickListener { boldButtonClicked() }
|
|
|
|
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
|
|
|
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply {
|
|
colorInt = textColor; sizeDp = 18
|
|
}
|
|
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
cameraIcon,
|
|
null,
|
|
null,
|
|
null
|
|
)
|
|
|
|
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply {
|
|
colorInt = textColor; sizeDp = 18
|
|
}
|
|
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
imageIcon,
|
|
null,
|
|
null,
|
|
null
|
|
)
|
|
|
|
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply {
|
|
colorInt = textColor; sizeDp = 18
|
|
}
|
|
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
|
pollIcon,
|
|
null,
|
|
null,
|
|
null
|
|
)
|
|
|
|
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
|
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
|
|
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
|
}
|
|
|
|
private fun setupActionBar() {
|
|
setSupportActionBar(binding.toolbar)
|
|
supportActionBar?.run {
|
|
title = null
|
|
setDisplayHomeAsUpEnabled(true)
|
|
setDisplayShowHomeEnabled(true)
|
|
setHomeAsUpIndicator(R.drawable.ic_close_24dp)
|
|
}
|
|
|
|
}
|
|
|
|
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
|
|
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
|
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
|
val avatarSize = a.getDimensionPixelSize(0, 1)
|
|
a.recycle()
|
|
|
|
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
|
loadAvatar(
|
|
activeAccount.profilePictureUrl,
|
|
binding.composeAvatar,
|
|
avatarSize / 8,
|
|
animateAvatars
|
|
)
|
|
binding.composeAvatar.contentDescription = getString(
|
|
R.string.compose_active_account_description,
|
|
activeAccount.fullName
|
|
)
|
|
}
|
|
|
|
private fun replaceTextAtCaret(text: CharSequence) {
|
|
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
|
val start =
|
|
binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
|
val end =
|
|
binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
|
val textToInsert =
|
|
if(start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) {
|
|
" $text"
|
|
} else {
|
|
text
|
|
}
|
|
binding.composeEditField.text.replace(start, end, textToInsert)
|
|
|
|
// Set the cursor after the inserted text
|
|
binding.composeEditField.setSelection(start + text.length)
|
|
}
|
|
|
|
private fun enableFormattingSyntaxButton(syntax: String, enable: Boolean) {
|
|
val stringId = when(syntax) {
|
|
"text/html" -> R.string.action_html
|
|
"text/bbcode" -> R.string.action_bbcode
|
|
else -> R.string.action_markdown
|
|
}
|
|
|
|
val actionStringId =
|
|
if(enable) R.string.action_disable_formatting_syntax else R.string.action_enable_formatting_syntax
|
|
val tooltipText = getString(actionStringId).format(stringId)
|
|
|
|
binding.composeFormattingSyntax.contentDescription = tooltipText
|
|
|
|
@ColorInt val color = ThemeUtils.getColor(
|
|
this,
|
|
if(enable) R.attr.colorPrimary else android.R.attr.textColorTertiary
|
|
)
|
|
binding.composeFormattingSyntax.drawable.colorFilter =
|
|
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
|
|
|
enableMarkdownWYSIWYGButtons(enable)
|
|
}
|
|
|
|
private fun setIconForSyntax(syntax: String, enable: Boolean) {
|
|
val drawableId = when(syntax) {
|
|
"text/html" -> R.drawable.ic_html_24dp
|
|
"text/bbcode" -> R.drawable.ic_bbcode_24dp
|
|
else -> R.drawable.ic_markdown
|
|
}
|
|
|
|
suggestFormattingSyntax =
|
|
if(drawableId == R.drawable.ic_markdown) "text/markdown" else syntax
|
|
binding.composeFormattingSyntax.setImageResource(drawableId)
|
|
enableFormattingSyntaxButton(syntax, enable)
|
|
}
|
|
|
|
private fun toggleFormattingMode() {
|
|
if(viewModel.formattingSyntax.value!! == suggestFormattingSyntax) {
|
|
viewModel.formattingSyntax.value = ""
|
|
} else {
|
|
viewModel.formattingSyntax.value = suggestFormattingSyntax
|
|
}
|
|
}
|
|
|
|
private fun selectFormattingSyntax(): Boolean {
|
|
val menu = PopupMenu(this, binding.composeFormattingSyntax)
|
|
val plaintextId = 0
|
|
val markdownId = 1
|
|
val bbcodeId = 2
|
|
val htmlId = 3
|
|
|
|
menu.menu.add(0, plaintextId, 0, R.string.action_plaintext)
|
|
if(viewModel.instanceMetadata.value?.supportsMarkdown == true) {
|
|
menu.menu.add(0, markdownId, 0, R.string.action_markdown)
|
|
}
|
|
|
|
if(viewModel.instanceMetadata.value?.supportsBBcode == true) {
|
|
menu.menu.add(0, bbcodeId, 0, R.string.action_bbcode)
|
|
}
|
|
|
|
if(viewModel.instanceMetadata.value?.supportsHTML == true) {
|
|
menu.menu.add(0, htmlId, 0, R.string.action_html)
|
|
}
|
|
|
|
menu.setOnMenuItemClickListener { menuItem ->
|
|
val choose = when(menuItem.itemId) {
|
|
markdownId -> "text/markdown"
|
|
bbcodeId -> "text/bbcode"
|
|
htmlId -> "text/html"
|
|
else -> ""
|
|
}
|
|
suggestFormattingSyntax = choose
|
|
viewModel.formattingSyntax.value = choose
|
|
true
|
|
}
|
|
menu.show()
|
|
|
|
return true
|
|
}
|
|
|
|
private fun enableMarkdownWYSIWYGButtons(visible: Boolean) {
|
|
val visibility = if(visible) View.VISIBLE else View.GONE
|
|
binding.codeButton.visibility = visibility
|
|
binding.linkButton.visibility = visibility
|
|
binding.strikethroughButton.visibility = visibility
|
|
binding.italicButton.visibility = visibility
|
|
binding.boldButton.visibility = visibility
|
|
}
|
|
|
|
fun prependSelectedWordsWith(text: CharSequence) {
|
|
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
|
val start =
|
|
binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
|
val end =
|
|
binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
|
val editorText = binding.composeEditField.text
|
|
|
|
if(start == end) {
|
|
// No selection, just insert text at caret
|
|
editorText.insert(start, text)
|
|
// Set the cursor after the inserted text
|
|
binding.composeEditField.setSelection(start + text.length)
|
|
} else {
|
|
var wasWord: Boolean
|
|
var isWord = end < editorText.length && !Character.isWhitespace(editorText[end])
|
|
var newEnd = end
|
|
|
|
// Iterate the selection backward so we don't have to juggle indices on insertion
|
|
var index = end - 1
|
|
while(index >= start - 1 && index >= 0) {
|
|
wasWord = isWord
|
|
isWord = !Character.isWhitespace(editorText[index])
|
|
if(wasWord && !isWord) {
|
|
// We've reached the beginning of a word, perform insert
|
|
editorText.insert(index + 1, text)
|
|
newEnd += text.length
|
|
}
|
|
--index
|
|
}
|
|
|
|
if(start == 0 && isWord) {
|
|
// Special case when the selection includes the start of the text
|
|
editorText.insert(0, text)
|
|
newEnd += text.length
|
|
}
|
|
|
|
// Keep the same text (including insertions) selected
|
|
binding.composeEditField.setSelection(start, newEnd)
|
|
}
|
|
}
|
|
|
|
|
|
private fun atButtonClicked() {
|
|
prependSelectedWordsWith("@")
|
|
}
|
|
|
|
private fun hashButtonClicked() {
|
|
prependSelectedWordsWith("#")
|
|
}
|
|
|
|
private fun codeButtonClicked() {
|
|
when(viewModel.formattingSyntax.value!!) {
|
|
"text/markdown" -> MarkdownEdit.addCode(binding.composeEditField)
|
|
"text/bbcode" -> BBCodeEdit.addCode(binding.composeEditField)
|
|
"text/html" -> HTMLEdit.addCode(binding.composeEditField)
|
|
}
|
|
}
|
|
|
|
private fun linkButtonClicked() {
|
|
when(viewModel.formattingSyntax.value!!) {
|
|
"text/markdown" -> MarkdownEdit.addLink(binding.composeEditField)
|
|
"text/bbcode" -> BBCodeEdit.addLink(binding.composeEditField)
|
|
"text/html" -> HTMLEdit.addLink(binding.composeEditField)
|
|
}
|
|
}
|
|
|
|
private fun strikethroughButtonClicked() {
|
|
when(viewModel.formattingSyntax.value!!) {
|
|
"text/markdown" -> MarkdownEdit.addStrikeThrough(binding.composeEditField)
|
|
"text/bbcode" -> BBCodeEdit.addStrikeThrough(binding.composeEditField)
|
|
"text/html" -> HTMLEdit.addStrikeThrough(binding.composeEditField)
|
|
}
|
|
}
|
|
|
|
private fun italicButtonClicked() {
|
|
when(viewModel.formattingSyntax.value!!) {
|
|
"text/markdown" -> MarkdownEdit.addItalic(binding.composeEditField)
|
|
"text/bbcode" -> BBCodeEdit.addItalic(binding.composeEditField)
|
|
"text/html" -> HTMLEdit.addItalic(binding.composeEditField)
|
|
}
|
|
}
|
|
|
|
private fun boldButtonClicked() {
|
|
when(viewModel.formattingSyntax.value!!) {
|
|
"text/markdown" -> MarkdownEdit.addBold(binding.composeEditField)
|
|
"text/bbcode" -> BBCodeEdit.addBold(binding.composeEditField)
|
|
"text/html" -> HTMLEdit.addBold(binding.composeEditField)
|
|
}
|
|
}
|
|
|
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri)
|
|
super.onSaveInstanceState(outState)
|
|
}
|
|
|
|
private fun displayTransientError(@StringRes stringId: Int) {
|
|
val bar = Snackbar.make(binding.root, stringId, Snackbar.LENGTH_LONG)
|
|
//necessary so snackbar is shown over everything
|
|
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
|
bar.show()
|
|
}
|
|
|
|
private fun toggleHideMedia() {
|
|
this.viewModel.toggleMarkSensitive()
|
|
}
|
|
|
|
private fun updateSensitiveMediaToggle(
|
|
markMediaSensitive: Boolean,
|
|
contentWarningShown: Boolean
|
|
) {
|
|
if(viewModel.media.value.isNullOrEmpty()) {
|
|
binding.composeHideMediaButton.hide()
|
|
} else {
|
|
binding.composeHideMediaButton.show()
|
|
@ColorInt val color = if(contentWarningShown) {
|
|
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
|
binding.composeHideMediaButton.isClickable = false
|
|
ContextCompat.getColor(this, R.color.transparent_tusky_blue)
|
|
|
|
} else {
|
|
binding.composeHideMediaButton.isClickable = true
|
|
if(markMediaSensitive) {
|
|
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
|
ContextCompat.getColor(this, R.color.tusky_blue)
|
|
} else {
|
|
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
|
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
|
}
|
|
}
|
|
binding.composeHideMediaButton.drawable.colorFilter =
|
|
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
|
}
|
|
}
|
|
|
|
private fun updateScheduleButton() {
|
|
@ColorInt val color = if(binding.composeScheduleView.time == null) {
|
|
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
|
} else {
|
|
ContextCompat.getColor(this, R.color.tusky_blue)
|
|
}
|
|
binding.composeScheduleButton.drawable.colorFilter =
|
|
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
|
}
|
|
|
|
private fun enableButtons(enable: Boolean) {
|
|
binding.composeAddMediaButton.isClickable = enable
|
|
binding.composeToggleVisibilityButton.isClickable = enable
|
|
binding.composeEmojiButton.isClickable = enable
|
|
binding.composeHideMediaButton.isClickable = enable
|
|
binding.composeScheduleButton.isClickable = enable
|
|
binding.composeFormattingSyntax.isClickable = enable
|
|
binding.composeTootButton.isEnabled = enable
|
|
binding.composePreviewButton.isEnabled = enable
|
|
binding.composeStickerButton.isEnabled = enable
|
|
}
|
|
|
|
private fun setStatusVisibility(visibility: Status.Visibility) {
|
|
binding.composeOptionsBottomSheet.setStatusVisibility(visibility)
|
|
binding.composeTootButton.setStatusVisibility(visibility)
|
|
|
|
val iconRes = when(visibility) {
|
|
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
|
|
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
|
|
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
|
|
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
|
|
else -> R.drawable.ic_lock_open_24dp
|
|
}
|
|
binding.composeToggleVisibilityButton.setImageResource(iconRes)
|
|
}
|
|
|
|
private fun showComposeOptions() {
|
|
if(composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
|
|
private fun onScheduleClick() {
|
|
if(viewModel.scheduledAt.value == null) {
|
|
binding.composeScheduleView.openPickDateDialog()
|
|
} else {
|
|
showScheduleView()
|
|
}
|
|
}
|
|
|
|
private fun showScheduleView() {
|
|
if(scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
|
|
private fun showEmojis() {
|
|
binding.emojiView.adapter?.let {
|
|
if(it.itemCount == 0) {
|
|
val errorMessage = getString(
|
|
R.string.error_no_custom_emojis,
|
|
accountManager.activeAccount!!.domain
|
|
)
|
|
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
|
|
} else {
|
|
if(emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun openPickDialog() {
|
|
if(addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
|
|
private fun onMediaPick() {
|
|
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
//Wait until bottom sheet is not collapsed and show next screen after
|
|
if(newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
addMediaBehavior.removeBottomSheetCallback(this)
|
|
if(ContextCompat.checkSelfPermission(
|
|
this@ComposeActivity,
|
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
) != PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
ActivityCompat.requestPermissions(
|
|
this@ComposeActivity,
|
|
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
|
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
|
|
)
|
|
} else {
|
|
initiateMediaPicking()
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
|
}
|
|
)
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
}
|
|
|
|
private fun openPollDialog() {
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
val instanceParams = viewModel.instanceParams.value!!
|
|
showAddPollDialog(
|
|
this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
|
instanceParams.pollMaxLength, viewModel::updatePoll
|
|
)
|
|
}
|
|
|
|
private fun setupPollView() {
|
|
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
|
val marginBottom =
|
|
resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
|
|
|
val layoutParams = LinearLayout.LayoutParams(
|
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
|
)
|
|
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
|
binding.pollPreview.layoutParams = layoutParams
|
|
|
|
binding.pollPreview.setOnClickListener {
|
|
val popup = PopupMenu(this, binding.pollPreview)
|
|
val editId = 1
|
|
val removeId = 2
|
|
popup.menu.add(0, editId, 0, R.string.edit_poll)
|
|
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
|
popup.setOnMenuItemClickListener { menuItem ->
|
|
when(menuItem.itemId) {
|
|
editId -> openPollDialog()
|
|
removeId -> removePoll()
|
|
}
|
|
true
|
|
}
|
|
popup.show()
|
|
}
|
|
}
|
|
|
|
private fun removePoll() {
|
|
viewModel.poll.value = null
|
|
binding.pollPreview.hide()
|
|
}
|
|
|
|
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
viewModel.statusVisibility.value = visibility
|
|
}
|
|
|
|
@VisibleForTesting
|
|
fun calculateTextLength(): Int {
|
|
var offset = 0
|
|
val urlSpans = binding.composeEditField.urls
|
|
if(urlSpans != null) {
|
|
for(span in urlSpans) {
|
|
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
|
|
}
|
|
}
|
|
var length = binding.composeEditField.length() - offset
|
|
if(viewModel.showContentWarning.value!!) {
|
|
length += binding.composeContentWarningField.length()
|
|
}
|
|
return length
|
|
}
|
|
|
|
private fun updateVisibleCharactersLeft() {
|
|
val remainingLength = maximumTootCharacters - calculateTextLength()
|
|
binding.composeCharactersLeftView.text =
|
|
String.format(Locale.getDefault(), "%d", remainingLength)
|
|
|
|
val textColor = if(remainingLength < 0) {
|
|
ContextCompat.getColor(this, R.color.tusky_red)
|
|
} else {
|
|
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
|
}
|
|
binding.composeCharactersLeftView.setTextColor(textColor)
|
|
}
|
|
|
|
private fun onContentWarningChanged() {
|
|
val showWarning = binding.composeContentWarningBar.isGone
|
|
viewModel.contentWarningChanged(showWarning)
|
|
updateVisibleCharactersLeft()
|
|
}
|
|
|
|
private fun verifyScheduledTime(): Boolean {
|
|
return binding.composeScheduleView.verifyScheduledTime(
|
|
binding.composeScheduleView.getDateTime(
|
|
viewModel.scheduledAt.value
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun onSendClicked(preview: Boolean) {
|
|
if(preview && previewBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
|
|
if(verifyScheduledTime()) {
|
|
sendStatus(preview)
|
|
} else {
|
|
showScheduleView()
|
|
}
|
|
}
|
|
|
|
private fun onStatusPreviewReady(status: Status) {
|
|
enableButtons(true)
|
|
if(previewBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
binding.previewView.setupWithStatus(status)
|
|
previewBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
|
|
/** This is for the fancy keyboards which can insert images and stuff. */
|
|
override fun onCommitContent(
|
|
inputContentInfo: InputContentInfoCompat,
|
|
flags: Int,
|
|
opts: Bundle?
|
|
): Boolean {
|
|
// Verify the returned content's type is of the correct MIME type
|
|
val supported = inputContentInfo.description.hasMimeType("image/*")
|
|
|
|
if(supported) {
|
|
val lacksPermission =
|
|
(flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
|
|
if(lacksPermission) {
|
|
try {
|
|
inputContentInfo.requestPermission()
|
|
} catch(e: Exception) {
|
|
Timber.e("InputContentInfoCompat#requestPermission() failed: ${e.message}")
|
|
return false
|
|
}
|
|
}
|
|
pickMedia(inputContentInfo.contentUri, inputContentInfo)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private fun sendStatus(preview: Boolean) {
|
|
enableButtons(false)
|
|
val tempText = binding.composeEditField.text.toString()
|
|
val contentText = if(viewModel.composeWithZwsp.value == true) {
|
|
tempText.composeWithZwsp()
|
|
} else {
|
|
tempText
|
|
}
|
|
|
|
var spoilerText = ""
|
|
if(viewModel.showContentWarning.value!!) {
|
|
spoilerText = binding.composeContentWarningField.text.toString()
|
|
}
|
|
val characterCount = calculateTextLength()
|
|
if((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) {
|
|
binding.composeEditField.error = getString(R.string.error_empty)
|
|
enableButtons(true)
|
|
} else if(characterCount <= maximumTootCharacters) {
|
|
if(viewModel.media.value!!.isNotEmpty()) {
|
|
finishingUploadDialog = ProgressDialog.show(
|
|
this, getString(R.string.dialog_title_finishing_media_upload),
|
|
getString(R.string.dialog_message_uploading_media), true, true
|
|
)
|
|
}
|
|
|
|
viewModel.sendStatus(contentText, spoilerText, preview).observeOnce(this) {
|
|
finishingUploadDialog?.dismiss()
|
|
if(!preview) {
|
|
deleteDraftAndFinish()
|
|
}
|
|
}
|
|
|
|
} else {
|
|
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
|
|
enableButtons(true)
|
|
}
|
|
}
|
|
|
|
override fun onRequestPermissionsResult(
|
|
requestCode: Int, permissions: Array<String>,
|
|
grantResults: IntArray
|
|
) {
|
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
if(requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
|
if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
initiateMediaPicking()
|
|
} else {
|
|
val bar = Snackbar.make(
|
|
binding.root, R.string.error_media_upload_permission,
|
|
Snackbar.LENGTH_SHORT
|
|
).apply {
|
|
|
|
}
|
|
bar.setAction(R.string.action_retry) { onMediaPick() }
|
|
//necessary so snackbar is shown over everything
|
|
bar.view.elevation =
|
|
resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
|
bar.show()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun initiateCameraApp() {
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
|
|
// We don't need to ask for permission in this case, because the used calls require
|
|
// android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was
|
|
// way before permission dialogues have been introduced.
|
|
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
|
if(intent.resolveActivity(packageManager) != null) {
|
|
val photoFile: File = try {
|
|
createNewImageFile(this)
|
|
} catch(ex: IOException) {
|
|
displayTransientError(R.string.error_media_upload_opening)
|
|
return
|
|
}
|
|
|
|
// Continue only if the File was successfully created
|
|
photoUploadUri = FileProvider.getUriForFile(
|
|
this,
|
|
BuildConfig.APPLICATION_ID + ".fileprovider",
|
|
photoFile
|
|
)
|
|
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
|
|
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
|
|
}
|
|
}
|
|
|
|
private fun initiateMediaPicking() {
|
|
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
|
|
|
if(!viewModel.hasNoAttachmentLimits) {
|
|
val mimeTypes = arrayOf("image/*", "video/*", "audio/*")
|
|
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
|
}
|
|
intent.type = "*/*"
|
|
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
startActivityForResult(intent, MEDIA_PICK_RESULT)
|
|
}
|
|
|
|
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
|
button.isEnabled = clickable
|
|
ThemeUtils.setDrawableTint(
|
|
this, button.drawable,
|
|
if(colorActive) android.R.attr.textColorTertiary
|
|
else R.attr.textColorDisabled
|
|
)
|
|
}
|
|
|
|
private fun enablePollButton(enable: Boolean) {
|
|
binding.addPollTextActionTextView.isEnabled = enable
|
|
val textColor = ThemeUtils.getColor(
|
|
this,
|
|
if(enable) android.R.attr.textColorTertiary
|
|
else R.attr.textColorDisabled
|
|
)
|
|
binding.addPollTextActionTextView.setTextColor(textColor)
|
|
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter =
|
|
PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
|
}
|
|
|
|
private fun removeMediaFromQueue(item: QueuedMedia) {
|
|
viewModel.removeMediaFromQueue(item)
|
|
}
|
|
|
|
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) {
|
|
// Single media, upload it and done.
|
|
pickMedia(intent.data!!)
|
|
} else if(intent.clipData != null) {
|
|
val clipData = intent.clipData!!
|
|
val count = clipData.itemCount
|
|
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 {
|
|
// if not grater then 4, upload all multiple media.
|
|
for(i in 0 until count) {
|
|
val imageUri = clipData.getItemAt(i).uri
|
|
pickMedia(imageUri)
|
|
}
|
|
}
|
|
}
|
|
} else if(resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
|
|
pickMedia(photoUploadUri!!)
|
|
}
|
|
}
|
|
|
|
private fun pickMedia(
|
|
uri: Uri,
|
|
contentInfoCompat: InputContentInfoCompat? = null,
|
|
filename: String? = null
|
|
) {
|
|
withLifecycleContext {
|
|
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver))
|
|
.observe { exceptionOrItem ->
|
|
contentInfoCompat?.releasePermission()
|
|
|
|
exceptionOrItem.asLeftOrNull()?.let {
|
|
val errorId = when(it) {
|
|
is VideoSizeException -> {
|
|
R.string.error_video_upload_size
|
|
}
|
|
is MediaSizeException -> {
|
|
R.string.error_media_upload_size
|
|
}
|
|
is AudioSizeException -> {
|
|
R.string.error_audio_upload_size
|
|
}
|
|
is VideoOrImageException -> {
|
|
R.string.error_media_upload_image_or_video
|
|
}
|
|
else -> {
|
|
Timber.d("That file could not be opened: $it")
|
|
R.string.error_media_upload_opening
|
|
}
|
|
}
|
|
displayTransientError(errorId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun showContentWarning(show: Boolean) {
|
|
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
|
|
@ColorInt val color = if(show) {
|
|
binding.composeContentWarningBar.show()
|
|
binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length)
|
|
binding.composeContentWarningField.requestFocus()
|
|
ContextCompat.getColor(this, R.color.tusky_blue)
|
|
} else {
|
|
binding.composeContentWarningBar.hide()
|
|
binding.composeEditField.requestFocus()
|
|
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
|
}
|
|
binding.composeContentWarningButton.drawable.colorFilter =
|
|
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
|
|
|
}
|
|
|
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
if(item.itemId == android.R.id.home) {
|
|
handleCloseButton()
|
|
return true
|
|
}
|
|
|
|
return super.onOptionsItemSelected(item)
|
|
}
|
|
|
|
override fun onBackPressed() {
|
|
// Acting like a teen: deliberately ignoring parent.
|
|
if(composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
|
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
|
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
|
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
|
stickerBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
|
previewBehavior.state == BottomSheetBehavior.STATE_EXPANDED
|
|
) {
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
|
|
return
|
|
}
|
|
|
|
handleCloseButton()
|
|
}
|
|
|
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
|
Timber.d(event.toString())
|
|
|
|
if(event.action == KeyEvent.ACTION_UP) {
|
|
if(event.isCtrlPressed) {
|
|
if(keyCode == KeyEvent.KEYCODE_ENTER) {
|
|
// send toot by pressing CTRL + ENTER
|
|
this.onSendClicked(false)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if(keyCode == KeyEvent.KEYCODE_BACK) {
|
|
onBackPressed()
|
|
return true
|
|
}
|
|
}
|
|
return super.onKeyDown(keyCode, event)
|
|
}
|
|
|
|
private fun handleCloseButton() {
|
|
val contentText = binding.composeEditField.text.toString()
|
|
val contentWarning = binding.composeContentWarningField.text.toString()
|
|
if(viewModel.didChange(contentText, contentWarning)) {
|
|
AlertDialog.Builder(this)
|
|
.setMessage(R.string.compose_save_draft)
|
|
.setPositiveButton(R.string.action_save) { _, _ ->
|
|
saveDraftAndFinish(contentText, contentWarning)
|
|
}
|
|
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
|
|
.show()
|
|
} else {
|
|
finishWithoutSlideOutAnimation()
|
|
}
|
|
}
|
|
|
|
private fun deleteDraftAndFinish() {
|
|
viewModel.deleteDraft()
|
|
finishWithoutSlideOutAnimation()
|
|
}
|
|
|
|
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
|
viewModel.saveDraft(contentText, contentWarning)
|
|
finishWithoutSlideOutAnimation()
|
|
}
|
|
|
|
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
|
return viewModel.searchAutocompleteSuggestions(token)
|
|
}
|
|
|
|
override fun onEmojiSelected(shortcode: String) {
|
|
replaceTextAtCaret(":$shortcode: ")
|
|
}
|
|
|
|
private fun setEmojiList(emojiList: List<Emoji>?) {
|
|
if(emojiList != null) {
|
|
binding.emojiView.adapter = EmojiAdapter(
|
|
emojiList,
|
|
this@ComposeActivity,
|
|
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
|
)
|
|
enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty())
|
|
}
|
|
}
|
|
|
|
private fun showStickers() {
|
|
if(stickerBehavior.state == BottomSheetBehavior.STATE_HIDDEN || stickerBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
}
|
|
|
|
override fun onEmojiSelected(id: String, shortcode: String) {
|
|
// pickMedia(Uri.parse(shbindingortcode))
|
|
|
|
Glide.with(this).asFile().load(shortcode).into(object : CustomTarget<File>() {
|
|
override fun onLoadCleared(placeholder: Drawable?) {
|
|
displayTransientError(R.string.error_sticker_fetch)
|
|
}
|
|
|
|
override fun onResourceReady(resource: File, transition: Transition<in File>?) {
|
|
val cut = shortcode.lastIndexOf('/')
|
|
val filename = if(cut != -1) shortcode.substring(cut + 1) else "unknown.png"
|
|
pickMedia(resource.toUri(), null, filename)
|
|
}
|
|
})
|
|
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
|
|
data class QueuedMedia(
|
|
val localId: Long,
|
|
val uri: Uri,
|
|
val type: Int,
|
|
val mediaSize: Long,
|
|
val originalFileName: String,
|
|
val noChanges: Boolean = false,
|
|
val anonymizeFileName: Boolean = false,
|
|
val uploadPercent: Int = 0,
|
|
val id: String? = null,
|
|
val description: String? = null
|
|
) {
|
|
companion object Type {
|
|
const val IMAGE: Int = 0
|
|
const val VIDEO: Int = 1
|
|
const val AUDIO: Int = 2
|
|
const val UNKNOWN: Int = 3
|
|
}
|
|
}
|
|
|
|
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) {
|
|
binding.composeScheduleView.onTimeSet(hourOfDay, minute)
|
|
viewModel.updateScheduledAt(binding.composeScheduleView.time)
|
|
if(verifyScheduledTime()) {
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
} else {
|
|
showScheduleView()
|
|
}
|
|
}
|
|
|
|
private fun resetSchedule() {
|
|
viewModel.updateScheduledAt(null)
|
|
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
}
|
|
|
|
@Parcelize
|
|
data class ComposeOptions(
|
|
// Let's keep fields var until all consumers are Kotlin
|
|
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,
|
|
var mentionedUsernames: Set<String>? = null,
|
|
var inReplyToId: String? = null,
|
|
var replyVisibility: Status.Visibility? = null,
|
|
var visibility: Status.Visibility? = null,
|
|
var contentWarning: String? = null,
|
|
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,
|
|
var formattingSyntax: String? = null,
|
|
var modifiedInitialState: Boolean? = null
|
|
) : Parcelable
|
|
|
|
companion object {
|
|
private const val MEDIA_PICK_RESULT = 1
|
|
private const val MEDIA_TAKE_PHOTO_RESULT = 2
|
|
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
|
|
|
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
|
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
|
|
|
|
// Mastodon only counts URLs as this long in terms of status character limits
|
|
@VisibleForTesting
|
|
const val MAXIMUM_URL_LENGTH = 23
|
|
|
|
@JvmStatic
|
|
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
|
return Intent(context, ComposeActivity::class.java).apply {
|
|
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
|
}
|
|
}
|
|
|
|
fun canHandleMimeType(mimeType: String?): Boolean {
|
|
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith(
|
|
"audio/"
|
|
) || mimeType == "text/plain")
|
|
}
|
|
}
|
|
}
|