/* Copyright 2021 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 . */ package com.keylesspalace.tusky.components.conversation import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @Inject lateinit var viewModelFactory: ViewModelFactory @Inject lateinit var eventHub: EventHub private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } private val binding by viewBinding(FragmentTimelineBinding::bind) private lateinit var adapter: ConversationAdapter private var hideFab = false 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?) { val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean("animateGifAvatars", false), mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), showBotOverlay = preferences.getBoolean("showBotOverlay", true), useBlurhash = preferences.getBoolean("useBlurhash", true), cardViewMode = CardViewMode.NONE, confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID, ) adapter = ConversationAdapter(statusDisplayOptions, this) setupRecyclerView() initSwipeToRefresh() adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } binding.statusView.hide() binding.progressBar.hide() if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) } } is LoadState.Error -> { binding.statusView.show() if ((loadState.refresh as LoadState.Error).error is IOException) { binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) } else { binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) } } is LoadState.Loading -> { binding.progressBar.show() } } } } adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) } } } } }) hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).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() } } } }) viewLifecycleOwner.lifecycleScope.launch { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } lifecycleScope.launchWhenResumed { val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) while (!useAbsoluteTime) { adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) delay(1.toDuration(DurationUnit.MINUTES)) } } eventHub.events .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe { event -> if (event is PreferenceChangedEvent) { onPreferenceChanged(event.preferenceKey) } } } private fun setupRecyclerView() { binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } private fun initSwipeToRefresh() { binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } override fun onReblog(reblog: Boolean, position: Int) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.favourite(favourite, conversation) } } override fun onQuote(position: Int) { // its impossible to quote private messages } override fun onBookmark(favourite: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.bookmark(favourite, conversation) } } override fun onMore(view: View, position: Int) { adapter.peek(position)?.let { conversation -> val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) if (conversation.lastStatus.status.muted == true) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) } popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.status_mute_conversation -> viewModel.muteConversation(conversation) R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) R.id.conversation_delete -> deleteConversation(conversation) } true } popup.show() } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { adapter.peek(position)?.let { conversation -> viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view) } } override fun onViewThread(position: Int) { adapter.peek(position)?.let { conversation -> viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) } } override fun onOpenReblog(position: Int) { // there are no reblogs in conversations } override fun onExpandedChange(expanded: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.expandHiddenStatus(expanded, conversation) } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.showContent(isShowing, conversation) } } override fun onLoadMore(position: Int) { // not using the old way of pagination } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) } } override fun onViewAccount(id: String) { val intent = AccountActivity.getIntent(requireContext(), id) startActivity(intent) } override fun onViewTag(tag: String) { val intent = StatusListActivity.newHashtagIntent(requireContext(), tag) startActivity(intent) } override fun removeItem(position: Int) { // not needed } override fun onReply(position: Int) { adapter.peek(position)?.let { conversation -> reply(conversation.lastStatus.status) } } override fun onVoteInPoll(position: Int, choices: MutableList) { adapter.peek(position)?.let { conversation -> viewModel.voteInPoll(choices, conversation) } } override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.remove(conversation) } .show() } private fun onPreferenceChanged(key: String) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) when (key) { PrefKeys.FAB_HIDE -> { hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) } PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled if (enabled != oldMediaPreviewEnabled) { adapter.mediaPreviewEnabled = enabled adapter.notifyItemRangeChanged(0, adapter.itemCount) } } } } companion object { fun newInstance() = ConversationsFragment() } }