chats: add media, stickers, emojis (wip)
This commit is contained in:
parent
31bb5daea1
commit
70c8012286
|
@ -2,7 +2,7 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 25,
|
||||
"identityHash": "8c97dfd1b3d04602e25139ec97d2a282",
|
||||
"identityHash": "ee8ddca7a73aef753951c2e2522cbb28",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "TootEntity",
|
||||
|
@ -296,7 +296,7 @@
|
|||
},
|
||||
{
|
||||
"tableName": "InstanceEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "instance",
|
||||
|
@ -333,6 +333,12 @@
|
|||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chatLimit",
|
||||
"columnName": "chatLimit",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -867,7 +873,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c97dfd1b3d04602e25139ec97d2a282')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee8ddca7a73aef753951c2e2522cbb28')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,18 +1,31 @@
|
|||
package com.keylesspalace.tusky.components.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.ViewTagActivity
|
||||
import com.keylesspalace.tusky.adapter.ChatMessagesAdapter
|
||||
import com.keylesspalace.tusky.adapter.ChatMessagesViewHolder
|
||||
import com.keylesspalace.tusky.adapter.TimelineAdapter
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Chat
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.interfaces.ChatActionListener
|
||||
|
@ -20,30 +33,55 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.repository.ChatMesssageOrPlaceholder
|
||||
import com.keylesspalace.tusky.repository.ChatRepository
|
||||
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
|
||||
import kotlinx.android.synthetic.main.activity_chat.*
|
||||
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
|
||||
import androidx.arch.core.util.Function
|
||||
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.lifecycle.Lifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.*
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
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.adapter.*
|
||||
import com.keylesspalace.tusky.appstore.*
|
||||
import com.keylesspalace.tusky.components.common.*
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
|
||||
import com.keylesspalace.tusky.repository.Placeholder
|
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode
|
||||
import com.keylesspalace.tusky.service.MessageToSend
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.util.*
|
||||
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.Observable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.android.synthetic.main.activity_chat.progressBar
|
||||
import kotlinx.android.synthetic.main.fragment_timeline.*
|
||||
import kotlinx.android.synthetic.main.activity_chat.*
|
||||
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.lang.Exception
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChatActivity: BottomSheetActivity(),
|
||||
Injectable, ChatActionListener {
|
||||
Injectable,
|
||||
ChatActionListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider,
|
||||
EmojiKeyboard.OnEmojiSelectedListener,
|
||||
OnEmojiSelectedListener,
|
||||
InputConnectionCompat.OnCommitContentListener {
|
||||
private val TAG = "ChatsActivity" // logging tag
|
||||
private val LOAD_AT_ONCE = 30
|
||||
|
||||
|
@ -55,6 +93,13 @@ class ChatActivity: BottomSheetActivity(),
|
|||
lateinit var chatsRepo: ChatRepository
|
||||
@Inject
|
||||
lateinit var serviceClient: ServiceClient
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@VisibleForTesting
|
||||
val viewModel: ChatViewModel by viewModels { viewModelFactory }
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
|
||||
lateinit var adapter: ChatMessagesAdapter
|
||||
|
||||
|
@ -64,10 +109,17 @@ class ChatActivity: BottomSheetActivity(),
|
|||
})
|
||||
|
||||
private var bottomLoading = false
|
||||
private var eventRegistered = false
|
||||
private var isNeedRefresh = false
|
||||
private var didLoadEverythingBottom = false
|
||||
private var initialUpdateFailed = false
|
||||
private var haveStickers = false
|
||||
|
||||
private lateinit var addMediaBehavior : BottomSheetBehavior<*>
|
||||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var stickerBehavior: BottomSheetBehavior<*>
|
||||
|
||||
private var finishingUploadDialog: ProgressDialog? = null
|
||||
private var photoUploadUri: Uri? = null
|
||||
|
||||
private enum class FetchEnd {
|
||||
TOP, BOTTOM, MIDDLE
|
||||
|
@ -130,46 +182,70 @@ class ChatActivity: BottomSheetActivity(),
|
|||
}
|
||||
|
||||
private lateinit var chatId : String
|
||||
private lateinit var avatarUrl : String
|
||||
private lateinit var displayName : String
|
||||
private lateinit var username : String
|
||||
private lateinit var emojis : ArrayList<Emoji>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val chatId = intent.getStringExtra(ID)
|
||||
val avatarUrl = intent.getStringExtra(AVATAR_URL)
|
||||
val displayName = intent.getStringExtra(DISPLAY_NAME)
|
||||
val username = intent.getStringExtra(USERNAME)
|
||||
val emojis = intent.getParcelableArrayListExtra<Emoji>(EMOJIS)
|
||||
|
||||
if(chatId == null || avatarUrl == null || displayName == null || username == null || emojis == null) {
|
||||
throw IllegalArgumentException("Can't open ChatActivity without chat id")
|
||||
}
|
||||
this.chatId = chatId
|
||||
|
||||
if(accountManager.activeAccount == null) {
|
||||
throw Exception("No active account!")
|
||||
}
|
||||
|
||||
chatId = intent.getStringExtra(ID) ?: throw IllegalArgumentException("Can't open ChatActivity without chatId")
|
||||
avatarUrl = intent.getStringExtra(AVATAR_URL) ?: throw IllegalArgumentException("Can't open ChatActivity without avatarUrl")
|
||||
displayName = intent.getStringExtra(DISPLAY_NAME) ?: throw IllegalArgumentException("Can't open ChatActivity without displayName")
|
||||
username = intent.getStringExtra(USERNAME) ?: throw IllegalArgumentException("Can't open ChatActivity without username")
|
||||
emojis = intent.getParcelableArrayListExtra<Emoji>(EMOJIS) ?: throw IllegalArgumentException("Can't open ChatActivity without emojis")
|
||||
|
||||
setContentView(R.layout.activity_chat)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
subscribeToUpdates()
|
||||
|
||||
setupHeader()
|
||||
setupChat()
|
||||
setupAttachment()
|
||||
setupComposeField(savedInstanceState?.getString(MESSAGE_KEY))
|
||||
setupButtons()
|
||||
|
||||
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event: Event? ->
|
||||
when(event) {
|
||||
is ChatMessageDeliveredEvent -> {
|
||||
onRefresh()
|
||||
enableButton(sendButton, true, true)
|
||||
enableButton(attachmentButton, true, true)
|
||||
enableButton(stickerButton, haveStickers, haveStickers)
|
||||
editText.text.clear()
|
||||
viewModel.media.value = listOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tryCache()
|
||||
}
|
||||
|
||||
private fun setupHeader() {
|
||||
supportActionBar?.run {
|
||||
title = ""
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
loadAvatar(avatarUrl, chatAvatar,
|
||||
resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),true)
|
||||
|
||||
loadAvatar(avatarUrl, chatAvatar, resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),true)
|
||||
chatTitle.text = displayName.emojify(emojis, chatTitle, true)
|
||||
chatUsername.text = username
|
||||
}
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
CardViewMode.NONE,
|
||||
private fun setupChat() {
|
||||
val statusDisplayOptions = StatusDisplayOptions(false,false,
|
||||
true, false, false, CardViewMode.NONE,
|
||||
false)
|
||||
|
||||
adapter = ChatMessagesAdapter(dataSource, this, statusDisplayOptions, accountManager.activeAccount!!.accountId)
|
||||
|
@ -181,34 +257,399 @@ class ChatActivity: BottomSheetActivity(),
|
|||
recycler.layoutManager = layoutManager
|
||||
// recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
|
||||
recycler.adapter = adapter
|
||||
}
|
||||
|
||||
private fun setupAttachment() {
|
||||
val onMediaPick = View.OnClickListener { view ->
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
viewModel.media.value?.get(0)?.let {
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> {
|
||||
makeCaptionDialog(it.description, it.uri) { newDescription ->
|
||||
viewModel.updateDescription(it.localId, newDescription)
|
||||
}
|
||||
}
|
||||
removeId -> viewModel.removeMediaFromQueue(it)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
imageAttachment.setOnClickListener(onMediaPick)
|
||||
textAttachment.setOnClickListener(onMediaPick)
|
||||
}
|
||||
|
||||
private fun setupComposeField(startingText: String?) {
|
||||
editText.setOnCommitContentListener(this)
|
||||
|
||||
editText.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
|
||||
editText.setAdapter(
|
||||
ComposeAutoCompleteAdapter(this))
|
||||
editText.setTokenizer(ComposeTokenizer())
|
||||
|
||||
editText.setText(startingText)
|
||||
editText.setSelection(editText.length())
|
||||
|
||||
val mentionColour = editText.linkTextColors.defaultColor
|
||||
highlightSpans(editText.text, mentionColour)
|
||||
editText.afterTextChanged { editable ->
|
||||
highlightSpans(editable, mentionColour)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
editText.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
pickMedia(inputContentInfo.contentUri, inputContentInfo)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun subscribeToUpdates() {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.chatLimit
|
||||
}
|
||||
viewModel.haveStickers.observe { haveStickers ->
|
||||
if (haveStickers) {
|
||||
stickerButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
viewModel.instanceStickers.observe { stickers ->
|
||||
if(stickers.isNotEmpty()) {
|
||||
haveStickers = true
|
||||
stickerButton.visibility = View.VISIBLE
|
||||
enableButton(stickerButton, true, true)
|
||||
stickerKeyboard.setupStickerKeyboard(this@ChatActivity, stickers)
|
||||
}
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
viewModel.media.observe {
|
||||
if(it.isNotEmpty()) {
|
||||
val media = it[0]
|
||||
|
||||
when(media.type) {
|
||||
ComposeActivity.QueuedMedia.UNKNOWN -> {
|
||||
textAttachment.visibility = View.VISIBLE
|
||||
imageAttachment.visibility = View.GONE
|
||||
|
||||
textAttachment.text = media.originalFileName
|
||||
textAttachment.setChecked(!media.description.isNullOrEmpty())
|
||||
textAttachment.setProgress(media.uploadPercent)
|
||||
}
|
||||
ComposeActivity.QueuedMedia.AUDIO -> {
|
||||
imageAttachment.visibility = View.VISIBLE
|
||||
textAttachment.visibility = View.GONE
|
||||
|
||||
imageAttachment.setChecked(!media.description.isNullOrEmpty())
|
||||
imageAttachment.setProgress(media.uploadPercent)
|
||||
imageAttachment.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||
}
|
||||
else -> {
|
||||
imageAttachment.visibility = View.VISIBLE
|
||||
textAttachment.visibility = View.GONE
|
||||
|
||||
imageAttachment.setChecked(!media.description.isNullOrEmpty())
|
||||
imageAttachment.setProgress(media.uploadPercent)
|
||||
|
||||
Glide.with(imageAttachment.context)
|
||||
.load(media.uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.dontAnimate()
|
||||
.into(imageAttachment)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageAttachment.visibility = View.GONE
|
||||
textAttachment.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setEmojiList(emojiList: List<Emoji>?) {
|
||||
if (emojiList != null) {
|
||||
emojiView.adapter = EmojiAdapter(emojiList, this@ChatActivity)
|
||||
enableButton(emojiButton, true, emojiList.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = editText.selectionStart.coerceAtMost(editText.selectionEnd)
|
||||
val end = editText.selectionStart.coerceAtLeast(editText.selectionEnd)
|
||||
val textToInsert = if (start > 0 && !editText.text[start - 1].isWhitespace()) {
|
||||
" $text"
|
||||
} else {
|
||||
text
|
||||
}
|
||||
editText.text.replace(start, end, textToInsert)
|
||||
|
||||
// Set the cursor after the inserted text
|
||||
editText.setSelection(start + text.length)
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(shortcode: String) {
|
||||
replaceTextAtCaret(":$shortcode: ")
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(id: String, shortcode: String) {
|
||||
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
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet)
|
||||
emojiBehavior = BottomSheetBehavior.from(emojiView)
|
||||
stickerBehavior = BottomSheetBehavior.from(stickerKeyboard)
|
||||
|
||||
sendButton.setOnClickListener {
|
||||
val media = viewModel.media.value?.get(0)
|
||||
|
||||
serviceClient.sendChatMessage( MessageToSend(
|
||||
editText.text.toString(),
|
||||
null,
|
||||
null,
|
||||
media?.id,
|
||||
media?.uri?.toString(),
|
||||
accountManager.activeAccount!!.id,
|
||||
this.chatId,
|
||||
0
|
||||
))
|
||||
|
||||
enableButton(sendButton, false, false)
|
||||
enableButton(attachmentButton, false, false)
|
||||
enableButton(stickerButton, false, false)
|
||||
}
|
||||
|
||||
if (!eventRegistered) {
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event: Event? ->
|
||||
when(event) {
|
||||
is ChatMessageDeliveredEvent -> {
|
||||
onRefresh()
|
||||
editText.text.clear()
|
||||
}
|
||||
attachmentButton.setOnClickListener { openPickDialog() }
|
||||
emojiButton.setOnClickListener { showEmojis() }
|
||||
stickerButton.setOnClickListener { showStickers() }
|
||||
|
||||
enableButton(stickerButton, false, false)
|
||||
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
|
||||
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 }
|
||||
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
|
||||
actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
}
|
||||
|
||||
private fun openPickDialog() {
|
||||
if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
} else {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmojis() {
|
||||
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
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
} else {
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showStickers() {
|
||||
if (stickerBehavior.state == BottomSheetBehavior.STATE_HIDDEN || stickerBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
stickerBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
} else {
|
||||
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
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 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@ChatActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this@ChatActivity,
|
||||
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
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
|
||||
grantResults: IntArray) {
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
initiateMediaPicking()
|
||||
} else {
|
||||
val bar = Snackbar.make(activityChat, 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 initiateMediaPicking() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
intent.type = "*/*" // Pleroma allows anything
|
||||
startActivityForResult(intent, MEDIA_PICK_RESULT)
|
||||
}
|
||||
|
||||
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) {
|
||||
pickMedia(intent.data!!)
|
||||
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
|
||||
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 pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
|
||||
|
||||
contentInfoCompat?.releasePermission()
|
||||
|
||||
if(exceptionOrItem.isLeft()) {
|
||||
val errorId = when (val exception = exceptionOrItem.asLeft()) {
|
||||
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 -> {
|
||||
Log.d(TAG, "That file could not be opened", exception)
|
||||
R.string.error_media_upload_opening
|
||||
}
|
||||
}
|
||||
eventRegistered = true
|
||||
displayTransientError(errorId)
|
||||
} else {
|
||||
enableButton(attachmentButton, false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tryCache()
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri)
|
||||
outState.putString(MESSAGE_KEY, editText.text.toString())
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(activityChat, 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 clearPlaceholdersForResponse(msgs: MutableList<ChatMesssageOrPlaceholder>) {
|
||||
|
@ -449,7 +890,6 @@ class ChatActivity: BottomSheetActivity(),
|
|||
}
|
||||
|
||||
private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) {
|
||||
topProgressBar.hide()
|
||||
if (fetchEnd == FetchEnd.MIDDLE && !msgs[position].isRight()) {
|
||||
var placeholder = msgs[position].asLeftOrNull()
|
||||
val newViewData: ChatMessageViewData
|
||||
|
@ -509,6 +949,19 @@ class ChatActivity: BottomSheetActivity(),
|
|||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Acting like a teen: deliberately ignoring parent.
|
||||
if (addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
stickerBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
|
@ -556,6 +1009,12 @@ class ChatActivity: BottomSheetActivity(),
|
|||
}
|
||||
|
||||
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
|
||||
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
|
||||
private const val MESSAGE_KEY = "MESSAGE"
|
||||
|
||||
fun getIntent(context: Context, chat: Chat) : Intent {
|
||||
val intent = Intent(context, ChatActivity::class.java)
|
||||
intent.putExtra(ID, chat.id)
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package com.keylesspalace.tusky.components.chat
|
||||
|
||||
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
|
||||
import com.keylesspalace.tusky.components.common.MediaUploader
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
open class ChatViewModel
|
||||
@Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val saveTootHelper: SaveTootHelper,
|
||||
private val db: AppDatabase
|
||||
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
|
||||
|
||||
}
|
|
@ -0,0 +1,377 @@
|
|||
/* Copyright 2019 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.common
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
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.search.SearchType
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceEntity
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.rxkotlin.Singles
|
||||
import retrofit2.Response
|
||||
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()
|
||||
|
||||
open class CommonComposeViewModel(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
protected val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
|
||||
protected val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
|
||||
protected val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
|
||||
val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
|
||||
var tryFetchStickers = false
|
||||
var hasNoAttachmentLimits = false
|
||||
|
||||
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
|
||||
ComposeInstanceParams(
|
||||
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
chatLimit = instance?.chatLimit ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
|
||||
val software = nodeinfo?.software?.name ?: "mastodon"
|
||||
|
||||
if(software.equals("pleroma")) {
|
||||
hasNoAttachmentLimits = true
|
||||
ComposeInstanceMetadata(
|
||||
software = "pleroma",
|
||||
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
|
||||
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
|
||||
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
|
||||
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
} else if(software.equals("pixelfed")) {
|
||||
ComposeInstanceMetadata(
|
||||
software = "pixelfed",
|
||||
supportsMarkdown = false,
|
||||
supportsBBcode = false,
|
||||
supportsHTML = false,
|
||||
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
} else {
|
||||
ComposeInstanceMetadata(
|
||||
software = "mastodon",
|
||||
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
|
||||
supportsBBcode = false,
|
||||
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
|
||||
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
}
|
||||
}
|
||||
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
|
||||
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
|
||||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
protected val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
|
||||
init {
|
||||
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.maxTootChars,
|
||||
maxPollOptions = instance.pollLimits?.maxOptions,
|
||||
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
|
||||
version = instance.version,
|
||||
chatLimit = instance.chatLimit
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
db.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.onErrorResumeNext(
|
||||
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
)
|
||||
.subscribe ({ instanceEntity ->
|
||||
emoji.postValue(instanceEntity.emojiList)
|
||||
instance.postValue(instanceEntity)
|
||||
}, { throwable ->
|
||||
// this can happen on network error when no cached data is available
|
||||
Log.w(TAG, "error loading instance data", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
|
||||
|
||||
api.getNodeinfoLinks().subscribe({
|
||||
links -> if(links.links.isNotEmpty()) {
|
||||
api.getNodeinfo(links.links[0].href).subscribe({
|
||||
ni -> nodeinfo.postValue(ni)
|
||||
}, {
|
||||
err -> Log.d(TAG, "Failed to get nodeinfo", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
}, { err ->
|
||||
Log.d(TAG, "Failed to get nodeinfo links", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri, filename: String?): 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>>()
|
||||
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
|
||||
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
|
||||
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (!hasNoAttachmentLimits
|
||||
&& type != QueuedMedia.Type.IMAGE
|
||||
&& mediaItems.isNotEmpty()
|
||||
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size, filename ?: "unknown")
|
||||
}
|
||||
}
|
||||
.subscribe({ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
}, { error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
})
|
||||
.autoDispose()
|
||||
return liveData
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String): QueuedMedia {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
|
||||
hasNoAttachmentLimits)
|
||||
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
|
||||
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem, videoLimit, imageLimit )
|
||||
.subscribe ({ event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.attachment.id, uploadPercent = -1)
|
||||
}
|
||||
synchronized(media) {
|
||||
val mediaValue = media.value!!
|
||||
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
|
||||
media.postValue(if (index == -1) {
|
||||
mediaValue + newMediaItem
|
||||
} else {
|
||||
mediaValue.toMutableList().also { it[index] = newMediaItem }
|
||||
})
|
||||
}
|
||||
}, { error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
})
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
protected fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
|
||||
hasNoAttachmentLimits, -1, id, description)
|
||||
media.value = media.value!! + mediaItem
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
|
||||
val newList = media.value!!.toMutableList()
|
||||
val index = newList.indexOfFirst { it.localId == localId }
|
||||
if (index != -1) {
|
||||
newList[index] = newList[index].copy(description = description)
|
||||
}
|
||||
media.value = newList
|
||||
val completedCaptioningLiveData = MutableLiveData<Boolean>()
|
||||
media.observeForever(object : Observer<List<QueuedMedia>> {
|
||||
override fun onChanged(mediaItems: List<QueuedMedia>) {
|
||||
val updatedItem = mediaItems.find { it.localId == localId }
|
||||
if (updatedItem == null) {
|
||||
media.removeObserver(this)
|
||||
} else if (updatedItem.id != null) {
|
||||
api.updateMedia(updatedItem.id, description)
|
||||
.subscribe({
|
||||
completedCaptioningLiveData.postValue(true)
|
||||
}, {
|
||||
completedCaptioningLiveData.postValue(false)
|
||||
})
|
||||
.autoDispose()
|
||||
media.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
|
||||
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun getStickers() {
|
||||
if(!tryFetchStickers)
|
||||
return
|
||||
|
||||
api.getStickers().subscribe({ stickers ->
|
||||
if (stickers.isNotEmpty()) {
|
||||
haveStickers.postValue(true)
|
||||
|
||||
val singles = mutableListOf<Single<Response<StickerPack>>>()
|
||||
|
||||
for(entry in stickers) {
|
||||
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
|
||||
singles += api.getStickerPack(url)
|
||||
}
|
||||
|
||||
Single.zip(singles) {
|
||||
it.map {
|
||||
it as Response<StickerPack>
|
||||
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
|
||||
it.body()!!
|
||||
}
|
||||
}.onErrorReturn {
|
||||
Log.d(TAG, "Failed to get sticker pack.json", it)
|
||||
emptyList()
|
||||
}.subscribe() { pack ->
|
||||
if(pack.isNotEmpty()) {
|
||||
val array = pack.toTypedArray()
|
||||
array.sort()
|
||||
this.stickers.postValue(array)
|
||||
}
|
||||
}.autoDispose()
|
||||
}
|
||||
}, {
|
||||
err -> Log.d(TAG, "Failed to get sticker.json", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun setup() {
|
||||
getStickers() // early as possible
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CCVM"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
|
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500
|
||||
const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
|
||||
const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val chatLimit: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
||||
|
||||
data class ComposeInstanceMetadata(
|
||||
val software: String,
|
||||
val supportsMarkdown: Boolean,
|
||||
val supportsBBcode: Boolean,
|
||||
val supportsHTML: Boolean,
|
||||
val videoLimit: Long,
|
||||
val imageLimit: Long
|
||||
)
|
|
@ -13,7 +13,7 @@
|
|||
* 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;
|
||||
package com.keylesspalace.tusky.components.common;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
|
@ -13,11 +13,13 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.components.common
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
|
@ -132,22 +134,22 @@ class MediaUploaderImpl(
|
|||
if (mediaSize > videoLimit) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
PreparedMedia(QueuedMedia.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
PreparedMedia(QueuedMedia.IMAGE, uri, mediaSize)
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > videoLimit) { // TODO: CHANGE!!11
|
||||
throw AudioSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
PreparedMedia(QueuedMedia.AUDIO, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
if (mediaSize > videoLimit) {
|
||||
throw MediaSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.UNKNOWN, uri, mediaSize)
|
||||
PreparedMedia(QueuedMedia.UNKNOWN, uri, mediaSize)
|
||||
// throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
|
@ -226,3 +228,27 @@ class MediaUploaderImpl(
|
|||
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
||||
}
|
||||
}
|
||||
|
||||
fun Uri.toFileName(contentResolver: ContentResolver? = null): String {
|
||||
var result: String = "unknown"
|
||||
|
||||
if(scheme.equals("content") && contentResolver != null) {
|
||||
val cursor = contentResolver.query(this, null, null, null, null)
|
||||
cursor?.use{
|
||||
if(it.moveToFirst()) {
|
||||
result = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(result.equals("unknown")) {
|
||||
path?.let {
|
||||
result = it
|
||||
val cut = result.lastIndexOf('/')
|
||||
if (cut != -1) {
|
||||
result = result.substring(cut + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
|
@ -23,7 +23,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
|
@ -53,7 +52,6 @@ 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.Observer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
|
@ -61,7 +59,6 @@ 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.target.SimpleTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
@ -72,6 +69,7 @@ import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
|||
import com.keylesspalace.tusky.adapter.EmojiAdapter
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||
import com.keylesspalace.tusky.appstore.*
|
||||
import com.keylesspalace.tusky.components.common.*
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||
|
@ -210,35 +208,6 @@ class ComposeActivity : BaseActivity(),
|
|||
}
|
||||
}
|
||||
|
||||
private fun uriToFilename(uri: Uri): String {
|
||||
var result: String = "unknown"
|
||||
if(uri.scheme.equals("content")) {
|
||||
val cursor = contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.let {
|
||||
try {
|
||||
if(cursor.moveToFirst()) {
|
||||
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
}
|
||||
}
|
||||
finally {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
if(result.equals("unknown")) {
|
||||
val path = uri.getPath()
|
||||
path?.let {
|
||||
result = path
|
||||
val cut = result.lastIndexOf('/')
|
||||
if (cut != -1) {
|
||||
result = result.substring(cut + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
/* Get incoming images being sent through a share action from another app. Only do this
|
||||
|
@ -1129,7 +1098,7 @@ class ComposeActivity : BaseActivity(),
|
|||
|
||||
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri, filename ?: uriToFilename(uri)).observe { exceptionOrItem ->
|
||||
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
|
||||
|
||||
contentInfoCompat?.releasePermission()
|
||||
|
||||
|
|
|
@ -22,6 +22,10 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
|
||||
import com.keylesspalace.tusky.components.common.MediaUploader
|
||||
import com.keylesspalace.tusky.components.common.UploadEvent
|
||||
import com.keylesspalace.tusky.components.common.mutableLiveData
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
|
@ -36,17 +40,10 @@ import com.keylesspalace.tusky.util.*
|
|||
import io.reactivex.Single
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.rxkotlin.Singles
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import retrofit2.Response
|
||||
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(
|
||||
private val api: MastodonApi,
|
||||
|
@ -55,7 +52,7 @@ class ComposeViewModel
|
|||
private val serviceClient: ServiceClient,
|
||||
private val saveTootHelper: SaveTootHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
|
@ -65,58 +62,8 @@ class ComposeViewModel
|
|||
private var inReplyToId: String? = null
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
private var contentWarningStateChanged: Boolean = false
|
||||
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
|
||||
private val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
|
||||
private val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
|
||||
public val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
|
||||
public var tryFetchStickers = false
|
||||
public var formattingSyntax: String = ""
|
||||
public var hasNoAttachmentLimits = false
|
||||
|
||||
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
|
||||
ComposeInstanceParams(
|
||||
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
|
||||
val software = nodeinfo?.software?.name ?: "mastodon"
|
||||
|
||||
if(software.equals("pleroma")) {
|
||||
hasNoAttachmentLimits = true
|
||||
ComposeInstanceMetadata(
|
||||
software = "pleroma",
|
||||
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
|
||||
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
|
||||
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
|
||||
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
} else if(software.equals("pixelfed")) {
|
||||
ComposeInstanceMetadata(
|
||||
software = "pixelfed",
|
||||
supportsMarkdown = false,
|
||||
supportsBBcode = false,
|
||||
supportsHTML = false,
|
||||
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
} else {
|
||||
ComposeInstanceMetadata(
|
||||
software = "mastodon",
|
||||
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
|
||||
supportsBBcode = false,
|
||||
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
|
||||
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
|
||||
imageLimit = STATUS_IMAGE_SIZE_LIMIT
|
||||
)
|
||||
}
|
||||
}
|
||||
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
|
||||
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
val markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
||||
|
@ -129,125 +76,6 @@ class ComposeViewModel
|
|||
val setupComplete = mutableLiveData(false)
|
||||
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
|
||||
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
|
||||
|
||||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
|
||||
|
||||
init {
|
||||
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.maxTootChars,
|
||||
maxPollOptions = instance.pollLimits?.maxOptions,
|
||||
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
|
||||
version = instance.version
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
db.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.onErrorResumeNext(
|
||||
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
)
|
||||
.subscribe ({ instanceEntity ->
|
||||
emoji.postValue(instanceEntity.emojiList)
|
||||
instance.postValue(instanceEntity)
|
||||
}, { throwable ->
|
||||
// this can happen on network error when no cached data is available
|
||||
Log.w(TAG, "error loading instance data", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
|
||||
|
||||
api.getNodeinfoLinks().subscribe({
|
||||
links -> if(links.links.isNotEmpty()) {
|
||||
api.getNodeinfo(links.links[0].href).subscribe({
|
||||
ni -> nodeinfo.postValue(ni)
|
||||
}, {
|
||||
err -> Log.d(TAG, "Failed to get nodeinfo", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
}, { err ->
|
||||
Log.d(TAG, "Failed to get nodeinfo links", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri, filename: String?): 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>>()
|
||||
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
|
||||
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
|
||||
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (!hasNoAttachmentLimits
|
||||
&& type != QueuedMedia.Type.IMAGE
|
||||
&& mediaItems.isNotEmpty()
|
||||
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size, filename ?: "unknown")
|
||||
}
|
||||
}
|
||||
.subscribe({ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
}, { error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
})
|
||||
.autoDispose()
|
||||
return liveData
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String): QueuedMedia {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
|
||||
hasNoAttachmentLimits)
|
||||
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
|
||||
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
|
||||
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem, videoLimit, imageLimit )
|
||||
.subscribe ({ event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.attachment.id, uploadPercent = -1)
|
||||
}
|
||||
synchronized(media) {
|
||||
val mediaValue = media.value!!
|
||||
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
|
||||
media.postValue(if (index == -1) {
|
||||
mediaValue + newMediaItem
|
||||
} else {
|
||||
mediaValue.toMutableList().also { it[index] = newMediaItem }
|
||||
})
|
||||
}
|
||||
}, { error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
})
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
|
||||
hasNoAttachmentLimits, -1, id, description)
|
||||
media.value = media.value!! + mediaItem
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
|
||||
|
@ -342,85 +170,6 @@ class ComposeViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
|
||||
val newList = media.value!!.toMutableList()
|
||||
val index = newList.indexOfFirst { it.localId == localId }
|
||||
if (index != -1) {
|
||||
newList[index] = newList[index].copy(description = description)
|
||||
}
|
||||
media.value = newList
|
||||
val completedCaptioningLiveData = MutableLiveData<Boolean>()
|
||||
media.observeForever(object : Observer<List<QueuedMedia>> {
|
||||
override fun onChanged(mediaItems: List<QueuedMedia>) {
|
||||
val updatedItem = mediaItems.find { it.localId == localId }
|
||||
if (updatedItem == null) {
|
||||
media.removeObserver(this)
|
||||
} else if (updatedItem.id != null) {
|
||||
api.updateMedia(updatedItem.id, description)
|
||||
.subscribe({
|
||||
completedCaptioningLiveData.postValue(true)
|
||||
}, {
|
||||
completedCaptioningLiveData.postValue(false)
|
||||
})
|
||||
.autoDispose()
|
||||
media.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
}
|
||||
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
|
||||
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
|
@ -428,45 +177,8 @@ class ComposeViewModel
|
|||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun getStickers() {
|
||||
if(!tryFetchStickers)
|
||||
return
|
||||
|
||||
api.getStickers().subscribe({ stickers ->
|
||||
if (stickers.isNotEmpty()) {
|
||||
haveStickers.postValue(true)
|
||||
|
||||
val singles = mutableListOf<Single<Response<StickerPack>>>()
|
||||
|
||||
for(entry in stickers) {
|
||||
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
|
||||
singles += api.getStickerPack(url)
|
||||
}
|
||||
|
||||
Single.zip(singles) {
|
||||
it.map {
|
||||
it as Response<StickerPack>
|
||||
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
|
||||
it.body()!!
|
||||
}
|
||||
}.onErrorReturn {
|
||||
Log.d(TAG, "Failed to get sticker pack.json", it)
|
||||
emptyList()
|
||||
}.subscribe() { pack ->
|
||||
if(pack.isNotEmpty()) {
|
||||
val array = pack.toTypedArray()
|
||||
array.sort()
|
||||
this.stickers.postValue(array)
|
||||
}
|
||||
}.autoDispose()
|
||||
}
|
||||
}, {
|
||||
err -> Log.d(TAG, "Failed to get sticker.json", err)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
getStickers() // early as possible
|
||||
super.setup()
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
|
@ -554,30 +266,4 @@ class ComposeViewModel
|
|||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
|
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
|
||||
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
||||
|
||||
data class ComposeInstanceMetadata(
|
||||
val software: String,
|
||||
val supportsMarkdown: Boolean,
|
||||
val supportsBBcode: Boolean,
|
||||
val supportsHTML: Boolean,
|
||||
val videoLimit: Long,
|
||||
val imageLimit: Long
|
||||
)
|
||||
}
|
|
@ -370,6 +370,7 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
"`attachment` TEXT," +
|
||||
"`emojis` TEXT NOT NULL," +
|
||||
"PRIMARY KEY (`localId`, `messageId`))");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `chatLimit` INTEGER");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -28,5 +28,6 @@ data class InstanceEntity(
|
|||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
val maxPollOptionLength: Int?,
|
||||
val version: String?
|
||||
val version: String?,
|
||||
val chatLimit: Int?
|
||||
)
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
package com.keylesspalace.tusky.di
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
|
||||
import com.keylesspalace.tusky.components.common.MediaUploader
|
||||
import com.keylesspalace.tusky.components.common.MediaUploaderImpl
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
|
@ -60,9 +60,9 @@ class NetworkModule {
|
|||
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(HttpLoggingInterceptor().apply {
|
||||
//level = HttpLoggingInterceptor.Level.BASIC
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
//level = HttpLoggingInterceptor.Level.HEADERS
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
//level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.keylesspalace.tusky.components.chat.ChatViewModel
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||
|
@ -85,5 +86,10 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(ScheduledTootViewModel::class)
|
||||
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ChatViewModel::class)
|
||||
internal abstract fun chatViewModel(viewModel: ChatViewModel) : ViewModel
|
||||
|
||||
//Add more ViewModels here
|
||||
}
|
|
@ -31,6 +31,11 @@ data class Instance (
|
|||
@SerializedName("max_toot_chars") val maxTootChars: Int?,
|
||||
@SerializedName("max_bio_chars") val maxBioChars: Int?,
|
||||
@SerializedName("poll_limits") val pollLimits: PollLimits?,
|
||||
@SerializedName("chat_limit") val chatLimit: Int?,
|
||||
@SerializedName("avatar_upload_limit") val avatarUploadLimit: Long?,
|
||||
@SerializedName("banner_upload_limit") val bannerUploadLimit: Long?,
|
||||
@SerializedName("description_limit") val descriptionLimit: Int?,
|
||||
@SerializedName("upload_limit") val uploadLimit: Long?,
|
||||
val pleroma: InstancePleroma?
|
||||
) {
|
||||
override fun hashCode(): Int {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/activityChat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
|
@ -54,10 +55,7 @@
|
|||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
tools:text="\@Entenhausen@birbsarecooooooooooool.site" />
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -91,7 +89,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/appbar"
|
||||
tools:visibility="visible"
|
||||
app:layout_constrainedHeight="true" />
|
||||
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/composeLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -105,98 +103,170 @@
|
|||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/attachmentLayout"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_margin="8dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
||||
android:id="@+id/imageAttachment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/attachmentLayout"
|
||||
android:layout_width="@dimen/compose_media_preview_size"
|
||||
android:layout_height="@dimen/compose_media_preview_size"
|
||||
android:layout_margin="@dimen/compose_media_preview_margin_bottom"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
||||
android:id="@+id/imageAttachment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
/>
|
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.ProgressTextView
|
||||
android:id="@+id/textAttachment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="-1"
|
||||
android:singleLine="true"
|
||||
android:textSize="?attr/status_text_small"
|
||||
tools:visibility="visible"
|
||||
/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/attachmentButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_add_media"
|
||||
android:tooltipText="@string/action_add_media"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:srcCompat="@drawable/ic_attach_file_24dp" />
|
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.EditTextTyped
|
||||
android:id="@+id/editText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:singleLine="false"
|
||||
android:background="@null"
|
||||
android:completionThreshold="2"
|
||||
android:dropDownWidth="wrap_content"
|
||||
android:hint="@string/hint_compose"
|
||||
android:inputType="text|textMultiLine|textCapSentences"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
android:textColorHint="?android:attr/textColorTertiary"
|
||||
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
|
||||
app:layout_constraintRight_toLeftOf="@id/emojiButton"
|
||||
app:layout_constraintStart_toEndOf="@+id/attachmentButton"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
tools:text="Just landed in L.A." />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/emojiButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_emoji_keyboard"
|
||||
android:tooltipText="@string/action_emoji_keyboard"
|
||||
app:srcCompat="@drawable/ic_emoji_24dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:layout_constraintRight_toLeftOf="@id/stickerButton"
|
||||
/>
|
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.ProgressTextView
|
||||
android:id="@+id/textAttachment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="-1"
|
||||
android:singleLine="true"
|
||||
android:textSize="?attr/status_text_small"
|
||||
tools:visibility="visible"
|
||||
<ImageButton
|
||||
android:id="@+id/stickerButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_sticker"
|
||||
android:tooltipText="@string/action_sticker"
|
||||
app:srcCompat="@drawable/ic_sticker"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:layout_constraintRight_toLeftOf="@id/sendButton"
|
||||
/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/attachmentButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_add_media"
|
||||
android:tooltipText="@string/action_add_media"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:srcCompat="@drawable/ic_attach_file_24dp" />
|
||||
|
||||
<androidx.emoji.widget.EmojiEditText
|
||||
android:id="@+id/editText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:singleLine="false"
|
||||
android:background="@null"
|
||||
android:hint="@string/hint_compose"
|
||||
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
|
||||
app:layout_constraintRight_toLeftOf="@id/emojiButton"
|
||||
app:layout_constraintStart_toEndOf="@+id/attachmentButton"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
tools:text="Just landed in L.A." />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/emojiButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_emoji_keyboard"
|
||||
android:tooltipText="@string/action_emoji_keyboard"
|
||||
app:srcCompat="@drawable/ic_emoji_24dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:layout_constraintRight_toLeftOf="@id/sendButton"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/sendButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_send"
|
||||
android:tooltipText="@string/action_send"
|
||||
app:srcCompat="@drawable/ic_send_24dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
/>
|
||||
<ImageButton
|
||||
android:id="@+id/sendButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/action_send"
|
||||
android:tooltipText="@string/action_send"
|
||||
app:srcCompat="@drawable/ic_send_24dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/emojiView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
android:background="?attr/colorSurface"
|
||||
android:clipToPadding="false"
|
||||
android:elevation="12dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="@dimen/compose_activity_bottom_bar_height"
|
||||
app:behavior_hideable="true"
|
||||
app:behavior_peekHeight="0dp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<com.keylesspalace.tusky.view.EmojiKeyboard
|
||||
android:id="@+id/stickerKeyboard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:background="?attr/colorSurface"
|
||||
android:elevation="12dp"
|
||||
android:paddingBottom="@dimen/compose_activity_bottom_bar_height"
|
||||
app:behavior_hideable="true"
|
||||
app:behavior_peekHeight="0dp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/addMediaBottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
android:elevation="12dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="52dp"
|
||||
app:behavior_hideable="true"
|
||||
app:behavior_peekHeight="0dp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/actionPhotoTake"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="8dp"
|
||||
android:padding="8dp"
|
||||
android:text="@string/action_photo_take"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/actionPhotoPick"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="8dp"
|
||||
android:padding="8dp"
|
||||
android:text="@string/action_add_media"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -23,7 +23,7 @@ import com.keylesspalace.tusky.appstore.*
|
|||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||
import com.keylesspalace.tusky.components.common.MediaUploader
|
||||
import com.keylesspalace.tusky.db.*
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
|
@ -42,7 +42,6 @@ import org.mockito.Mockito.mock
|
|||
import org.robolectric.Robolectric
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.fakes.RoboMenuItem
|
||||
import java.lang.Math.pow
|
||||
|
||||
/**
|
||||
* Created by charlag on 3/7/18.
|
||||
|
|
Loading…
Reference in New Issue