123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- /* 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 <http://www.gnu.org/licenses>. */
- package com.keylesspalace.tusky.components.conversation
- import android.os.Bundle
- import android.view.LayoutInflater
- import android.view.Menu
- import android.view.MenuInflater
- import android.view.MenuItem
- import android.view.View
- import android.view.ViewGroup
- import androidx.appcompat.app.AlertDialog
- import androidx.appcompat.widget.PopupMenu
- import androidx.core.view.MenuProvider
- 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.google.android.material.color.MaterialColors
- 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.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 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 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,
- MenuProvider {
- @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?) {
- requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
- 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),
- showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
- openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
- )
- 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)
- }
- }
- }
- override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
- menuInflater.inflate(R.menu.fragment_conversations, menu)
- menu.findItem(R.id.action_refresh)?.apply {
- icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
- sizeDp = 20
- colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
- }
- }
- }
- override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
- return when (menuItem.itemId) {
- R.id.action_refresh -> {
- binding.swipeRefreshLayout.isRefreshing = true
- refreshContent()
- true
- }
- else -> false
- }
- }
- 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 refreshContent() {
- adapter.refresh()
- }
- private fun initSwipeToRefresh() {
- binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
- 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 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<Int>) {
- 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()
- }
- }
|