package com.keylesspalace.tusky.fragment import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.arch.core.util.Function import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager import androidx.recyclerview.widget.* import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.ChatsAdapter import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.TimelineAdapter import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.components.chat.ChatActivity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Chat import com.keylesspalace.tusky.entity.ChatMessage import com.keylesspalace.tusky.entity.NewChatMessage import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ChatActionListener import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.repository.* import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.viewdata.ChatViewData import com.uber.autodispose.AutoDispose import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import com.uber.autodispose.android.lifecycle.autoDispose import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.fragment_timeline.* import java.io.IOException import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, ReselectableFragment, ChatActionListener, OnRefreshListener { private val TAG = "ChatsF" // logging tag private val LOAD_AT_ONCE = 30 private val BROKEN_PAGINATION_IN_BACKEND = true // break pagination until it's not fixed in plemora @Inject lateinit var eventHub: EventHub @Inject lateinit var api: MastodonApi @Inject lateinit var accountManager: AccountManager @Inject lateinit var chatRepo: ChatRepository @Inject lateinit var timelineCases: TimelineCases lateinit var adapter: ChatsAdapter lateinit var layoutManager: LinearLayoutManager private lateinit var scrollListener: EndlessOnScrollListener private lateinit var bottomSheetActivity: BottomSheetActivity private var hideFab = false private var bottomLoading = false private var eventRegistered = false private var isSwipeToRefreshEnabled = true private var isNeedRefresh = false private var didLoadEverythingBottom = false private var initialUpdateFailed = false private enum class FetchEnd { TOP, BOTTOM, MIDDLE } private val chats = PairedList(Function {input -> input.asRightOrNull()?.let(ViewDataUtils::chatToViewData) ?: ChatViewData.Placeholder(input.asLeft().id, false) }) private val listUpdateCallback = object : ListUpdateCallback { override fun onInserted(position: Int, count: Int) { if (isAdded) { Log.d(TAG, "onInserted"); adapter.notifyItemRangeInserted(position, count) // scroll up when new items at the top are loaded while being in the first position // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 if (position == 0 && context != null && adapter.itemCount != count) { if (isSwipeToRefreshEnabled) recyclerView.scrollBy(0, Utils.dpToPx(context!!, -30)); else recyclerView.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() { override fun areItemsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { return oldItem.getViewDataId() == newItem.getViewDataId() } override fun areContentsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload(oldItem: ChatViewData, newItem: ChatViewData): Any? { return if (oldItem.deepEquals(newItem)) { //If items are equal - update timestamp only listOf(StatusBaseViewHolder.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 { override fun getItemCount(): Int { return differ.currentList.size } override fun getItemAt(pos: Int): ChatViewData { return differ.currentList[pos] } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val preferences = PreferenceManager.getDefaultSharedPreferences(activity) val statusDisplayOptions = StatusDisplayOptions( preferences.getBoolean("animateGifAvatars", false), accountManager.activeAccount!!.mediaPreviewEnabled, preferences.getBoolean("absoluteTimeView", false), preferences.getBoolean("showBotOverlay", true), false, CardViewMode.NONE,false ) adapter = ChatsAdapter(dataSource, statusDisplayOptions, this, accountManager.activeAccount!!.accountId) } override fun onAttach(context: Context) { super.onAttach(context) bottomSheetActivity = if (context is BottomSheetActivity) { context } else { throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled swipeRefreshLayout.setOnRefreshListener(this) swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) // TODO: a11y recyclerView.setHasFixedSize(true) layoutManager = LinearLayoutManager(view.context) recyclerView.layoutManager = layoutManager recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) recyclerView.adapter = adapter if (chats.isEmpty()) { progressBar.visibility = View.VISIBLE bottomLoading = true sendInitialRequest() } else { progressBar.visibility = View.GONE if (isNeedRefresh) onRefresh() } } private fun sendInitialRequest() { // debug // sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) tryCache() } private fun tryCache() { // Request timeline from disk to make it quick, then replace it with timeline from // the server to update it chatRepo.getChats(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe { newChats -> if (newChats.size > 1) { val mutableChats = newChats.toMutableList() mutableChats.removeAll { it.isLeft() } chats.clear() chats.addAll(mutableChats) updateAdapter() progressBar.visibility = View.GONE } updateCurrent() loadAbove() } } private fun updateCurrent() { if (!BROKEN_PAGINATION_IN_BACKEND && chats.isEmpty()) { return } val topId = chats.firstOrNull { it.isRight() }?.asRight()?.id chatRepo.getChats(topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe({ newChats -> initialUpdateFailed = false // When cached timeline is too old, we would replace it with nothing if (newChats.isNotEmpty()) { // clear old cached statuses if(BROKEN_PAGINATION_IN_BACKEND) { chats.clear() } else { chats.removeAll { if(it.isLeft()) { val p = it.asLeft() p.id.length < topId!!.length || p.id < topId } else { val c = it.asRight() c.id.length < topId!!.length || c.id < topId } } } chats.addAll(newChats) updateAdapter() } bottomLoading = false // Indicate that we are not loading anymore progressBar.visibility = View.GONE swipeRefreshLayout.isRefreshing = false }, { initialUpdateFailed = true // Indicate that we are not loading anymore progressBar.visibility = View.GONE swipeRefreshLayout.isRefreshing = false }) } private fun showNothing() { statusView.visibility = View.VISIBLE statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) } private fun removeAllByAccountId(accountId: String) { chats.removeAll { val chat = it.asRightOrNull() chat != null && chat.account.id == accountId } updateAdapter() } private fun removeAllByInstance(instance: String) { chats.removeAll { val chat = it.asRightOrNull() chat != null && LinkHelper.getDomain(chat.account.url) == instance } updateAdapter() } private fun deleteChatById(id: String) { val iterator = chats.iterator() while(iterator.hasNext()) { val chat = iterator.next().asRightOrNull() if(chat != null && chat.id == id) { iterator.remove() updateAdapter() break } } if(chats.isEmpty()) { showNothing() } } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't * guaranteed to be set until then. */ /* Use a modified scroll listener that both loads more statuses as it goes, and hides * the follow button on down-scroll. */ val preferences = PreferenceManager.getDefaultSharedPreferences(context) hideFab = preferences.getBoolean("fabHide", false) scrollListener = object : EndlessOnScrollListener(layoutManager) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { super.onScrolled(view, dx, dy) val activity = activity as ActionButtonActivity? val composeButton = activity!!.actionButton if (composeButton != null) { if (hideFab) { if (dy > 0 && composeButton.isShown) { composeButton.hide() // hides the button if we're scrolling down } else if (dy < 0 && !composeButton.isShown) { composeButton.show() // shows it if we are scrolling up } } else if (!composeButton.isShown) { composeButton.show() } } } override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { if(!BROKEN_PAGINATION_IN_BACKEND) this@ChatsFragment.onLoadMore() } } recyclerView.addOnScrollListener(scrollListener) if (!eventRegistered) { eventHub.events .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe { event: Event? -> when(event) { is BlockEvent -> removeAllByAccountId(event.accountId) is MuteEvent -> removeAllByAccountId(event.accountId) is DomainMuteEvent -> removeAllByInstance(event.instance) is StatusDeletedEvent -> deleteChatById(event.statusId) is PreferenceChangedEvent -> onPreferenceChanged(event.preferenceKey) is ChatMessageReceivedEvent -> onRefresh() // TODO: proper update } } eventRegistered = true } } /* private fun onChatMessageReceived(msg: ChatMessage) { val pos = findChatPosition(msg.chatId) if(pos == -1) { return } val oldChat = chats[pos].asRight() val newChat = Chat(oldChat.account, oldChat.id, oldChat.unread + 1, msg, msg.createdAt) val newViewData = ViewDataUtils.chatToViewData(newChat) chats.removeAt(pos) chats.add(pos, newChat.lift()) chats.sortByDescending { if(it.isLeft()) Date(Long.MIN_VALUE) else it.asRight().updatedAt } updateAdapter() } */ private fun onPreferenceChanged(key: String) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) when (key) { "fabHide" -> { hideFab = sharedPreferences.getBoolean("fabHide", false) } } } override fun onRefresh() { if (isSwipeToRefreshEnabled) swipeRefreshLayout.isEnabled = true statusView.visibility = View.GONE isNeedRefresh = false if (this.initialUpdateFailed) { updateCurrent() } loadAbove() } private fun loadAbove() { if(BROKEN_PAGINATION_IN_BACKEND) { updateCurrent() return } var firstOrNull: String? = null var secondOrNull: String? = null for (i in chats.indices) { val chat = chats[i] if (chat.isRight()) { firstOrNull = chat.asRight().id if (i + 1 < chats.size && chats[i + 1].isRight()) { secondOrNull = chats[i + 1].asRight().id } break } } if (firstOrNull != null) { sendFetchChatsRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) } else { sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) } } private fun onLoadMore() { if (BROKEN_PAGINATION_IN_BACKEND) updateCurrent() return if (didLoadEverythingBottom || bottomLoading) { return } if (chats.isEmpty()) { sendInitialRequest() return } bottomLoading = true val last = chats.last() val placeholder: Placeholder if (last.isRight()) { val placeholderId = last.asRight().id.dec() placeholder = Placeholder(placeholderId) chats.add(Left(placeholder)) } else { placeholder = last.asLeft() } chats.setPairedItem(chats.size - 1, ChatViewData.Placeholder(placeholder.id, true)) updateAdapter() val bottomId = chats.findLast { it.isRight() }?.let { it.asRight().id } sendFetchChatsRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) } private fun sendFetchChatsRequest(maxId: String?, sinceId: String?, sinceIdMinusOne: String?, fetchEnd: FetchEnd, pos: Int) { if (isAdded && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.visibility != View.VISIBLE) && !isSwipeToRefreshEnabled) topProgressBar.show() // allow getting old statuses/fallbacks for network only for for bottom loading val mode = if (fetchEnd == FetchEnd.BOTTOM) { TimelineRequestMode.ANY } else { TimelineRequestMode.NETWORK } chatRepo.getChats(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 updateChats(newChats: MutableList, fullFetch: Boolean) { if (newChats.isEmpty()) { updateAdapter() return } if (chats.isEmpty()) { chats.addAll(newChats) } else { val lastOfNew = newChats[newChats.size - 1] val index = chats.indexOf(lastOfNew) if (index >= 0) { chats.subList(0, index).clear() } val newIndex = newChats.indexOf(chats[0]) if (newIndex == -1) { if (index == -1 && fullFetch) { newChats.last { it.isRight() }.let { val placeholderId = it.asRight().id.inc() newChats.add(Left(Placeholder(placeholderId))) } } chats.addAll(0, newChats) } else { chats.addAll(0, newChats.subList(0, newIndex)) } } // Remove all consecutive placeholders removeConsecutivePlaceholders() updateAdapter() } private fun removeConsecutivePlaceholders() { for (i in 0 until chats.size - 1) { if (chats[i].isLeft() && chats[i + 1].isLeft()) { chats.removeAt(i) } } } private fun replacePlaceholderWithChats(newChats: MutableList, fullFetch: Boolean, pos: Int) { val placeholder = chats[pos] if (placeholder.isLeft()) { chats.removeAt(pos) } if (newChats.isEmpty()) { updateAdapter() return } if (fullFetch) { newChats.add(placeholder) } chats.addAll(pos, newChats) removeConsecutivePlaceholders() updateAdapter() } private fun addItems(newChats: List) { if (newChats.isEmpty()) { return } val last = chats.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 && !newChats.contains(last)) { chats.addAll(newChats) removeConsecutivePlaceholders() updateAdapter() } } private fun onFetchTimelineSuccess(chats: MutableList, 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 = chats.size >= LOAD_AT_ONCE when (fetchEnd) { FetchEnd.TOP -> { updateChats(chats, fullFetch) } FetchEnd.MIDDLE -> { replacePlaceholderWithChats(chats, fullFetch, pos) } FetchEnd.BOTTOM -> { if (this.chats.isNotEmpty() && !this.chats.last().isRight()) { this.chats.removeAt(this.chats.size - 1) updateAdapter() } if (chats.isNotEmpty() && !chats.last().isRight()) { // Removing placeholder if it's the last one from the cache chats.removeAt(chats.size - 1) } val oldSize = this.chats.size if (this.chats.size > 1) { addItems(chats) } else { updateChats(chats, fullFetch) } if (this.chats.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 } } } if (isAdded) { topProgressBar.hide() updateBottomLoadingState(fetchEnd) progressBar.visibility = View.GONE swipeRefreshLayout.isRefreshing = false swipeRefreshLayout.isEnabled = true if (this.chats.size == 0) { showNothing() } else { this.statusView.visibility = View.GONE } } } private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) { if (isAdded) { swipeRefreshLayout.isRefreshing = false topProgressBar.hide() if (fetchEnd == FetchEnd.MIDDLE && !chats[position].isRight()) { var placeholder = chats[position].asLeftOrNull() val newViewData: ChatViewData if (placeholder == null) { val chat = chats[position - 1].asRight() val newId = chat.id.dec() placeholder = Placeholder(newId) } newViewData = ChatViewData.Placeholder(placeholder.id, false) chats.setPairedItem(position, newViewData) updateAdapter() } else if (chats.isEmpty()) { swipeRefreshLayout.isEnabled = false statusView.visibility = View.VISIBLE if (exception is IOException) { statusView.setup(R.drawable.elephant_offline, R.string.error_network) { progressBar.visibility = View.VISIBLE onRefresh() } } else { statusView.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 (chats.size >= position && position > 0) { val fromChat = chats[position - 1].asRightOrNull() val toChat = chats[position + 1].asRightOrNull() if (fromChat == null || toChat == null) { Log.e(TAG, "Failed to load more at $position, wrong placeholder position") return } val maxMinusOne = if (chats.size > position + 1 && chats[position + 2].isRight()) chats[position + 1].asRight().id else null sendFetchChatsRequest(fromChat.id, toChat.id, maxMinusOne, FetchEnd.MIDDLE, position) val (id) = chats[position].asLeft() val newViewData = ChatViewData.Placeholder(id, true) chats.setPairedItem(position, newViewData) updateAdapter() } else { Log.e(TAG, "error loading more") } } override fun onViewAccount(id: String?) { id?.let(bottomSheetActivity::viewAccount) } override fun onViewUrl(url: String?) { url?.let { bottomSheetActivity.viewUrl(it, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } } // never called override fun onViewTag(tag: String?) {} private fun updateAdapter() { Log.d(TAG, "updateAdapter") differ.submitList(chats.pairedCopy) } private fun jumpToTop() { if (isAdded) { layoutManager.scrollToPosition(0) recyclerView.stopScroll() scrollListener.reset() } } override fun onReselect() { jumpToTop() } override fun onResume() { super.onResume() startUpdateTimestamp() } override fun refreshContent() { if (isAdded) onRefresh() else isNeedRefresh = true } /** * 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(activity) val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) if (!useAbsoluteTime) { Observable.interval(1, TimeUnit.MINUTES) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_PAUSE) .subscribe { updateAdapter() } } } private fun findChatPosition(id: String) : Int { return chats.indexOfFirst { it.isRight() && it.asRight().id == id } } private fun markAsRead(chat: Chat) { val pos = findChatPosition(chat.id) val chatViewData = ViewDataUtils.chatToViewData(chat) chats.setPairedItem(pos, chatViewData) updateAdapter() } override fun onMore(id: String, v: View) { val popup = PopupMenu(requireContext(), v) popup.inflate(R.menu.chat_more) val pos = findChatPosition(id) val chat = chats[pos].asRight() // val menu = popup.menu popup.setOnMenuItemClickListener { when(it.itemId) { R.id.chat_mark_as_read -> { api.markChatAsRead(chat.id, chat.lastMessage?.id ?: null) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe({ chat -> markAsRead(chat) }, { err -> Log.e(TAG, "Failed to mark chat as read", err) }) true } else -> { false // ???? } } } popup.show() } override fun openChat(position: Int) { if(position < 0 || position >= chats.size) return val chat = chats[position].asRightOrNull() chat?.let { bottomSheetActivity.openChat(it) } } }