Husky/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt

546 lines
20 KiB
Kotlin

package com.keylesspalace.tusky.components.chat
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
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.appstore.EventHub
import com.keylesspalace.tusky.di.Injectable
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 kotlinx.android.synthetic.main.activity_chat.*
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import androidx.arch.core.util.Function
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.*
import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.util.*
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 java.io.IOException
import java.lang.Exception
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ChatActivity: BottomSheetActivity(),
Injectable, ChatActionListener {
private val TAG = "ChatsActivity" // logging tag
private val LOAD_AT_ONCE = 30
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var chatsRepo: ChatRepository
lateinit var adapter: ChatMessagesAdapter
private val msgs = PairedList<ChatMesssageOrPlaceholder, ChatMessageViewData?>(Function<ChatMesssageOrPlaceholder, ChatMessageViewData?> { input ->
input.asRightOrNull()?.let(ViewDataUtils::chatMessageToViewData) ?:
ChatMessageViewData.Placeholder(input.asLeft().id, false)
})
private var bottomLoading = false
private var eventRegistered = false
private var isNeedRefresh = false
private var didLoadEverythingBottom = false
private var initialUpdateFailed = false
private enum class FetchEnd {
TOP, BOTTOM, MIDDLE
}
private val listUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
Log.d(TAG, "onInserted")
adapter.notifyItemRangeInserted(position, count)
if (position == 0) {
recycler.scrollToPosition(0)
}
}
override fun onRemoved(position: Int, count: Int) {
Log.d(TAG, "onRemoved")
adapter.notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
Log.d(TAG, "onMoved")
adapter.notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
Log.d(TAG, "onChanged")
adapter.notifyItemRangeChanged(position, count, payload)
}
}
private val diffCallback = object : DiffUtil.ItemCallback<ChatMessageViewData>() {
override fun areItemsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean {
return oldItem.getViewDataId() == newItem.getViewDataId()
}
override fun areContentsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean {
return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Any? {
return if (oldItem.deepEquals(newItem)) {
//If items are equal - update timestamp only
listOf(ChatMessagesViewHolder.Key.KEY_CREATED)
} else // If items are different - update a whole view holder
null
}
}
private val differ = AsyncListDiffer(listUpdateCallback,
AsyncDifferConfig.Builder(diffCallback).build())
private val dataSource = object : TimelineAdapter.AdapterDataSource<ChatMessageViewData> {
override fun getItemCount(): Int {
return differ.currentList.size
}
override fun getItemAt(pos: Int): ChatMessageViewData {
return differ.currentList[pos]
}
}
private lateinit var chatId : String
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!")
}
setContentView(R.layout.activity_chat)
setSupportActionBar(toolbar)
supportActionBar?.run {
title = ""
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(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,
false)
adapter = ChatMessagesAdapter(dataSource, this, statusDisplayOptions, accountManager.activeAccount!!.accountId)
// TODO: a11y
recycler.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(this)
layoutManager.reverseLayout = true
recycler.layoutManager = layoutManager
// recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
recycler.adapter = adapter
tryCache()
}
private fun clearPlaceholdersForResponse(msgs: MutableList<ChatMesssageOrPlaceholder>) {
msgs.removeAll { it.isLeft() }
}
private fun tryCache() {
// Request timeline from disk to make it quick, then replace it with timeline from
// the server to update it
chatsRepo.getChatMessages(chatId, null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { msgs ->
if (msgs.size > 1) {
val mutableMsgs = msgs.toMutableList()
clearPlaceholdersForResponse(mutableMsgs)
this.msgs.clear()
this.msgs.addAll(mutableMsgs)
updateAdapter()
progressBar.visibility = View.GONE
// Request statuses including current top to refresh all of them
}
updateCurrent()
loadAbove()
}
}
private fun updateCurrent() {
if (msgs.isEmpty()) {
return
}
val topId = msgs.first { it.isRight() }.asRight().id
chatsRepo.getChatMessages(chatId, topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ messages ->
initialUpdateFailed = false
// When cached timeline is too old, we would replace it with nothing
if (messages.isNotEmpty()) {
// clear old cached statuses
if(this.msgs.isNotEmpty()) {
this.msgs.removeAll {
if(it.isRight()) {
val chat = it.asRight()
chat.id.length < topId.length || chat.id < topId
} else {
val placeholder = it.asLeft()
placeholder.id.length < topId.length || placeholder.id < topId
}
}
}
this.msgs.addAll(messages)
updateAdapter()
}
bottomLoading = false
}, {
initialUpdateFailed = true
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
})
}
private fun showNothing() {
messageView.visibility = View.VISIBLE
messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
private fun loadAbove() {
var firstOrNull: String? = null
var secondOrNull: String? = null
for (i in msgs.indices) {
val msg = msgs[i]
if (msg.isRight()) {
firstOrNull = msg.asRight().id
if (i + 1 < msgs.size && msgs[i + 1].isRight()) {
secondOrNull = msgs[i + 1].asRight().id
}
break
}
}
if (firstOrNull != null) {
sendFetchMessagesRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1)
} else {
sendFetchMessagesRequest(null, null, null, FetchEnd.BOTTOM, -1)
}
}
private fun sendFetchMessagesRequest(maxId: String?, sinceId: String?,
sinceIdMinusOne: String?,
fetchEnd: FetchEnd, pos: Int) {
// allow getting old statuses/fallbacks for network only for for bottom loading
val mode = if (fetchEnd == FetchEnd.BOTTOM) {
TimelineRequestMode.ANY
} else {
TimelineRequestMode.NETWORK
}
chatsRepo.getChatMessages(chatId, maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) },
{ onFetchTimelineFailure(Exception(it), fetchEnd, pos) })
}
private fun updateAdapter() {
Log.d(TAG, "updateAdapter")
differ.submitList(msgs.pairedCopy)
}
private fun updateMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>, fullFetch: Boolean) {
if (newMsgs.isEmpty()) {
updateAdapter()
return
}
if (msgs.isEmpty()) {
msgs.addAll(newMsgs)
} else {
val lastOfNew = newMsgs[newMsgs.size - 1]
val index = msgs.indexOf(lastOfNew)
if (index >= 0) {
msgs.subList(0, index).clear()
}
val newIndex = newMsgs.indexOf(msgs[0])
if (newIndex == -1) {
if (index == -1 && fullFetch) {
newMsgs.findLast { it.isRight() }?.let {
val placeholderId = it.asRight().id.inc()
newMsgs.add(Either.Left(Placeholder(placeholderId)))
}
}
msgs.addAll(0, newMsgs)
} else {
msgs.addAll(0, newMsgs.subList(0, newIndex))
}
}
// Remove all consecutive placeholders
removeConsecutivePlaceholders()
updateAdapter()
}
private fun removeConsecutivePlaceholders() {
for (i in 0 until msgs.size - 1) {
if (msgs[i].isLeft() && msgs[i + 1].isLeft()) {
msgs.removeAt(i)
}
}
}
private fun replacePlaceholderWithMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>,
fullFetch: Boolean, pos: Int) {
val placeholder = msgs[pos]
if (placeholder.isLeft()) {
msgs.removeAt(pos)
}
if (newMsgs.isEmpty()) {
updateAdapter()
return
}
if (fullFetch) {
newMsgs.add(placeholder)
}
msgs.addAll(pos, newMsgs)
removeConsecutivePlaceholders()
updateAdapter()
}
private fun addItems(newMsgs: List<ChatMesssageOrPlaceholder>) {
if (newMsgs.isEmpty()) {
return
}
val last = msgs.findLast { it.isRight() }
// I was about to replace findStatus with indexOf but it is incorrect to compare value
// types by ID anyway and we should change equals() for Status, I think, so this makes sense
if (last != null && !newMsgs.contains(last)) {
msgs.addAll(newMsgs)
removeConsecutivePlaceholders()
updateAdapter()
}
}
private fun onFetchTimelineSuccess(msgs: MutableList<ChatMesssageOrPlaceholder>,
fetchEnd: FetchEnd, pos: Int) {
// We filled the hole (or reached the end) if the server returned less statuses than we
// we asked for.
val fullFetch = msgs.size >= LOAD_AT_ONCE
when (fetchEnd) {
FetchEnd.TOP -> {
updateMessages(msgs, fullFetch)
}
FetchEnd.MIDDLE -> {
replacePlaceholderWithMessages(msgs, fullFetch, pos)
}
FetchEnd.BOTTOM -> {
if (this.msgs.isNotEmpty() && !this.msgs.last().isRight()) {
this.msgs.removeAt(this.msgs.size - 1)
updateAdapter()
}
if (msgs.isNotEmpty() && !msgs.last().isRight()) {
// Removing placeholder if it's the last one from the cache
msgs.removeAt(msgs.size - 1)
}
val oldSize = this.msgs.size
if (this.msgs.size > 1) {
addItems(msgs)
} else {
updateMessages(msgs, fullFetch)
}
if (this.msgs.size == oldSize) {
// This may be a brittle check but seems like it works
// Can we check it using headers somehow? Do all server support them?
didLoadEverythingBottom = true
}
}
}
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
if (this.msgs.size == 0) {
showNothing()
} else {
messageView.visibility = View.GONE
}
}
private fun onRefresh() {
messageView.visibility = View.GONE
isNeedRefresh = false
if (this.initialUpdateFailed) {
updateCurrent()
}
loadAbove()
}
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
if (placeholder == null) {
val msg = msgs[position - 1].asRight()
val newId = msg.id.dec()
placeholder = Placeholder(newId)
}
newViewData = ChatMessageViewData.Placeholder(placeholder.id, false)
msgs.setPairedItem(position, newViewData)
updateAdapter()
} else if (msgs.isEmpty()) {
messageView.visibility = View.VISIBLE
if (exception is IOException) {
messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
} else {
messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
}
}
Log.e(TAG, "Fetch Failure: " + exception.message)
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
}
private fun updateBottomLoadingState(fetchEnd: FetchEnd) {
if (fetchEnd == FetchEnd.BOTTOM) {
bottomLoading = false
}
}
override fun onLoadMore(position: Int) {
//check bounds before accessing list,
if (msgs.size >= position && position > 0) {
val fromChat = msgs[position - 1].asRightOrNull()
val toChat = msgs[position + 1].asRightOrNull()
if (fromChat == null || toChat == null) {
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
return
}
val maxMinusOne = if (msgs.size > position + 1 && msgs[position + 2].isRight()) msgs[position + 1].asRight().id else null
sendFetchMessagesRequest(fromChat.id, toChat.id, maxMinusOne,
FetchEnd.MIDDLE, position)
val (id) = msgs[position].asLeft()
val newViewData = ChatMessageViewData.Placeholder(id, true)
msgs.setPairedItem(position, newViewData)
updateAdapter()
} else {
Log.e(TAG, "error loading more")
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
startUpdateTimestamp()
}
/**
* Start to update adapter every minute to refresh timestamp
* If setting absoluteTimeView is false
* Auto dispose observable on pause
*/
private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { updateAdapter() }
}
}
override fun onViewAccount(id: String) {
viewAccount(id)
}
override fun onViewUrl(url: String) {
viewUrl(url)
}
override fun onViewTag(tag: String) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
startActivity(intent)
}
companion object {
fun getIntent(context: Context, chat: Chat) : Intent {
val intent = Intent(context, ChatActivity::class.java)
intent.putExtra(ID, chat.id)
intent.putExtra(AVATAR_URL, chat.account.avatar)
intent.putExtra(DISPLAY_NAME, chat.account.displayName ?: chat.account.localUsername)
intent.putParcelableArrayListExtra(EMOJIS, ArrayList(chat.account.emojis ?: emptyList<Emoji>()))
intent.putExtra(USERNAME, chat.account.username)
return intent
}
const val ID = "id"
const val AVATAR_URL = "avatar_url"
const val DISPLAY_NAME = "display_name"
const val USERNAME = "username"
const val EMOJIS = "emojis"
}
}