ConversationsFragment.kt 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. /* Copyright 2021 Tusky Contributors
  2. *
  3. * This file is a part of Tusky.
  4. *
  5. * This program is free software; you can redistribute it and/or modify it under the terms of the
  6. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  7. * License, or (at your option) any later version.
  8. *
  9. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  10. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  11. * Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  14. * see <http://www.gnu.org/licenses>. */
  15. package com.keylesspalace.tusky.components.conversation
  16. import android.os.Bundle
  17. import android.view.LayoutInflater
  18. import android.view.Menu
  19. import android.view.MenuInflater
  20. import android.view.MenuItem
  21. import android.view.View
  22. import android.view.ViewGroup
  23. import androidx.appcompat.app.AlertDialog
  24. import androidx.appcompat.widget.PopupMenu
  25. import androidx.core.view.MenuProvider
  26. import androidx.fragment.app.viewModels
  27. import androidx.lifecycle.Lifecycle
  28. import androidx.lifecycle.lifecycleScope
  29. import androidx.paging.LoadState
  30. import androidx.preference.PreferenceManager
  31. import androidx.recyclerview.widget.DividerItemDecoration
  32. import androidx.recyclerview.widget.LinearLayoutManager
  33. import androidx.recyclerview.widget.RecyclerView
  34. import androidx.recyclerview.widget.SimpleItemAnimator
  35. import at.connyduck.sparkbutton.helpers.Utils
  36. import autodispose2.androidx.lifecycle.autoDispose
  37. import com.google.android.material.color.MaterialColors
  38. import com.keylesspalace.tusky.R
  39. import com.keylesspalace.tusky.StatusListActivity
  40. import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
  41. import com.keylesspalace.tusky.appstore.EventHub
  42. import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
  43. import com.keylesspalace.tusky.components.account.AccountActivity
  44. import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
  45. import com.keylesspalace.tusky.di.Injectable
  46. import com.keylesspalace.tusky.di.ViewModelFactory
  47. import com.keylesspalace.tusky.fragment.SFragment
  48. import com.keylesspalace.tusky.interfaces.ActionButtonActivity
  49. import com.keylesspalace.tusky.interfaces.ReselectableFragment
  50. import com.keylesspalace.tusky.interfaces.StatusActionListener
  51. import com.keylesspalace.tusky.settings.PrefKeys
  52. import com.keylesspalace.tusky.util.CardViewMode
  53. import com.keylesspalace.tusky.util.StatusDisplayOptions
  54. import com.keylesspalace.tusky.util.hide
  55. import com.keylesspalace.tusky.util.show
  56. import com.keylesspalace.tusky.util.viewBinding
  57. import com.keylesspalace.tusky.viewdata.AttachmentViewData
  58. import com.mikepenz.iconics.IconicsDrawable
  59. import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
  60. import com.mikepenz.iconics.utils.colorInt
  61. import com.mikepenz.iconics.utils.sizeDp
  62. import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
  63. import kotlinx.coroutines.delay
  64. import kotlinx.coroutines.flow.collectLatest
  65. import kotlinx.coroutines.launch
  66. import java.io.IOException
  67. import javax.inject.Inject
  68. import kotlin.time.DurationUnit
  69. import kotlin.time.toDuration
  70. class ConversationsFragment :
  71. SFragment(),
  72. StatusActionListener,
  73. Injectable,
  74. ReselectableFragment,
  75. MenuProvider {
  76. @Inject
  77. lateinit var viewModelFactory: ViewModelFactory
  78. @Inject
  79. lateinit var eventHub: EventHub
  80. private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
  81. private val binding by viewBinding(FragmentTimelineBinding::bind)
  82. private lateinit var adapter: ConversationAdapter
  83. private var hideFab = false
  84. override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
  85. return inflater.inflate(R.layout.fragment_timeline, container, false)
  86. }
  87. override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  88. requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
  89. val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
  90. val statusDisplayOptions = StatusDisplayOptions(
  91. animateAvatars = preferences.getBoolean("animateGifAvatars", false),
  92. mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
  93. useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
  94. showBotOverlay = preferences.getBoolean("showBotOverlay", true),
  95. useBlurhash = preferences.getBoolean("useBlurhash", true),
  96. cardViewMode = CardViewMode.NONE,
  97. confirmReblogs = preferences.getBoolean("confirmReblogs", true),
  98. confirmFavourites = preferences.getBoolean("confirmFavourites", false),
  99. hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
  100. animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
  101. showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
  102. openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
  103. )
  104. adapter = ConversationAdapter(statusDisplayOptions, this)
  105. setupRecyclerView()
  106. initSwipeToRefresh()
  107. adapter.addLoadStateListener { loadState ->
  108. if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
  109. binding.swipeRefreshLayout.isRefreshing = false
  110. }
  111. binding.statusView.hide()
  112. binding.progressBar.hide()
  113. if (adapter.itemCount == 0) {
  114. when (loadState.refresh) {
  115. is LoadState.NotLoading -> {
  116. if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
  117. binding.statusView.show()
  118. binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
  119. }
  120. }
  121. is LoadState.Error -> {
  122. binding.statusView.show()
  123. if ((loadState.refresh as LoadState.Error).error is IOException) {
  124. binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
  125. } else {
  126. binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
  127. }
  128. }
  129. is LoadState.Loading -> {
  130. binding.progressBar.show()
  131. }
  132. }
  133. }
  134. }
  135. adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
  136. override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
  137. if (positionStart == 0 && adapter.itemCount != itemCount) {
  138. binding.recyclerView.post {
  139. if (getView() != null) {
  140. binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
  141. }
  142. }
  143. }
  144. }
  145. })
  146. hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
  147. binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
  148. override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
  149. val composeButton = (activity as ActionButtonActivity).actionButton
  150. if (composeButton != null) {
  151. if (hideFab) {
  152. if (dy > 0 && composeButton.isShown) {
  153. composeButton.hide() // hides the button if we're scrolling down
  154. } else if (dy < 0 && !composeButton.isShown) {
  155. composeButton.show() // shows it if we are scrolling up
  156. }
  157. } else if (!composeButton.isShown) {
  158. composeButton.show()
  159. }
  160. }
  161. }
  162. })
  163. viewLifecycleOwner.lifecycleScope.launch {
  164. viewModel.conversationFlow.collectLatest { pagingData ->
  165. adapter.submitData(pagingData)
  166. }
  167. }
  168. lifecycleScope.launchWhenResumed {
  169. val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
  170. while (!useAbsoluteTime) {
  171. adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
  172. delay(1.toDuration(DurationUnit.MINUTES))
  173. }
  174. }
  175. eventHub.events
  176. .observeOn(AndroidSchedulers.mainThread())
  177. .autoDispose(this, Lifecycle.Event.ON_DESTROY)
  178. .subscribe { event ->
  179. if (event is PreferenceChangedEvent) {
  180. onPreferenceChanged(event.preferenceKey)
  181. }
  182. }
  183. }
  184. override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
  185. menuInflater.inflate(R.menu.fragment_conversations, menu)
  186. menu.findItem(R.id.action_refresh)?.apply {
  187. icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
  188. sizeDp = 20
  189. colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
  190. }
  191. }
  192. }
  193. override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
  194. return when (menuItem.itemId) {
  195. R.id.action_refresh -> {
  196. binding.swipeRefreshLayout.isRefreshing = true
  197. refreshContent()
  198. true
  199. }
  200. else -> false
  201. }
  202. }
  203. private fun setupRecyclerView() {
  204. binding.recyclerView.setHasFixedSize(true)
  205. binding.recyclerView.layoutManager = LinearLayoutManager(context)
  206. binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
  207. (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
  208. binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
  209. }
  210. private fun refreshContent() {
  211. adapter.refresh()
  212. }
  213. private fun initSwipeToRefresh() {
  214. binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
  215. binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
  216. }
  217. override fun onReblog(reblog: Boolean, position: Int) {
  218. // its impossible to reblog private messages
  219. }
  220. override fun onFavourite(favourite: Boolean, position: Int) {
  221. adapter.peek(position)?.let { conversation ->
  222. viewModel.favourite(favourite, conversation)
  223. }
  224. }
  225. override fun onBookmark(favourite: Boolean, position: Int) {
  226. adapter.peek(position)?.let { conversation ->
  227. viewModel.bookmark(favourite, conversation)
  228. }
  229. }
  230. override fun onMore(view: View, position: Int) {
  231. adapter.peek(position)?.let { conversation ->
  232. val popup = PopupMenu(requireContext(), view)
  233. popup.inflate(R.menu.conversation_more)
  234. if (conversation.lastStatus.status.muted == true) {
  235. popup.menu.removeItem(R.id.status_mute_conversation)
  236. } else {
  237. popup.menu.removeItem(R.id.status_unmute_conversation)
  238. }
  239. popup.setOnMenuItemClickListener { item ->
  240. when (item.itemId) {
  241. R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
  242. R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
  243. R.id.conversation_delete -> deleteConversation(conversation)
  244. }
  245. true
  246. }
  247. popup.show()
  248. }
  249. }
  250. override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
  251. adapter.peek(position)?.let { conversation ->
  252. viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
  253. }
  254. }
  255. override fun onViewThread(position: Int) {
  256. adapter.peek(position)?.let { conversation ->
  257. viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
  258. }
  259. }
  260. override fun onOpenReblog(position: Int) {
  261. // there are no reblogs in conversations
  262. }
  263. override fun onExpandedChange(expanded: Boolean, position: Int) {
  264. adapter.peek(position)?.let { conversation ->
  265. viewModel.expandHiddenStatus(expanded, conversation)
  266. }
  267. }
  268. override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
  269. adapter.peek(position)?.let { conversation ->
  270. viewModel.showContent(isShowing, conversation)
  271. }
  272. }
  273. override fun onLoadMore(position: Int) {
  274. // not using the old way of pagination
  275. }
  276. override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
  277. adapter.peek(position)?.let { conversation ->
  278. viewModel.collapseLongStatus(isCollapsed, conversation)
  279. }
  280. }
  281. override fun onViewAccount(id: String) {
  282. val intent = AccountActivity.getIntent(requireContext(), id)
  283. startActivity(intent)
  284. }
  285. override fun onViewTag(tag: String) {
  286. val intent = StatusListActivity.newHashtagIntent(requireContext(), tag)
  287. startActivity(intent)
  288. }
  289. override fun removeItem(position: Int) {
  290. // not needed
  291. }
  292. override fun onReply(position: Int) {
  293. adapter.peek(position)?.let { conversation ->
  294. reply(conversation.lastStatus.status)
  295. }
  296. }
  297. override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
  298. adapter.peek(position)?.let { conversation ->
  299. viewModel.voteInPoll(choices, conversation)
  300. }
  301. }
  302. override fun onReselect() {
  303. if (isAdded) {
  304. binding.recyclerView.layoutManager?.scrollToPosition(0)
  305. binding.recyclerView.stopScroll()
  306. }
  307. }
  308. private fun deleteConversation(conversation: ConversationViewData) {
  309. AlertDialog.Builder(requireContext())
  310. .setMessage(R.string.dialog_delete_conversation_warning)
  311. .setNegativeButton(android.R.string.cancel, null)
  312. .setPositiveButton(android.R.string.ok) { _, _ ->
  313. viewModel.remove(conversation)
  314. }
  315. .show()
  316. }
  317. private fun onPreferenceChanged(key: String) {
  318. val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
  319. when (key) {
  320. PrefKeys.FAB_HIDE -> {
  321. hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
  322. }
  323. PrefKeys.MEDIA_PREVIEW_ENABLED -> {
  324. val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
  325. val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
  326. if (enabled != oldMediaPreviewEnabled) {
  327. adapter.mediaPreviewEnabled = enabled
  328. adapter.notifyItemRangeChanged(0, adapter.itemCount)
  329. }
  330. }
  331. }
  332. }
  333. companion object {
  334. fun newInstance() = ConversationsFragment()
  335. }
  336. }