Fix animated emojis in composable views

This commit is contained in:
Adolfo Santiago 2022-05-21 11:29:36 +02:00
parent a26c078731
commit 81abedb89b
No known key found for this signature in database
GPG key ID: 244D6F9A317B4A65
4 changed files with 451 additions and 276 deletions

View file

@ -15,37 +15,47 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.github.penfeizhou.animation.glide.AnimationDecoderOption
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import java.util.*
class EmojiAdapter(emojiList: List<Emoji>, private val onEmojiSelectedListener: OnEmojiSelectedListener) : RecyclerView.Adapter<EmojiAdapter.EmojiHolder>() { class EmojiAdapter(
private val emojiList : List<Emoji> emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener,
private val animateEmojis: Boolean
) : RecyclerView.Adapter<EmojiAdapter.EmojiHolder>() {
private val emojis: List<Emoji>
init { init {
this.emojiList = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } this.emojis =
.sortedBy { it.shortcode.toLowerCase(Locale.ROOT) } emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase() }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return emojiList.size return emojis.size
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji_button, parent, false) as ImageView val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_emoji_button, parent, false) as ImageView
return EmojiHolder(view) return EmojiHolder(view)
} }
override fun onBindViewHolder(viewHolder: EmojiHolder, position: Int) { override fun onBindViewHolder(viewHolder: EmojiHolder, position: Int) {
val emoji = emojiList[position] val emoji = emojis[position]
Glide.with(viewHolder.emojiImageView) Glide.with(viewHolder.emojiImageView)
.load(emoji.url) .load(emoji.url)
.set(AnimationDecoderOption.DISABLE_ANIMATION_GIF_DECODER, !animateEmojis)
.set(AnimationDecoderOption.DISABLE_ANIMATION_WEBP_DECODER, !animateEmojis)
.set(AnimationDecoderOption.DISABLE_ANIMATION_APNG_DECODER, !animateEmojis)
.into(viewHolder.emojiImageView) .into(viewHolder.emojiImageView)
viewHolder.emojiImageView.setOnClickListener { viewHolder.emojiImageView.setOnClickListener {

View file

@ -55,6 +55,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory } private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory }
private lateinit var preferences: SharedPreferences
private lateinit var adapter: AnnouncementAdapter private lateinit var adapter: AnnouncementAdapter
private val picker by lazy { EmojiPicker(this) } private val picker by lazy { EmojiPicker(this) }
@ -89,7 +90,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
announcementsList.addItemDecoration(divider) announcementsList.addItemDecoration(divider)
val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false)
adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled) adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled)
@ -128,7 +129,11 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
viewModel.emojis.observe(this) { viewModel.emojis.observe(this) {
it?.let { list -> it?.let { list ->
picker.adapter = EmojiAdapter(list, this) picker.adapter = EmojiAdapter(
list,
this,
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
} }
} }

View file

@ -5,13 +5,13 @@ import android.app.Activity
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@ -21,15 +21,6 @@ import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
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
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 androidx.arch.core.util.Function import androidx.arch.core.util.Function
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
@ -41,28 +32,70 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.* import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListUpdateCallback
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.* import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.adapter.* import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.common.* import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.ChatMessagesAdapter
import com.keylesspalace.tusky.adapter.ChatMessagesViewHolder
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.adapter.TimelineAdapter
import com.keylesspalace.tusky.appstore.ChatMessageDeliveredEvent
import com.keylesspalace.tusky.appstore.ChatMessageReceivedEvent
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
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.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.ChatMesssageOrPlaceholder
import com.keylesspalace.tusky.repository.ChatRepository
import com.keylesspalace.tusky.repository.Placeholder import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.service.MessageToSend import com.keylesspalace.tusky.service.MessageToSend
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.PairedList
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.ViewDataUtils
import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.withLifecycleContext
import com.keylesspalace.tusky.view.EmojiKeyboard import com.keylesspalace.tusky.view.EmojiKeyboard
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -70,13 +103,32 @@ import com.mikepenz.iconics.utils.sizeDp
import com.uber.autodispose.android.lifecycle.autoDispose import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_chat.*
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.lang.Exception
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import kotlinx.android.synthetic.main.activity_chat.actionPhotoPick
import kotlinx.android.synthetic.main.activity_chat.actionPhotoTake
import kotlinx.android.synthetic.main.activity_chat.activityChat
import kotlinx.android.synthetic.main.activity_chat.addMediaBottomSheet
import kotlinx.android.synthetic.main.activity_chat.attachmentButton
import kotlinx.android.synthetic.main.activity_chat.attachmentLayout
import kotlinx.android.synthetic.main.activity_chat.chatAvatar
import kotlinx.android.synthetic.main.activity_chat.chatTitle
import kotlinx.android.synthetic.main.activity_chat.chatUsername
import kotlinx.android.synthetic.main.activity_chat.editText
import kotlinx.android.synthetic.main.activity_chat.emojiButton
import kotlinx.android.synthetic.main.activity_chat.emojiView
import kotlinx.android.synthetic.main.activity_chat.imageAttachment
import kotlinx.android.synthetic.main.activity_chat.messageView
import kotlinx.android.synthetic.main.activity_chat.progressBar
import kotlinx.android.synthetic.main.activity_chat.recycler
import kotlinx.android.synthetic.main.activity_chat.sendButton
import kotlinx.android.synthetic.main.activity_chat.stickerButton
import kotlinx.android.synthetic.main.activity_chat.stickerKeyboard
import kotlinx.android.synthetic.main.activity_chat.textAttachment
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import timber.log.Timber
class ChatActivity : BottomSheetActivity(), class ChatActivity : BottomSheetActivity(),
Injectable, Injectable,
@ -85,30 +137,36 @@ class ChatActivity: BottomSheetActivity(),
EmojiKeyboard.OnEmojiSelectedListener, EmojiKeyboard.OnEmojiSelectedListener,
OnEmojiSelectedListener, OnEmojiSelectedListener,
InputConnectionCompat.OnCommitContentListener { InputConnectionCompat.OnCommitContentListener {
private val TAG = "ChatsActivity" // logging tag
private val LOAD_AT_ONCE = 30 private val LOAD_AT_ONCE = 30
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject @Inject
lateinit var api: MastodonApi lateinit var api: MastodonApi
@Inject @Inject
lateinit var chatsRepo: ChatRepository lateinit var chatsRepo: ChatRepository
@Inject @Inject
lateinit var serviceClient: ServiceClient lateinit var serviceClient: ServiceClient
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@VisibleForTesting @VisibleForTesting
val viewModel: ChatViewModel by viewModels { viewModelFactory } val viewModel: ChatViewModel by viewModels { viewModelFactory }
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
lateinit var adapter: ChatMessagesAdapter lateinit var adapter: ChatMessagesAdapter
private val msgs = PairedList<ChatMesssageOrPlaceholder, ChatMessageViewData?>(Function<ChatMesssageOrPlaceholder, ChatMessageViewData?> { input -> private val msgs =
input.asRightOrNull()?.let(ViewDataUtils::chatMessageToViewData) ?: PairedList<ChatMesssageOrPlaceholder, ChatMessageViewData?>(Function<ChatMesssageOrPlaceholder, ChatMessageViewData?> { input ->
ChatMessageViewData.Placeholder(input.asLeft().id, false) input.asRightOrNull()?.let(ViewDataUtils::chatMessageToViewData)
?: ChatMessageViewData.Placeholder(input.asLeft().id, false)
}) })
private var bottomLoading = false private var bottomLoading = false
@ -130,7 +188,7 @@ class ChatActivity: BottomSheetActivity(),
private val listUpdateCallback = object : ListUpdateCallback { private val listUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
Log.d(TAG, "onInserted") Timber.d("onInserted")
adapter.notifyItemRangeInserted(position, count) adapter.notifyItemRangeInserted(position, count)
if(position == 0) { if(position == 0) {
recycler.scrollToPosition(0) recycler.scrollToPosition(0)
@ -138,31 +196,40 @@ class ChatActivity: BottomSheetActivity(),
} }
override fun onRemoved(position: Int, count: Int) { override fun onRemoved(position: Int, count: Int) {
Log.d(TAG, "onRemoved") Timber.d("onRemoved")
adapter.notifyItemRangeRemoved(position, count) adapter.notifyItemRangeRemoved(position, count)
} }
override fun onMoved(fromPosition: Int, toPosition: Int) { override fun onMoved(fromPosition: Int, toPosition: Int) {
Log.d(TAG, "onMoved") Timber.d("onMoved")
adapter.notifyItemMoved(fromPosition, toPosition) adapter.notifyItemMoved(fromPosition, toPosition)
} }
override fun onChanged(position: Int, count: Int, payload: Any?) { override fun onChanged(position: Int, count: Int, payload: Any?) {
Log.d(TAG, "onChanged") Timber.d("onChanged")
adapter.notifyItemRangeChanged(position, count, payload) adapter.notifyItemRangeChanged(position, count, payload)
} }
} }
private val diffCallback = object : DiffUtil.ItemCallback<ChatMessageViewData>() { private val diffCallback = object : DiffUtil.ItemCallback<ChatMessageViewData>() {
override fun areItemsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean { override fun areItemsTheSame(
oldItem: ChatMessageViewData,
newItem: ChatMessageViewData
): Boolean {
return oldItem.getViewDataId() == newItem.getViewDataId() return oldItem.getViewDataId() == newItem.getViewDataId()
} }
override fun areContentsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean { override fun areContentsTheSame(
oldItem: ChatMessageViewData,
newItem: ChatMessageViewData
): Boolean {
return false // Items are different always. It allows to refresh timestamp on every view holder update return false // Items are different always. It allows to refresh timestamp on every view holder update
} }
override fun getChangePayload(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Any? { override fun getChangePayload(
oldItem: ChatMessageViewData,
newItem: ChatMessageViewData
): Any? {
return if(oldItem.deepEquals(newItem)) { return if(oldItem.deepEquals(newItem)) {
//If items are equal - update timestamp only //If items are equal - update timestamp only
listOf(ChatMessagesViewHolder.Key.KEY_CREATED) listOf(ChatMessagesViewHolder.Key.KEY_CREATED)
@ -171,8 +238,10 @@ class ChatActivity: BottomSheetActivity(),
} }
} }
private val differ = AsyncListDiffer(listUpdateCallback, private val differ = AsyncListDiffer(
AsyncDifferConfig.Builder(diffCallback).build()) listUpdateCallback,
AsyncDifferConfig.Builder(diffCallback).build()
)
private val dataSource = object : TimelineAdapter.AdapterDataSource<ChatMessageViewData> { private val dataSource = object : TimelineAdapter.AdapterDataSource<ChatMessageViewData> {
override fun getItemCount(): Int { override fun getItemCount(): Int {
@ -190,6 +259,8 @@ class ChatActivity: BottomSheetActivity(),
private lateinit var username: String private lateinit var username: String
private lateinit var emojis: ArrayList<Emoji> private lateinit var emojis: ArrayList<Emoji>
private lateinit var preferences: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -197,18 +268,23 @@ class ChatActivity: BottomSheetActivity(),
throw Exception("No active account!") throw Exception("No active account!")
} }
chatId = intent.getStringExtra(ID) ?: throw IllegalArgumentException("Can't open ChatActivity without chatId") chatId = intent.getStringExtra(ID)
avatarUrl = intent.getStringExtra(AVATAR_URL) ?: throw IllegalArgumentException("Can't open ChatActivity without avatarUrl") ?: throw IllegalArgumentException("Can't open ChatActivity without chatId")
displayName = intent.getStringExtra(DISPLAY_NAME) ?: throw IllegalArgumentException("Can't open ChatActivity without displayName") avatarUrl = intent.getStringExtra(AVATAR_URL)
username = intent.getStringExtra(USERNAME) ?: throw IllegalArgumentException("Can't open ChatActivity without username") ?: throw IllegalArgumentException("Can't open ChatActivity without avatarUrl")
emojis = intent.getParcelableArrayListExtra<Emoji>(EMOJIS) ?: throw IllegalArgumentException("Can't open ChatActivity without emojis") 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) setContentView(R.layout.activity_chat)
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
subscribeToUpdates() subscribeToUpdates()
val preferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
viewModel.tryFetchStickers = preferences.getBoolean(PrefKeys.STICKERS, false) viewModel.tryFetchStickers = preferences.getBoolean(PrefKeys.STICKERS, false)
viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false) viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false)
@ -254,7 +330,12 @@ class ChatActivity: BottomSheetActivity(),
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(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) chatTitle.text = displayName.emojify(emojis, chatTitle, true)
chatUsername.text = username chatUsername.text = username
} }
@ -306,7 +387,8 @@ class ChatActivity: BottomSheetActivity(),
editText.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } editText.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
editText.setAdapter( editText.setAdapter(
ComposeAutoCompleteAdapter(this)) ComposeAutoCompleteAdapter(this)
)
editText.setTokenizer(ComposeTokenizer()) editText.setTokenizer(ComposeTokenizer())
editText.setText(startingText) editText.setText(startingText)
@ -321,7 +403,8 @@ class ChatActivity: BottomSheetActivity(),
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093 // work around Android platform bug -> https://issuetracker.google.com/issues/67102093
if(Build.VERSION.SDK_INT == Build.VERSION_CODES.O if(Build.VERSION.SDK_INT == Build.VERSION_CODES.O
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
) {
editText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) editText.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
} }
} }
@ -342,17 +425,22 @@ class ChatActivity: BottomSheetActivity(),
} }
/** This is for the fancy keyboards which can insert images and stuff. */ /** This is for the fancy keyboards which can insert images and stuff. */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean { override fun onCommitContent(
inputContentInfo: InputContentInfoCompat,
flags: Int,
opts: Bundle?
): Boolean {
// Verify the returned content's type is of the correct MIME type // Verify the returned content's type is of the correct MIME type
val supported = inputContentInfo.description.hasMimeType("image/*") val supported = inputContentInfo.description.hasMimeType("image/*")
if(supported) { if(supported) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 val lacksPermission =
(flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if(lacksPermission) { if(lacksPermission) {
try { try {
inputContentInfo.requestPermission() inputContentInfo.requestPermission()
} catch(e: Exception) { } catch(e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message) Timber.e("InputContentInfoCompat#requestPermission() failed: ${e.message}")
return false return false
} }
} }
@ -382,7 +470,11 @@ class ChatActivity: BottomSheetActivity(),
enableSendButton() enableSendButton()
enableButton(attachmentButton, notHaveMedia, notHaveMedia) enableButton(attachmentButton, notHaveMedia, notHaveMedia)
enableButton(stickerButton, haveStickers && notHaveMedia, haveStickers && notHaveMedia) enableButton(
stickerButton,
haveStickers && notHaveMedia,
haveStickers && notHaveMedia
)
if(!notHaveMedia) { if(!notHaveMedia) {
val media = it[0] val media = it[0]
@ -432,7 +524,11 @@ class ChatActivity: BottomSheetActivity(),
private fun setEmojiList(emojiList: List<Emoji>?) { private fun setEmojiList(emojiList: List<Emoji>?) {
if(emojiList != null) { if(emojiList != null) {
emojiView.adapter = EmojiAdapter(emojiList, this@ChatActivity) emojiView.adapter = EmojiAdapter(
emojiList,
this@ChatActivity,
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
enableButton(emojiButton, true, emojiList.isNotEmpty()) enableButton(emojiButton, true, emojiList.isNotEmpty())
} }
} }
@ -492,10 +588,19 @@ class ChatActivity: BottomSheetActivity(),
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 } val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply {
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) colorInt = textColor; sizeDp = 18
}
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(
cameraIcon,
null,
null,
null
)
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 } val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply {
colorInt = textColor; sizeDp = 18
}
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
actionPhotoTake.setOnClickListener { initiateCameraApp() } actionPhotoTake.setOnClickListener { initiateCameraApp() }
@ -505,14 +610,16 @@ class ChatActivity: BottomSheetActivity(),
private fun onSendClicked() { private fun onSendClicked() {
val media = viewModel.getSingleMedia() val media = viewModel.getSingleMedia()
serviceClient.sendChatMessage(MessageToSend( serviceClient.sendChatMessage(
MessageToSend(
editText.text.toString(), editText.text.toString(),
media?.id, media?.id,
media?.uri?.toString(), media?.uri?.toString(),
accountManager.activeAccount!!.id, accountManager.activeAccount!!.id,
this.chatId, this.chatId,
0 0
)) )
)
sending = true sending = true
editText.text.clear() editText.text.clear()
@ -535,7 +642,10 @@ class ChatActivity: BottomSheetActivity(),
private fun showEmojis() { private fun showEmojis() {
emojiView.adapter?.let { emojiView.adapter?.let {
if(it.itemCount == 0) { if(it.itemCount == 0) {
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) val errorMessage = getString(
R.string.error_no_custom_emojis,
accountManager.activeAccount!!.domain
)
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
} else { } else {
if(emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { if(emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
@ -575,9 +685,11 @@ class ChatActivity: BottomSheetActivity(),
} }
// Continue only if the File was successfully created // Continue only if the File was successfully created
photoUploadUri = FileProvider.getUriForFile(this, photoUploadUri = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile) photoFile
)
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
} }
@ -589,10 +701,16 @@ class ChatActivity: BottomSheetActivity(),
//Wait until bottom sheet is not collapsed and show next screen after //Wait until bottom sheet is not collapsed and show next screen after
if(newState == BottomSheetBehavior.STATE_COLLAPSED) { if(newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this) addMediaBehavior.removeBottomSheetCallback(this)
if (ContextCompat.checkSelfPermission(this@ChatActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { if(ContextCompat.checkSelfPermission(
ActivityCompat.requestPermissions(this@ChatActivity, this@ChatActivity,
Manifest.permission.READ_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this@ChatActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else { } else {
initiateMediaPicking() initiateMediaPicking()
} }
@ -604,19 +722,24 @@ class ChatActivity: BottomSheetActivity(),
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, override fun onRequestPermissionsResult(
grantResults: IntArray) { requestCode: Int, permissions: Array<String>,
grantResults: IntArray
) {
if(requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { if(requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking() initiateMediaPicking()
} else { } else {
val bar = Snackbar.make(activityChat, R.string.error_media_upload_permission, val bar = Snackbar.make(
Snackbar.LENGTH_SHORT).apply { activityChat, R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
} }
bar.setAction(R.string.action_retry) { onMediaPick() } bar.setAction(R.string.action_retry) { onMediaPick() }
//necessary so snackbar is shown over everything //necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.view.elevation =
resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show() bar.show()
} }
} }
@ -641,14 +764,21 @@ class ChatActivity: BottomSheetActivity(),
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable button.isEnabled = clickable
ThemeUtils.setDrawableTint(this, button.drawable, ThemeUtils.setDrawableTint(
this, button.drawable,
if(colorActive) android.R.attr.textColorTertiary if(colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled) else R.attr.textColorDisabled
)
} }
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) { private fun pickMedia(
uri: Uri,
contentInfoCompat: InputContentInfoCompat? = null,
filename: String? = null
) {
withLifecycleContext { withLifecycleContext {
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem -> viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver))
.observe { exceptionOrItem ->
contentInfoCompat?.releasePermission() contentInfoCompat?.releasePermission()
@ -667,7 +797,7 @@ class ChatActivity: BottomSheetActivity(),
R.string.error_media_upload_image_or_video R.string.error_media_upload_image_or_video
} }
else -> { else -> {
Log.d(TAG, "That file could not be opened", exception) Timber.e("That file could not be opened", exception)
R.string.error_media_upload_opening R.string.error_media_upload_opening
} }
} }
@ -721,7 +851,14 @@ class ChatActivity: BottomSheetActivity(),
} }
val topId = msgs.first { it.isRight() }.asRight().id val topId = msgs.first { it.isRight() }.asRight().id
chatsRepo.getChatMessages(chatId, topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK) chatsRepo.getChatMessages(
chatId,
topId,
null,
null,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ messages -> .subscribe({ messages ->
@ -776,9 +913,11 @@ class ChatActivity: BottomSheetActivity(),
} }
} }
private fun sendFetchMessagesRequest(maxId: String?, sinceId: String?, private fun sendFetchMessagesRequest(
maxId: String?, sinceId: String?,
sinceIdMinusOne: String?, sinceIdMinusOne: String?,
fetchEnd: FetchEnd, pos: Int) { fetchEnd: FetchEnd, pos: Int
) {
// allow getting old statuses/fallbacks for network only for for bottom loading // allow getting old statuses/fallbacks for network only for for bottom loading
val mode = if(fetchEnd == FetchEnd.BOTTOM) { val mode = if(fetchEnd == FetchEnd.BOTTOM) {
TimelineRequestMode.ANY TimelineRequestMode.ANY
@ -793,11 +932,14 @@ class ChatActivity: BottomSheetActivity(),
} }
private fun updateAdapter() { private fun updateAdapter() {
Log.d(TAG, "updateAdapter") Timber.d("updateAdapter")
differ.submitList(msgs.pairedCopy) differ.submitList(msgs.pairedCopy)
} }
private fun updateMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>, fullFetch: Boolean) { private fun updateMessages(
newMsgs: MutableList<ChatMesssageOrPlaceholder>,
fullFetch: Boolean
) {
if(newMsgs.isEmpty()) { if(newMsgs.isEmpty()) {
updateAdapter() updateAdapter()
return return
@ -836,8 +978,10 @@ class ChatActivity: BottomSheetActivity(),
} }
} }
private fun replacePlaceholderWithMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>, private fun replacePlaceholderWithMessages(
fullFetch: Boolean, pos: Int) { newMsgs: MutableList<ChatMesssageOrPlaceholder>,
fullFetch: Boolean, pos: Int
) {
val placeholder = msgs[pos] val placeholder = msgs[pos]
if(placeholder.isLeft()) { if(placeholder.isLeft()) {
msgs.removeAt(pos) msgs.removeAt(pos)
@ -869,8 +1013,10 @@ class ChatActivity: BottomSheetActivity(),
} }
} }
private fun onFetchTimelineSuccess(msgs: MutableList<ChatMesssageOrPlaceholder>, private fun onFetchTimelineSuccess(
fetchEnd: FetchEnd, pos: Int) { msgs: MutableList<ChatMesssageOrPlaceholder>,
fetchEnd: FetchEnd, pos: Int
) {
// We filled the hole (or reached the end) if the server returned less statuses than we // We filled the hole (or reached the end) if the server returned less statuses than we
// we asked for. // we asked for.
@ -886,9 +1032,9 @@ class ChatActivity: BottomSheetActivity(),
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ .subscribe({
Log.d(TAG, "Marked new messages as read up to ${msgs[last].asRight().id}") Timber.d("Marked new messages as read up to ${msgs[last].asRight().id}")
}, { }, {
Log.d(TAG, "Failed to mark messages as read", it) Timber.e("Failed to mark messages as read", it)
}) })
} }
FetchEnd.MIDDLE -> { FetchEnd.MIDDLE -> {
@ -964,7 +1110,7 @@ class ChatActivity: BottomSheetActivity(),
} }
} }
} }
Log.e(TAG, "Fetch Failure: " + exception.message) Timber.e("Fetch Failure: " + exception.message)
updateBottomLoadingState(fetchEnd) updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
} }
@ -981,25 +1127,28 @@ class ChatActivity: BottomSheetActivity(),
val fromChat = msgs[position - 1].asRightOrNull() val fromChat = msgs[position - 1].asRightOrNull()
val toChat = msgs[position + 1].asRightOrNull() val toChat = msgs[position + 1].asRightOrNull()
if(fromChat == null || toChat == null) { if(fromChat == null || toChat == null) {
Log.e(TAG, "Failed to load more at $position, wrong placeholder position") Timber.e("Failed to load more at $position, wrong placeholder position")
return return
} }
val maxMinusOne = if (msgs.size > position + 1 && msgs[position + 2].isRight()) msgs[position + 1].asRight().id else null val maxMinusOne =
sendFetchMessagesRequest(fromChat.id, toChat.id, maxMinusOne, if(msgs.size > position + 1 && msgs[position + 2].isRight()) msgs[position + 1].asRight().id else null
FetchEnd.MIDDLE, position) sendFetchMessagesRequest(
fromChat.id, toChat.id, maxMinusOne,
FetchEnd.MIDDLE, position
)
val (id) = msgs[position].asLeft() val (id) = msgs[position].asLeft()
val newViewData = ChatMessageViewData.Placeholder(id, true) val newViewData = ChatMessageViewData.Placeholder(id, true)
msgs.setPairedItem(position, newViewData) msgs.setPairedItem(position, newViewData)
updateAdapter() updateAdapter()
} else { } else {
Log.e(TAG, "error loading more") Timber.e("error loading more")
} }
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, event.toString()) Timber.d(event.toString())
if(event.action == KeyEvent.ACTION_DOWN) { if(event.action == KeyEvent.ACTION_DOWN) {
if(event.isCtrlPressed) { if(event.isCtrlPressed) {
if(keyCode == KeyEvent.KEYCODE_ENTER) { if(keyCode == KeyEvent.KEYCODE_ENTER) {
@ -1022,7 +1171,8 @@ class ChatActivity: BottomSheetActivity(),
// Acting like a teen: deliberately ignoring parent. // Acting like a teen: deliberately ignoring parent.
if(addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN || if(addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
stickerBehavior.state != BottomSheetBehavior.STATE_HIDDEN) { stickerBehavior.state != BottomSheetBehavior.STATE_HIDDEN
) {
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -1053,7 +1203,7 @@ class ChatActivity: BottomSheetActivity(),
* Auto dispose observable on pause * Auto dispose observable on pause
*/ */
private fun startUpdateTimestamp() { private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
if(!useAbsoluteTime) { if(!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES) Observable.interval(1, TimeUnit.MINUTES)
@ -1086,7 +1236,8 @@ class ChatActivity: BottomSheetActivity(),
if(view != null) { if(view != null) {
val url = attachment.url val url = attachment.url
ViewCompat.setTransitionName(view, url) ViewCompat.setTransitionName(view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, url) val options =
ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, url)
startActivity(intent, options.toBundle()) startActivity(intent, options.toBundle())
} else { } else {
@ -1111,7 +1262,10 @@ class ChatActivity: BottomSheetActivity(),
intent.putExtra(ID, chat.id) intent.putExtra(ID, chat.id)
intent.putExtra(AVATAR_URL, chat.account.avatar) intent.putExtra(AVATAR_URL, chat.account.avatar)
intent.putExtra(DISPLAY_NAME, chat.account.displayName ?: chat.account.localUsername) intent.putExtra(DISPLAY_NAME, chat.account.displayName ?: chat.account.localUsername)
intent.putParcelableArrayListExtra(EMOJIS, ArrayList(chat.account.emojis ?: emptyList<Emoji>())) intent.putParcelableArrayListExtra(
EMOJIS,
ArrayList(chat.account.emojis ?: emptyList<Emoji>())
)
intent.putExtra(USERNAME, chat.account.username) intent.putExtra(USERNAME, chat.account.username)
return intent return intent
} }

View file

@ -167,9 +167,11 @@ class ComposeActivity : BaseActivity(),
private val maxUploadMediaNumber = 4 private val maxUploadMediaNumber = 4
private var mediaCount = 0 private var mediaCount = 0
private lateinit var preferences: SharedPreferences
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val preferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if(theme == "black") { if(theme == "black") {
setTheme(R.style.TuskyDialogActivityBlackTheme) setTheme(R.style.TuskyDialogActivityBlackTheme)
@ -1447,7 +1449,11 @@ class ComposeActivity : BaseActivity(),
private fun setEmojiList(emojiList: List<Emoji>?) { private fun setEmojiList(emojiList: List<Emoji>?) {
if(emojiList != null) { if(emojiList != null) {
binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity) binding.emojiView.adapter = EmojiAdapter(
emojiList,
this@ComposeActivity,
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty()) enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty())
} }
} }