ff8dd37855
* Replace "warn"-filtered posts in timelines and thread view with placeholders * Adapt hashtag muting interface * Rework filter UI * Add icon for account preferences * Clean up UI * WIP: Use chips instead of a list. Adjust padding * Scroll the filter edit activity Nested scrolling views (e.g., an activity that scrolls with an embedded list that also scrolls) can be difficult UI. Since the list of contexts is fixed, replace it with a fixed collection of switches, so there's no need to scroll the list. Since the list of actions is only two (warn, hide), and are mutually exclusive, replace the spinner with two radio buttons. Use the accent colour and title styles on the different heading titles in the layout, to match the presentation in Preferences. Add an explicit "Cancel" button. The layout is a straightforward LinearLayout, so use that instead of ConstraintLayout, and remove some unncessary IDs. Update EditFilterActivity to handle the new layout. * Cleanup * Add more information to the filter list view * First pass on code review comments * Add view model to filters activity * Add view model to edit filters activity * Only use the status wrapper for filtered statuses * Relint --------- Co-authored-by: Nik Clayton <nik@ngo.org.uk>
396 lines
15 KiB
Kotlin
396 lines
15 KiB
Kotlin
/* 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 clearWarningAction(position: Int) {
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|