Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159)
* Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java * Bare minimum changes to get this to compile and run - Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener` - Removed override for accountManager, it can be used from the superclass - Add `?.` where non-nullity could not (yet) be guaranteed - Remove `?` from type lists where non-nullity is guaranteed - Explicitly convert lists to mutable where necessary - Delete unused function `findReplyPosition` * Remove all unnecessary non-null (!!) assertions The previous change meant some values are no longer nullable. Remove the non-null assertions. * Lint ListStatusAccessibilityDelegate call - Remove redundant constructor - Move block outside of `()` * Use `let` when handling compose button visibility on scroll * Replace a `requireNonNull` with `!!` * Remove redundant return values * Remove or rename unused lambda parameters * Remove unnecessary type parameters * Remove unnecessary null checks * Replace cascading-if statement with `when` * Simplify calculation of `topId` * Use more appropriate list properties and methods - Access the last value with `.last()` - Access the last index with `.lastIndex` - Replace logical-chain with `asRightOrNull` and `?.` - `.isNotEmpty()`, not `!...isEmpty()` * Inline unnecessary variable * Use PrefKeys constants instead of bare strings * Use `requireContext()` instead of `context!!` * Replace deprecated `onActivityCreated()` with `onViewCreated()` * Remove unnecessary variable setting * Replace `size == 0` check with `isEmpty()` * Format with ktlint, no functionality changes * Convert NotifcationsAdapter to Kotlin Does not compile, this is the unchanged output of the "Convert to Kotlin" function * Minimum changes to get NotificationsAdapter to compile * Remove unnecessary visibility modifiers * Use `isNotEmpty()` * Remove unused lambda parameters * Convert cascading-if to `when` * Simplifiy assignment op * Use explicit argument names with `copy()` * Use `.firstOrNull()` instead of `if` * Mark as lateinit to avoid unnecessary null checks * Format with ktlint, whitespace changes only * Bare minimum necessary to demonstrate paging in notifications Create `NotificationsPagingSource`. This uses a new `notifications2()` API call, which will exist until all the code has been adapted. Instead of using placeholders, Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`) to consume this data. Expose the paging source view a new `NotificationsViewModel` `flow`, and submit new pages to the adapter as they are available in `NotificationsFragment`. Comment out any other code in `NotificationsFragment` that deals with loading data from the network. This will be updated as necessary, either here, or in the view model. Lots of functionality is missing, including: - Different views for different notification types - Starting at the remembered notification position - Interacting with notifications - Adjusting the UI state to match the loading state These will be added incrementally. * Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter With this change `NotificationsPagingAdapter` shows notifications about a status correctly. - Introduce a `ViewHolder` abstract class that all Notification view holders derive from. Modify the fallback view holder to use this. - Implement `StatusNotificationViewHolder`. Much of the code is from the existing implementation in the `NotificationAdapater`. - The original code split the code that binds values to views between the adapter's `bindViewHolder` method and the view holder's methods. In this code, all of the binding code is in the view holder, in a `bind` method. This is called by the adapter's `bindViewHolder` method. This keeps all the binding logic in the view holder, where it belongs. - The new `StatusNotificationViewHolder` uses view binding to access its views instead of `findViewById`. - Logically, information about whether to show sensitive media, or open content warnings should be part of the `StatusDisplayOptions`. So add those as fields, and populate them appropriately. This affects code outside notification handling, which will be adjusted later. * Note some TODOs to complete before the PR is finished * Extract StatusNotificationViewHolder to a new file * Add TODO for NotificationViewData.Concrete * Convert the adapter to take NotificationViewData.Concrete * Add a view holder for regular status notifications * Migrate Follow and FollowRequest notifications * Migrate report notifications * Convert onViewThread to use the adapter data * Convert onViewMedia to use the adapter data * Convert onMore to use the adapter data * Convert onReply to use the adapter data * Convert NotificationViewData to Kotlin * Re-implement the reblog functionality - Move reblogging in to the view model - Update the UI via the adapter's `snapshot()` and `notifyItemChanged()` methods * Re-implement the favourite functionality Same approach as reblog * Re-implement the bookmark functionality Same approach as reblog * Add TODO re StatusActionListener interface * Add TODO re event handling * Re-implementing the voting functionality * Re-implement viewing hidden content - Hidden media - Content behind a content warning * Add a TODO re pinning * Re-implement "Show more" / "Show less" * Delete unused updateStatus() function * Comment out the scroll listener for the moment * Re-implement applying filters to notifications Introduce `NotificationsRepository`, to provide access to the notifications stream. When changing the filters the flow is as follows: - User clicks "Apply" in the fragment. - Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new class). - View model maintains a private flow of incoming UI actions. The new action is emitted to that flow. - In view model, `notificationFilter` waits for `.ApplyFilter` actions, and ensures the filter is saved, then emits it. - In view model, `pagingDataFlow` waits for new items from `notificationsFilter` and fetches the notifications from the repository in response. The repository provides `Notification`, so the model maps them to `NotificationViewData.Concrete` for display by the adapter. - In view model the UI state also waits for new items from `notificationsFilter` and emits a new `UiState` every time the filter is changed. When opening the fragment for the first time: - All of the above machinery, but `notificationFilter` also fetches the filter from the active account and emits that first. This triggers the first fetch and the first update of `uiState`. Also: - Add TODOs for functionality that is not implemented yet - Delete a lot of dead code from NotificationsFragment * Include important preference values in `uiState` Listen to the flow of eventHub events, filtered to preference changes that are relevant to the notification view. When preferences change (or when the view model starts), fetch the current values, and include them in `uiState`. Remove preference handling from `NotificationsFragment`, and just use the values from `uiState`. Adjust how the `useAbsoluteTime` preference is handled. The previous code loaded new content (via a diffutil) in to the adapter, which would trigger a re-binding of the timestamp. As the adapter content is immutable, the new code simply triggers a re-binding of the views that are currently visible on screen. * Update UI in response to different load states Notifications can be loaded at the top and bottom of the timeline. Add a new layout to show the progress of these loads, and any errors that can occur. Catch network errors in `NotificationsPagingSource` and convert to `LoadState.Error`. Add a header/footer to the notifications list to show the load state. Collect the load state from the adapter, use this to drive the visibility of different views. * Save and restore the last read notification ID Use this when fetching notifications, to centre the list around the notification that was last read. * Call notifyItemRangeChanged with the correct parameters * Don't try and save list position if there are no items in the list * Show/hide the "Nothing to see" view appropriately * Update comments * Handle the case where the notification key no longer exists * Re-implement support for showMediaPreview and other settings * Re-implement "hide FAB when scrolling" preference * Delete dead code * Delete Notifications Adapater and Placeholder types * Remove NotificationViewData.Concrete subclass Now there's no Placeholder, everything is a NotificationViewData. * Improve how notification pages are loaded if the first notification is missing or filtered * Re-implement clear notifications, show errors * s/default/from/ * Add missing headers * Don't process bookmarking via EventHub - Initiating a bookmark is triggered by the fragment sending a StatusUiAction.Bookmark - View model receives this, makes API call, waits for response, emits either a success or failure state - Fragment collects success/failure states, updates the UI accordingly * Don't process favourites via EventHub * Don't process reblog via EventHub * Don't process poll votes with EventHub This removes EventHub from the fragment * Respond to follow requests via the view model * Docs and cleanup * Typo and editing pass * Minor edits for clarity * Remove newline in diagram * Reorder sequence diagram * s/authorize/accept/ * s/pagingDataFlow/pagingData/ * Add brief KDoc * Try and fetch a full first page of notifications * Call the API method `notifications` again * Log UI errors at the point of handling * Remove unused variable * Replace String.format() with interpolation * Convert NotificationViewData to data class * Rename copy() to make(), to avoid confusion with default copy() method * Lint * Update app/src/main/res/layout/simple_list_item_1.xml * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt * Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt * Initial NotificationsViewModel tests * Add missing import * More tests, some cleanup * Comments, re-order some code * Set StateRestorationPolicy.PREVENT_WHEN_EMPTY * Mark clearNotifications() as "suspend" * Catch exceptions from clearNotifications and emit * Update TODOs with explanations * Ensure initial fetch uses a null ID * Stop/start collecting pagingData based on the lifecycle * Don't hide the list while refreshing * Refresh notifications on mutes and blocks * Update tests now clearNotifications is a suspend fun * Add "Refresh" menu to NotificationsFragment * Use account.name over account.displayName * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com> * Mark layoutmanager as lateinit * Mark layoutmanager as lateinit * Refactor generating UI text * Add Copyright header * Correctly apply notification filters * Show follow request header in notifications * Wait for follow request actions to complete, so the reqeuest is sent * Remove duplicate copyright header * Revert copyright change in unmodified file * Null check response body * Move NotificationsFragment to component.notifications * Use viewlifecycleowner.lifecyclescope * Show notification filter as a dialog rather than a popup window The popup window: - Is inconsistent UI - Requires a custom layout - Didn't play nicely with viewbinding * Refresh adapter on block/mute * Scroll up slightly when new content is loaded * Restore progressbar * Lint * Update app/src/main/res/layout/simple_list_item_1.xml --------- Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
This commit is contained in:
parent
ce6a350267
commit
4d401c7878
53 changed files with 4460 additions and 2262 deletions
|
|
@ -35,8 +35,14 @@ class FollowRequestsAdapter(
|
|||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
|
||||
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return FollowRequestViewHolder(binding, false)
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
showHeader = false
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ class ConversationsFragment :
|
|||
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)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowViewHolder(
|
||||
private val binding: ItemFollowBinding,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_42dp
|
||||
)
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setMessage(
|
||||
viewData.account,
|
||||
viewData.type === Notification.Type.SIGN_UP,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupButtons(notificationActionListener, viewData.account.id)
|
||||
}
|
||||
|
||||
private fun setMessage(
|
||||
account: TimelineAccount,
|
||||
isSignUp: Boolean,
|
||||
animateAvatars: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val context = binding.notificationText.context
|
||||
val format =
|
||||
context.getString(
|
||||
if (isSignUp) {
|
||||
R.string.notification_sign_up_format
|
||||
} else {
|
||||
R.string.notification_follow_format
|
||||
}
|
||||
)
|
||||
val wrappedDisplayName = account.name.unicodeWrap()
|
||||
val wholeMessage = String.format(format, wrappedDisplayName)
|
||||
val emojifiedMessage =
|
||||
wholeMessage.emojify(
|
||||
account.emojis,
|
||||
binding.notificationText,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationText.text = emojifiedMessage
|
||||
val username = context.getString(R.string.post_username_format, account.username)
|
||||
binding.notificationUsername.text = username
|
||||
val emojifiedDisplayName = wrappedDisplayName.emojify(
|
||||
account.emojis,
|
||||
binding.notificationUsername,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationDisplayName.text = emojifiedDisplayName
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.notificationAvatar,
|
||||
avatarRadius42dp,
|
||||
animateAvatars
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupButtons(listener: NotificationActionListener, accountId: String) {
|
||||
binding.root.setOnClickListener { listener.onViewAccount(accountId) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,681 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
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.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsFragment :
|
||||
SFragment(),
|
||||
StatusActionListener,
|
||||
NotificationActionListener,
|
||||
AccountActionListener,
|
||||
OnRefreshListener,
|
||||
MenuProvider,
|
||||
Injectable,
|
||||
ReselectableFragment {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
||||
|
||||
private lateinit var adapter: NotificationsPagingAdapter
|
||||
|
||||
private lateinit var layoutManager: LinearLayoutManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = NotificationsPagingAdapter(
|
||||
notificationDiffCallback,
|
||||
accountId = accountManager.activeAccount!!.accountId,
|
||||
statusActionListener = this,
|
||||
notificationActionListener = this,
|
||||
accountActionListener = this,
|
||||
statusDisplayOptions = viewModel.statusDisplayOptions.value
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
|
||||
}
|
||||
|
||||
private fun updateFilterVisibility(showFilter: Boolean) {
|
||||
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
|
||||
if (showFilter) {
|
||||
binding.appBarOptions.setExpanded(true, false)
|
||||
binding.appBarOptions.visibility = View.VISIBLE
|
||||
// Set content behaviour to hide filter on scroll
|
||||
params.behavior = ScrollingViewBehavior()
|
||||
} else {
|
||||
binding.appBarOptions.setExpanded(false, false)
|
||||
binding.appBarOptions.visibility = View.GONE
|
||||
// Clear behaviour to hide app bar
|
||||
params.behavior = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmClearNotifications() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.notification_clear_text)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
// Setup the SwipeRefreshLayout.
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
|
||||
// Setup the RecyclerView.
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||
ListStatusAccessibilityDelegate(
|
||||
binding.recyclerView,
|
||||
this
|
||||
) { pos: Int ->
|
||||
val notification = adapter.snapshot()[pos]
|
||||
// We support replies only for now
|
||||
if (notification is NotificationViewData) {
|
||||
notification.statusViewData
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context,
|
||||
DividerItemDecoration.VERTICAL
|
||||
)
|
||||
)
|
||||
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
val actionButton = (activity as ActionButtonActivity).actionButton
|
||||
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
actionButton?.let { fab ->
|
||||
if (!viewModel.uiState.value.showFabWhileScrolling) {
|
||||
if (dy > 0 && fab.isShown) {
|
||||
fab.hide() // Hide when scrolling down
|
||||
} else if (dy < 0 && !fab.isShown) {
|
||||
fab.show() // Show when scrolling up
|
||||
}
|
||||
} else if (!fab.isShown) {
|
||||
fab.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
|
||||
header = NotificationsLoadStateAdapter { adapter.retry() },
|
||||
footer = NotificationsLoadStateAdapter { adapter.retry() }
|
||||
)
|
||||
|
||||
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
|
||||
binding.buttonFilter.setOnClickListener { showFilterDialog() }
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
|
||||
false
|
||||
|
||||
// Signal the user that a refresh has loaded new items above their current position
|
||||
// by scrolling up slightly to disclose the new content
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Collect this flow to notify the adapter that the timestamps of the visible items have
|
||||
* changed
|
||||
*/
|
||||
val updateTimestampFlow = flow {
|
||||
while (true) { delay(60000); emit(Unit) }
|
||||
}.onEach {
|
||||
layoutManager.findFirstVisibleItemPosition().let { first ->
|
||||
first == RecyclerView.NO_POSITION && return@let
|
||||
val count = layoutManager.findLastVisibleItemPosition() - first
|
||||
adapter.notifyItemRangeChanged(
|
||||
first,
|
||||
count,
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
viewModel.pagingData.collectLatest { pagingData ->
|
||||
Log.d(TAG, "Submitting data to adapter")
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
// Show errors from the view model as snack bars.
|
||||
//
|
||||
// Errors are shown:
|
||||
// - Indefinitely, so the user has a chance to read and understand
|
||||
// the message
|
||||
// - With a max of 5 text lines, to allow space for longer errors.
|
||||
// E.g., on a typical device, an error message like "Bookmarking
|
||||
// post failed: Unable to resolve host 'mastodon.social': No
|
||||
// address associated with hostname" is 3 lines.
|
||||
// - With a "Retry" option if the error included a UiAction to retry.
|
||||
launch {
|
||||
viewModel.uiError.collect { error ->
|
||||
Log.d(TAG, error.toString())
|
||||
val message = getString(
|
||||
error.message,
|
||||
error.exception.localizedMessage
|
||||
?: getString(R.string.ui_error_unknown)
|
||||
)
|
||||
val snackbar = Snackbar.make(
|
||||
// Without this the FAB will not move out of the way
|
||||
(activity as ActionButtonActivity).actionButton ?: binding.root,
|
||||
message,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
).setTextMaxLines(5)
|
||||
error.action?.let { action ->
|
||||
snackbar.setAction(R.string.action_retry) {
|
||||
viewModel.accept(action)
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
|
||||
// The status view has pre-emptively updated its state to show
|
||||
// that the action succeeded. Since it hasn't, re-bind the view
|
||||
// to show the correct data.
|
||||
error.action?.let { action ->
|
||||
action is StatusAction || return@let
|
||||
|
||||
val position = adapter.snapshot().indexOfFirst {
|
||||
it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
|
||||
}
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show successful notification action as brief snackbars, so the
|
||||
// user is clear the action has happened.
|
||||
launch {
|
||||
viewModel.uiSuccess
|
||||
.filterIsInstance<NotificationActionSuccess>()
|
||||
.collect {
|
||||
Snackbar.make(
|
||||
(activity as ActionButtonActivity).actionButton ?: binding.root,
|
||||
getString(it.msg),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
when (it) {
|
||||
// The follow request is no longer valid, refresh the adapter to
|
||||
// remove it.
|
||||
is NotificationActionSuccess.AcceptFollowRequest,
|
||||
is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update adapter data when status actions are successful, and re-bind to update
|
||||
// the UI.
|
||||
launch {
|
||||
viewModel.uiSuccess
|
||||
.filterIsInstance<StatusActionSuccess>()
|
||||
.collect {
|
||||
val indexedViewData = adapter.snapshot()
|
||||
.withIndex()
|
||||
.firstOrNull { notificationViewData ->
|
||||
notificationViewData.value?.statusViewData?.status?.id ==
|
||||
it.action.statusViewData.id
|
||||
} ?: return@collect
|
||||
|
||||
val statusViewData =
|
||||
indexedViewData.value?.statusViewData ?: return@collect
|
||||
|
||||
val status = when (it) {
|
||||
is StatusActionSuccess.Bookmark ->
|
||||
statusViewData.status.copy(bookmarked = it.action.state)
|
||||
is StatusActionSuccess.Favourite ->
|
||||
statusViewData.status.copy(favourited = it.action.state)
|
||||
is StatusActionSuccess.Reblog ->
|
||||
statusViewData.status.copy(reblogged = it.action.state)
|
||||
is StatusActionSuccess.VoteInPoll ->
|
||||
statusViewData.status.copy(
|
||||
poll = it.action.poll.votedCopy(it.action.choices)
|
||||
)
|
||||
}
|
||||
indexedViewData.value?.statusViewData = statusViewData.copy(
|
||||
status = status
|
||||
)
|
||||
|
||||
adapter.notifyItemChanged(indexedViewData.index)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh adapter on mutes and blocks
|
||||
launch {
|
||||
viewModel.uiSuccess.collectLatest {
|
||||
when (it) {
|
||||
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
|
||||
adapter.refresh()
|
||||
else -> { /* nothing to do */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update filter option visibility from uiState
|
||||
launch {
|
||||
viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
|
||||
}
|
||||
|
||||
// Update status display from statusDisplayOptions. If the new options request
|
||||
// relative time display collect the flow to periodically re-bind the UI.
|
||||
launch {
|
||||
viewModel.statusDisplayOptions
|
||||
.collectLatest {
|
||||
adapter.statusDisplayOptions = it
|
||||
layoutManager.findFirstVisibleItemPosition().let { first ->
|
||||
first == RecyclerView.NO_POSITION && return@let
|
||||
val count = layoutManager.findLastVisibleItemPosition() - first
|
||||
adapter.notifyItemRangeChanged(
|
||||
first,
|
||||
count,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
if (!it.useAbsoluteTime) {
|
||||
updateTimestampFlow.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the UI from the loadState
|
||||
adapter.loadStateFlow
|
||||
.distinctUntilChangedBy { it.refresh }
|
||||
.collect { loadState ->
|
||||
binding.recyclerView.isVisible = true
|
||||
binding.progressBar.isVisible = loadState.refresh is LoadState.Loading &&
|
||||
!binding.swipeRefreshLayout.isRefreshing
|
||||
binding.swipeRefreshLayout.isRefreshing =
|
||||
loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible
|
||||
|
||||
binding.statusView.isVisible = false
|
||||
if (loadState.refresh is LoadState.NotLoading) {
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty
|
||||
)
|
||||
binding.recyclerView.isVisible = false
|
||||
binding.statusView.isVisible = true
|
||||
} else {
|
||||
binding.statusView.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
when ((loadState.refresh as LoadState.Error).error) {
|
||||
is IOException -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) { adapter.retry() }
|
||||
}
|
||||
else -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) { adapter.retry() }
|
||||
}
|
||||
}
|
||||
binding.recyclerView.isVisible = false
|
||||
binding.statusView.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.fragment_notifications, 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
|
||||
onRefresh()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
binding.progressBar.isVisible = false
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
// Save the ID of the first notification visible in the list
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position >= 0) {
|
||||
adapter.snapshot()[position]?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.reply(status)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Reblog(reblog, statusViewData))
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Favourite(favourite, statusViewData))
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData))
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
val poll = statusViewData.status.poll ?: return
|
||||
viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData))
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.more(status, view, position)
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.viewMedia(attachmentIndex, list(status), view)
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.viewThread(status.actionableId, status.actionableStatus.url)
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
val account = adapter.peek(position)?.account!!
|
||||
onViewAccount(account.id)
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isExpanded = expanded
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isShowingContent = isShowing
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
// Empty -- this fragment doesn't show placeholders
|
||||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isCollapsed = isCollapsed
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
onContentCollapsedChange(isCollapsed, position)
|
||||
}
|
||||
|
||||
private fun clearNotifications() {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.progressBar.isVisible = false
|
||||
viewModel.accept(FallibleUiAction.ClearNotifications)
|
||||
}
|
||||
|
||||
private fun showFilterDialog() {
|
||||
FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter ->
|
||||
if (viewModel.uiState.value.activeFilter != filter) {
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(filter))
|
||||
}
|
||||
}
|
||||
.show(parentFragmentManager, "dialogFilter")
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
super.viewTag(tag)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
super.viewAccount(id)
|
||||
}
|
||||
|
||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onBlock(block: Boolean, id: String, position: Int) {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
||||
if (accept) {
|
||||
viewModel.accept(NotificationAction.AcceptFollowRequest(accountId))
|
||||
} else {
|
||||
viewModel.accept(NotificationAction.RejectFollowRequest(accountId))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewThreadForStatus(status: Status) {
|
||||
super.viewThread(status.actionableId, status.actionableStatus.url)
|
||||
}
|
||||
|
||||
override fun onViewReport(reportId: String) {
|
||||
requireContext().openLink(
|
||||
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
|
||||
)
|
||||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
// Empty -- this fragment doesn't remove items
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.appBarOptions.setExpanded(true, false)
|
||||
layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationF"
|
||||
fun newInstance() = NotificationsFragment()
|
||||
|
||||
private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =
|
||||
object : DiffUtil.ItemCallback<NotificationViewData>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else {
|
||||
// If items are different - update a whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDialogFragment(
|
||||
private val activeFilter: Set<Notification.Type>,
|
||||
private val listener: ((filter: Set<Notification.Type>) -> Unit)
|
||||
) : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
|
||||
val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray()
|
||||
val checkedItems = Notification.Type.visibleTypes.map {
|
||||
!activeFilter.contains(it)
|
||||
}.toBooleanArray()
|
||||
|
||||
val builder = AlertDialog.Builder(context)
|
||||
.setTitle(R.string.notifications_apply_filter)
|
||||
.setMultiChoiceItems(items, checkedItems) { _, which, isChecked ->
|
||||
checkedItems[which] = isChecked
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val excludes: MutableSet<Notification.Type> = HashSet()
|
||||
for (i in Notification.Type.visibleTypes.indices) {
|
||||
if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i])
|
||||
}
|
||||
listener(excludes)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
return builder.create()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
|
||||
/** Show load state and retry options when loading notifications */
|
||||
class NotificationsLoadStateAdapter(
|
||||
private val retry: () -> Unit
|
||||
) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NotificationsLoadStateViewHolder {
|
||||
return NotificationsLoadStateViewHolder.create(parent, retry)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
|
||||
holder.bind(loadState)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Display the header/footer loading state to the user.
|
||||
*
|
||||
* Either:
|
||||
*
|
||||
* 1. A page is being loaded, display a progress view, or
|
||||
* 2. An error occurred, display an error message with a "retry" button
|
||||
*
|
||||
* @param retry function to invoke if the user clicks the "retry" button
|
||||
*/
|
||||
class NotificationsLoadStateViewHolder(
|
||||
private val binding: ItemNotificationsLoadStateFooterViewBinding,
|
||||
retry: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.retryButton.setOnClickListener { retry.invoke() }
|
||||
}
|
||||
|
||||
fun bind(loadState: LoadState) {
|
||||
if (loadState is LoadState.Error) {
|
||||
val ctx = binding.root.context
|
||||
binding.errorMsg.text = when (loadState.error) {
|
||||
is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception)
|
||||
// Other exceptions to consider:
|
||||
// - UnknownHostException, default text is:
|
||||
// Unable to resolve "%s": No address associated with hostname
|
||||
else -> loadState.error.localizedMessage
|
||||
}
|
||||
}
|
||||
binding.progressBar.isVisible = loadState is LoadState.Loading
|
||||
binding.retryButton.isVisible = loadState is LoadState.Error
|
||||
binding.errorMsg.isVisible = loadState is LoadState.Error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder {
|
||||
val binding = ItemNotificationsLoadStateFooterViewBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return NotificationsLoadStateViewHolder(binding, retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
||||
import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||
import com.keylesspalace.tusky.databinding.SimpleListItem1Binding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
/** How to present the notification in the UI */
|
||||
enum class NotificationViewKind {
|
||||
/** View as the original status */
|
||||
STATUS,
|
||||
|
||||
/** View as the original status, with the interaction type above */
|
||||
NOTIFICATION,
|
||||
FOLLOW,
|
||||
FOLLOW_REQUEST,
|
||||
REPORT,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun from(kind: Notification.Type?): NotificationViewKind {
|
||||
return when (kind) {
|
||||
Notification.Type.MENTION,
|
||||
Notification.Type.POLL,
|
||||
Notification.Type.UNKNOWN -> STATUS
|
||||
Notification.Type.FAVOURITE,
|
||||
Notification.Type.REBLOG,
|
||||
Notification.Type.STATUS,
|
||||
Notification.Type.UPDATE -> NOTIFICATION
|
||||
Notification.Type.FOLLOW,
|
||||
Notification.Type.SIGN_UP -> FOLLOW
|
||||
Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST
|
||||
Notification.Type.REPORT -> REPORT
|
||||
null -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationActionListener {
|
||||
fun onViewAccount(id: String)
|
||||
fun onViewThreadForStatus(status: Status)
|
||||
fun onViewReport(reportId: String)
|
||||
|
||||
/**
|
||||
* Called when the status has a content warning and the visibility of the content behind
|
||||
* the warning is being changed.
|
||||
*
|
||||
* @param expanded the desired state of the content behind the content warning
|
||||
* @param position the adapter position of the view
|
||||
*
|
||||
*/
|
||||
fun onExpandedChange(expanded: Boolean, position: Int)
|
||||
|
||||
/**
|
||||
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
|
||||
* status content is interacted with.
|
||||
*
|
||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||
* @param position The position of the status in the list.
|
||||
*/
|
||||
fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int)
|
||||
}
|
||||
|
||||
class NotificationsPagingAdapter(
|
||||
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
|
||||
/** ID of the the account that notifications are being displayed for */
|
||||
private val accountId: String,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
var statusDisplayOptions: StatusDisplayOptions
|
||||
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
|
||||
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
/** View holders in this adapter must implement this interface */
|
||||
interface ViewHolder {
|
||||
/** Bind the data from the notification and payloads to the view */
|
||||
fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return NotificationViewKind.from(getItem(position)?.type).ordinal
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
|
||||
return when (NotificationViewKind.values()[viewType]) {
|
||||
NotificationViewKind.STATUS -> {
|
||||
StatusViewHolder(
|
||||
ItemStatusBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
NotificationViewKind.NOTIFICATION -> {
|
||||
StatusNotificationViewHolder(
|
||||
ItemStatusNotificationBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
notificationActionListener,
|
||||
absoluteTimeFormatter
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW -> {
|
||||
FollowViewHolder(
|
||||
ItemFollowBinding.inflate(inflater, parent, false),
|
||||
notificationActionListener
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW_REQUEST -> {
|
||||
FollowRequestViewHolder(
|
||||
ItemFollowRequestBinding.inflate(inflater, parent, false),
|
||||
accountActionListener,
|
||||
showHeader = true
|
||||
)
|
||||
}
|
||||
NotificationViewKind.REPORT -> {
|
||||
ReportNotificationViewHolder(
|
||||
ItemReportNotificationBinding.inflate(inflater, parent, false),
|
||||
notificationActionListener
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
FallbackNotificationViewHolder(
|
||||
SimpleListItem1Binding.inflate(inflater, parent, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
bindViewHolder(holder, position, null)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
bindViewHolder(holder, position, payloads)
|
||||
}
|
||||
|
||||
private fun bindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>?
|
||||
) {
|
||||
getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification view holder to use if no other type is appropriate. Should never normally
|
||||
* be used, but is useful when migrating code.
|
||||
*/
|
||||
private class FallbackNotificationViewHolder(
|
||||
val binding: SimpleListItem1Binding
|
||||
) : ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
binding.text1.text = viewData.statusViewData?.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import okhttp3.Headers
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Models next/prev links from the "Links" header in an API response */
|
||||
data class Links(val next: String?, val prev: String?)
|
||||
|
||||
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
|
||||
class NotificationsPagingSource @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val notificationFilter: Set<Notification.Type>
|
||||
) : PagingSource<String, Notification>() {
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
|
||||
Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
|
||||
|
||||
try {
|
||||
val response = when (params) {
|
||||
is LoadParams.Refresh -> {
|
||||
getInitialPage(params)
|
||||
}
|
||||
is LoadParams.Append -> mastodonApi.notifications(
|
||||
maxId = params.key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
is LoadParams.Prepend -> mastodonApi.notifications(
|
||||
minId = params.key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return LoadResult.Error(Throwable(response.errorBody().toString()))
|
||||
}
|
||||
|
||||
val links = getPageLinks(response.headers()["link"])
|
||||
return LoadResult.Page(
|
||||
data = response.body()!!,
|
||||
nextKey = links.next,
|
||||
prevKey = links.prev
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the initial page of notifications, using params.key as the ID of the initial
|
||||
* notification to fetch.
|
||||
*
|
||||
* - If there is no key, a page of the most recent notifications is returned
|
||||
* - If the notification exists, and is not filtered, a page of notifications is returned
|
||||
* - If the notification does not exist, or is filtered, the page of notifications immediately
|
||||
* before is returned
|
||||
* - If there is no page of notifications immediately before then the page immediately after
|
||||
* is returned
|
||||
*/
|
||||
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
|
||||
// If the key is null this is straightforward, just return the most recent notifications.
|
||||
val key = params.key
|
||||
?: return@coroutineScope mastodonApi.notifications(
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
|
||||
// It's important to return *something* from this state. If an empty page is returned
|
||||
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
|
||||
//
|
||||
// In addition, the Mastodon API does not let you fetch a page that contains a given key.
|
||||
// You can fetch the page immediately before the key, or the page immediately after, but
|
||||
// you can not fetch the page itself.
|
||||
|
||||
// First, try and get the notification itself, and the notifications immediately before
|
||||
// it. This is so that a full page of results can be returned. Returning just the
|
||||
// single notification means the displayed list can jump around a bit as more data is
|
||||
// loaded.
|
||||
//
|
||||
// Make both requests, and wait for the first to complete.
|
||||
val deferredNotification = async { mastodonApi.notification(id = key) }
|
||||
val deferredNotificationPage = async {
|
||||
mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
|
||||
}
|
||||
|
||||
val notification = deferredNotification.await()
|
||||
if (notification.isSuccessful) {
|
||||
// If this was successful we must still check that the user is not filtering this type
|
||||
// of notification, as fetching a single notification ignores filters. Returning this
|
||||
// notification if the user is filtering the type is wrong.
|
||||
notification.body()?.let { body ->
|
||||
if (!notificationFilter.contains(body.type)) {
|
||||
// Notification is *not* filtered. We can return this, but need the next page of
|
||||
// notifications as well
|
||||
|
||||
// Collect all notifications in to this list
|
||||
val notifications = mutableListOf(body)
|
||||
val notificationPage = deferredNotificationPage.await()
|
||||
if (notificationPage.isSuccessful) {
|
||||
notificationPage.body()?.let {
|
||||
notifications.addAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
// "notifications" now contains at least one notification we can return, and
|
||||
// hopefully a full page.
|
||||
|
||||
// Build correct max_id and min_id links for the response. The "min_id" to use
|
||||
// when fetching the next page is the same as "key". The "max_id" is the ID of
|
||||
// the oldest notification in the list.
|
||||
val maxId = notifications.last().id
|
||||
val headers = Headers.Builder()
|
||||
.add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
|
||||
.build()
|
||||
|
||||
return@coroutineScope Response.success(notifications, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The user's last read notification was missing or is filtered. Use the page of
|
||||
// notifications chronologically older than their desired notification.
|
||||
deferredNotificationPage.await().apply {
|
||||
if (this.isSuccessful) return@coroutineScope this
|
||||
}
|
||||
|
||||
// There were no notifications older than the user's desired notification. Return the page
|
||||
// of notifications immediately newer than their desired notification.
|
||||
return@coroutineScope mastodonApi.notifications(
|
||||
minId = key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPageLinks(linkHeader: String?): Links {
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
return Links(
|
||||
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
||||
"max_id"
|
||||
),
|
||||
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
||||
"min_id"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||
anchorPage?.prevKey ?: anchorPage?.nextKey
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsPagingSource"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.InvalidatingPagingSourceFactory
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingSource
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi
|
||||
) {
|
||||
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
|
||||
|
||||
/**
|
||||
* @return flow of Mastodon [Notification], excluding all types in [filter].
|
||||
* Notifications are loaded in [pageSize] increments.
|
||||
*/
|
||||
fun getNotificationsStream(
|
||||
filter: Set<Notification.Type>,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
initialKey: String? = null
|
||||
): Flow<PagingData<Notification>> {
|
||||
Log.d(TAG, "getNotificationsStream(), filtering: $filter")
|
||||
|
||||
factory = InvalidatingPagingSourceFactory {
|
||||
NotificationsPagingSource(mastodonApi, filter)
|
||||
}
|
||||
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = pageSize),
|
||||
initialKey = initialKey,
|
||||
pagingSourceFactory = factory!!
|
||||
).flow
|
||||
}
|
||||
|
||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
||||
fun invalidate() {
|
||||
factory?.invalidate()
|
||||
}
|
||||
|
||||
/** Clear notifications */
|
||||
suspend fun clearNotifications(): Response<ResponseBody> {
|
||||
return mastodonApi.clearNotifications()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsRepository"
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
data class UiState(
|
||||
/** Filtered notification types */
|
||||
val activeFilter: Set<Notification.Type> = emptySet(),
|
||||
|
||||
/** True if the UI to filter and clear notifications should be shown */
|
||||
val showFilterOptions: Boolean = false,
|
||||
|
||||
/** True if the FAB should be shown while scrolling */
|
||||
val showFabWhileScrolling: Boolean = true
|
||||
)
|
||||
|
||||
/** Preferences the UI reacts to */
|
||||
data class UiPrefs(
|
||||
val showFabWhileScrolling: Boolean,
|
||||
val showFilter: Boolean
|
||||
) {
|
||||
companion object {
|
||||
/** Relevant preference keys. Changes to any of these trigger a display update */
|
||||
val prefKeys = setOf(
|
||||
PrefKeys.FAB_HIDE,
|
||||
PrefKeys.SHOW_NOTIFICATIONS_FILTER
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parent class for all UI actions, fallible or infallible. */
|
||||
sealed class UiAction
|
||||
|
||||
/** Actions the user can trigger from the UI. These actions may fail. */
|
||||
sealed class FallibleUiAction : UiAction() {
|
||||
/** Clear all notifications */
|
||||
object ClearNotifications : FallibleUiAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions the user can trigger from the UI that either cannot fail, or if they do fail,
|
||||
* do not show an error.
|
||||
*/
|
||||
sealed class InfallibleUiAction : UiAction() {
|
||||
/** Apply a new filter to the notification list */
|
||||
// This saves the list to the local database, which triggers a refresh of the data.
|
||||
// Saving the data can't fail, which is why this is infallible. Refreshing the
|
||||
// data may fail, but that's handled by the paging system / adapter refresh logic.
|
||||
data class ApplyFilter(val filter: Set<Notification.Type>) : InfallibleUiAction()
|
||||
|
||||
/**
|
||||
* User is leaving the fragment, save the ID of the visible notification.
|
||||
*
|
||||
* Infallible because if it fails there's nowhere to show the error, and nothing the user
|
||||
* can do.
|
||||
*/
|
||||
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
|
||||
}
|
||||
|
||||
/** Actions the user can trigger on an individual notification. These may fail. */
|
||||
sealed class NotificationAction : FallibleUiAction() {
|
||||
data class AcceptFollowRequest(val accountId: String) : NotificationAction()
|
||||
|
||||
data class RejectFollowRequest(val accountId: String) : NotificationAction()
|
||||
}
|
||||
|
||||
sealed class UiSuccess {
|
||||
// These three are from menu items on the status. Currently they don't come to the
|
||||
// viewModel as actions, they're noticed when events are posted. That will change,
|
||||
// but for the moment we can still report them to the UI. Typically, receiving any
|
||||
// of these three should trigger the UI to refresh.
|
||||
|
||||
/** A user was blocked */
|
||||
object Block : UiSuccess()
|
||||
|
||||
/** A user was muted */
|
||||
object Mute : UiSuccess()
|
||||
|
||||
/** A conversation was muted */
|
||||
object MuteConversation : UiSuccess()
|
||||
}
|
||||
|
||||
/** The result of a successful action on a notification */
|
||||
sealed class NotificationActionSuccess(
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val msg: Int,
|
||||
|
||||
/**
|
||||
* The original action, in case additional information is required from it to display the
|
||||
* message.
|
||||
*/
|
||||
open val action: NotificationAction
|
||||
) : UiSuccess() {
|
||||
data class AcceptFollowRequest(override val action: NotificationAction) :
|
||||
NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action)
|
||||
data class RejectFollowRequest(override val action: NotificationAction) :
|
||||
NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun from(action: NotificationAction) = when (action) {
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions the user can trigger on an individual status */
|
||||
sealed class StatusAction(
|
||||
open val statusViewData: StatusViewData.Concrete
|
||||
) : FallibleUiAction() {
|
||||
/** Set the bookmark state for a status */
|
||||
data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Set the favourite state for a status */
|
||||
data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Set the reblog state for a status */
|
||||
data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Vote in a poll */
|
||||
data class VoteInPoll(
|
||||
val poll: Poll,
|
||||
val choices: List<Int>,
|
||||
override val statusViewData: StatusViewData.Concrete
|
||||
) : StatusAction(statusViewData)
|
||||
}
|
||||
|
||||
/** Changes to a status' visible state after API calls */
|
||||
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
|
||||
data class Bookmark(override val action: StatusAction.Bookmark) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class Favourite(override val action: StatusAction.Favourite) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class Reblog(override val action: StatusAction.Reblog) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class VoteInPoll(override val action: StatusAction.VoteInPoll) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
companion object {
|
||||
fun from(action: StatusAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(action)
|
||||
is StatusAction.Favourite -> Favourite(action)
|
||||
is StatusAction.Reblog -> Reblog(action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Errors from fallible view model actions that the UI will need to show */
|
||||
sealed class UiError(
|
||||
/** The exception associated with the error */
|
||||
open val exception: Exception,
|
||||
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val message: Int,
|
||||
|
||||
/** The action that failed. Can be resent to retry the action */
|
||||
open val action: UiAction? = null
|
||||
) {
|
||||
data class ClearNotifications(override val exception: Exception) : UiError(
|
||||
exception,
|
||||
R.string.ui_error_clear_notifications
|
||||
)
|
||||
|
||||
data class Bookmark(
|
||||
override val exception: Exception,
|
||||
override val action: StatusAction.Bookmark
|
||||
) : UiError(exception, R.string.ui_error_bookmark, action)
|
||||
|
||||
data class Favourite(
|
||||
override val exception: Exception,
|
||||
override val action: StatusAction.Favourite
|
||||
) : UiError(exception, R.string.ui_error_favourite, action)
|
||||
|
||||
data class Reblog(
|
||||
override val exception: Exception,
|
||||
override val action: StatusAction.Reblog
|
||||
) : UiError(exception, R.string.ui_error_reblog, action)
|
||||
|
||||
data class VoteInPoll(
|
||||
override val exception: Exception,
|
||||
override val action: StatusAction.VoteInPoll
|
||||
) : UiError(exception, R.string.ui_error_vote, action)
|
||||
|
||||
data class AcceptFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val action: NotificationAction.AcceptFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_accept_follow_request, action)
|
||||
|
||||
data class RejectFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val action: NotificationAction.RejectFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_reject_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(exception, action)
|
||||
is StatusAction.Favourite -> Favourite(exception, action)
|
||||
is StatusAction.Reblog -> Reblog(exception, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val repository: NotificationsRepository,
|
||||
private val preferences: SharedPreferences,
|
||||
private val accountManager: AccountManager,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<UiState>
|
||||
|
||||
/** Flow of changes to statusDisplayOptions, for use by the UI */
|
||||
val statusDisplayOptions: StateFlow<StatusDisplayOptions>
|
||||
|
||||
val pagingData: Flow<PagingData<NotificationViewData>>
|
||||
|
||||
/** Flow of user actions received from the UI */
|
||||
private val uiAction = MutableSharedFlow<UiAction>()
|
||||
|
||||
/** Flow of successful action results */
|
||||
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not
|
||||
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
|
||||
// flow (e.g., after a device orientation change) should not re-show the most recent success or
|
||||
// error message, as it will be confusing to the user.
|
||||
val uiSuccess = MutableSharedFlow<UiSuccess>()
|
||||
|
||||
/** Flow of transient errors for the UI to present */
|
||||
val uiError = MutableSharedFlow<UiError>()
|
||||
|
||||
/** Accept UI actions in to actionStateFlow */
|
||||
val accept: (UiAction) -> Unit = { action ->
|
||||
viewModelScope.launch { uiAction.emit(action) }
|
||||
}
|
||||
|
||||
init {
|
||||
// Handle changes to notification filters
|
||||
val notificationFilter = uiAction
|
||||
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
|
||||
.distinctUntilChanged()
|
||||
// Save each change back to the active account
|
||||
.onEach { action ->
|
||||
Log.d(TAG, "notificationFilter: $action")
|
||||
accountManager.activeAccount?.let { account ->
|
||||
account.notificationsFilter = serialize(action.filter)
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
// Load the initial filter from the active account
|
||||
.onStart {
|
||||
emit(
|
||||
InfallibleUiAction.ApplyFilter(
|
||||
filter = deserialize(accountManager.activeAccount?.notificationsFilter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Save the visible notification ID
|
||||
viewModelScope.launch {
|
||||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
Log.d(TAG, "Saving visible ID: ${action.visibleId}")
|
||||
accountManager.activeAccount?.let { account ->
|
||||
account.lastNotificationId = action.visibleId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial status display options from the user's preferences.
|
||||
//
|
||||
// Then collect future preference changes and emit new values in to
|
||||
// statusDisplayOptions if necessary.
|
||||
statusDisplayOptions = MutableStateFlow(
|
||||
StatusDisplayOptions.from(
|
||||
preferences,
|
||||
accountManager.activeAccount!!
|
||||
)
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events.asFlow()
|
||||
.filterIsInstance<PreferenceChangedEvent>()
|
||||
.filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) }
|
||||
.map {
|
||||
statusDisplayOptions.value.make(
|
||||
preferences,
|
||||
it.preferenceKey,
|
||||
accountManager.activeAccount!!
|
||||
)
|
||||
}
|
||||
.collect {
|
||||
statusDisplayOptions.emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle UiAction.ClearNotifications
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<FallibleUiAction.ClearNotifications>()
|
||||
.collectLatest {
|
||||
try {
|
||||
repository.clearNotifications().apply {
|
||||
if (this.isSuccessful) {
|
||||
repository.invalidate()
|
||||
} else {
|
||||
uiError.emit(UiError.make(HttpException(this), it))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle NotificationAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<NotificationAction>()
|
||||
.debounce(DEBOUNCE_TIMEOUT_MS)
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
is NotificationAction.AcceptFollowRequest ->
|
||||
timelineCases.acceptFollowRequest(action.accountId).await()
|
||||
is NotificationAction.RejectFollowRequest ->
|
||||
timelineCases.rejectFollowRequest(action.accountId).await()
|
||||
}
|
||||
uiSuccess.emit(NotificationActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle StatusAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<StatusAction>()
|
||||
.debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
is StatusAction.Bookmark ->
|
||||
timelineCases.bookmark(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
).await()
|
||||
is StatusAction.Favourite ->
|
||||
timelineCases.favourite(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
).await()
|
||||
is StatusAction.Reblog ->
|
||||
timelineCases.reblog(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
).await()
|
||||
is StatusAction.VoteInPoll ->
|
||||
timelineCases.voteInPoll(
|
||||
action.statusViewData.actionableId,
|
||||
action.poll.id,
|
||||
action.choices
|
||||
).await()
|
||||
}
|
||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle events that should refresh the list
|
||||
viewModelScope.launch {
|
||||
eventHub.events.asFlow().collectLatest {
|
||||
when (it) {
|
||||
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
|
||||
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
|
||||
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The database stores "0" as the last notification ID if notifications have not been
|
||||
// fetched. Convert to null to ensure a full fetch in this case
|
||||
val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) {
|
||||
"0" -> null
|
||||
else -> id
|
||||
}
|
||||
Log.d(TAG, "Restoring at $lastNotificationId")
|
||||
|
||||
pagingData = notificationFilter
|
||||
.flatMapLatest { action ->
|
||||
getNotifications(filters = action.filter, initialKey = lastNotificationId)
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
|
||||
UiState(
|
||||
activeFilter = filter.filter,
|
||||
showFilterOptions = prefs.showFilter,
|
||||
showFabWhileScrolling = prefs.showFabWhileScrolling
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
|
||||
initialValue = UiState()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getNotifications(
|
||||
filters: Set<Notification.Type>,
|
||||
initialKey: String? = null
|
||||
): Flow<PagingData<NotificationViewData>> {
|
||||
return repository.getNotificationsStream(filter = filters, initialKey = initialKey)
|
||||
.map { pagingData ->
|
||||
pagingData.map { notification ->
|
||||
notification.toViewData(
|
||||
isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
|
||||
!(notification.status?.actionableStatus?.sensitive ?: false),
|
||||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||
isCollapsed = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Flow of relevant preferences that change the UI
|
||||
*/
|
||||
// TODO: Preferences should be in a repository
|
||||
private fun getUiPrefs() = eventHub.events.asFlow()
|
||||
.filterIsInstance<PreferenceChangedEvent>()
|
||||
.filter { UiPrefs.prefKeys.contains(it.preferenceKey) }
|
||||
.map { toPrefs() }
|
||||
.onStart { emit(toPrefs()) }
|
||||
|
||||
private fun toPrefs() = UiPrefs(
|
||||
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
|
||||
showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsViewModel"
|
||||
private const val DEBOUNCE_TIMEOUT_MS = 500L
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) {
|
|||
|
||||
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
Notification.Type.asList.forEach {
|
||||
Notification.Type.visibleTypes.forEach {
|
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.InputFilter
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* View holder for a status with an activity to be notified about (posted, boosted,
|
||||
* favourited, or edited, per [NotificationViewKind.from]).
|
||||
*
|
||||
* Shows a line with the activity, and who initiated the activity. Clicking this should
|
||||
* go to the profile page for the initiator.
|
||||
*
|
||||
* Displays the original status below that. Clicking this should go to the original
|
||||
* status in context.
|
||||
*/
|
||||
internal class StatusNotificationViewHolder(
|
||||
private val binding: ItemStatusNotificationBinding,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val absoluteTimeFormatter: AbsoluteTimeFormatter
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_48dp
|
||||
)
|
||||
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_36dp
|
||||
)
|
||||
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_24dp
|
||||
)
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
val statusViewData = viewData.statusViewData
|
||||
if (payloads.isNullOrEmpty()) {
|
||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
||||
if (statusViewData == null) {
|
||||
showNotificationContent(false)
|
||||
} else {
|
||||
showNotificationContent(true)
|
||||
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
|
||||
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
|
||||
setUsername(account.username)
|
||||
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
|
||||
if (viewData.type == Notification.Type.STATUS ||
|
||||
viewData.type == Notification.Type.UPDATE
|
||||
) {
|
||||
setAvatar(
|
||||
account.avatar,
|
||||
account.bot,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
} else {
|
||||
setAvatars(
|
||||
account.avatar,
|
||||
viewData.account.avatar,
|
||||
statusDisplayOptions.animateAvatars
|
||||
)
|
||||
}
|
||||
|
||||
binding.notificationContainer.setOnClickListener {
|
||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
||||
}
|
||||
binding.notificationContent.setOnClickListener {
|
||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
||||
}
|
||||
binding.notificationTopText.setOnClickListener {
|
||||
notificationActionListener.onViewAccount(viewData.account.id)
|
||||
}
|
||||
}
|
||||
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
|
||||
} else {
|
||||
for (item in payloads) {
|
||||
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
|
||||
setCreatedAt(
|
||||
statusViewData.status.actionableStatus.createdAt,
|
||||
statusDisplayOptions.useAbsoluteTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationContent(show: Boolean) {
|
||||
binding.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningDescription.visibility =
|
||||
if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningButton.visibility =
|
||||
if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun setDisplayName(name: String, emojis: List<Emoji>?, animateEmojis: Boolean) {
|
||||
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
|
||||
binding.statusDisplayName.text = emojifiedName
|
||||
}
|
||||
|
||||
private fun setUsername(name: String) {
|
||||
val context = binding.statusUsername.context
|
||||
val format = context.getString(R.string.post_username_format)
|
||||
val usernameText = String.format(format, name)
|
||||
binding.statusUsername.text = usernameText
|
||||
}
|
||||
|
||||
private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
|
||||
if (useAbsoluteTime) {
|
||||
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
val readout: String
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
val readoutAloud: CharSequence
|
||||
if (createdAt != null) {
|
||||
val then = createdAt.time
|
||||
val now = Date().time
|
||||
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
|
||||
readoutAloud = DateUtils.getRelativeTimeSpanString(
|
||||
then,
|
||||
now,
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
)
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m"
|
||||
readoutAloud = "? minutes"
|
||||
}
|
||||
binding.statusMetaInfo.text = readout
|
||||
binding.statusMetaInfo.contentDescription = readoutAloud
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIconWithColor(
|
||||
context: Context,
|
||||
@DrawableRes drawable: Int,
|
||||
@ColorRes color: Int
|
||||
): Drawable? {
|
||||
val icon = ContextCompat.getDrawable(context, drawable)
|
||||
icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
|
||||
return icon
|
||||
}
|
||||
|
||||
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
|
||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
|
||||
loadAvatar(
|
||||
statusAvatarUrl,
|
||||
binding.notificationStatusAvatar,
|
||||
avatarRadius48dp,
|
||||
animateAvatars
|
||||
)
|
||||
if (showBotOverlay && isBot) {
|
||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||
Glide.with(binding.notificationNotificationAvatar)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(binding.notificationNotificationAvatar)
|
||||
} else {
|
||||
binding.notificationNotificationAvatar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
|
||||
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
|
||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
|
||||
loadAvatar(
|
||||
statusAvatarUrl,
|
||||
binding.notificationStatusAvatar,
|
||||
avatarRadius36dp,
|
||||
animateAvatars
|
||||
)
|
||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||
loadAvatar(
|
||||
notificationAvatarUrl,
|
||||
binding.notificationNotificationAvatar,
|
||||
avatarRadius24dp,
|
||||
animateAvatars
|
||||
)
|
||||
}
|
||||
|
||||
fun setMessage(
|
||||
notificationViewData: NotificationViewData,
|
||||
listener: LinkListener,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val statusViewData = notificationViewData.statusViewData
|
||||
val displayName = notificationViewData.account.name.unicodeWrap()
|
||||
val type = notificationViewData.type
|
||||
val context = binding.notificationTopText.context
|
||||
val format: String
|
||||
val icon: Drawable?
|
||||
when (type) {
|
||||
Notification.Type.FAVOURITE -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||
format = context.getString(R.string.notification_favourite_format)
|
||||
}
|
||||
Notification.Type.REBLOG -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_reblog_format)
|
||||
}
|
||||
Notification.Type.STATUS -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_subscription_format)
|
||||
}
|
||||
Notification.Type.UPDATE -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_update_format)
|
||||
}
|
||||
else -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||
format = context.getString(R.string.notification_favourite_format)
|
||||
}
|
||||
}
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
|
||||
icon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
val wholeMessage = String.format(format, displayName)
|
||||
val str = SpannableStringBuilder(wholeMessage)
|
||||
val displayNameIndex = format.indexOf("%s")
|
||||
str.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
displayNameIndex,
|
||||
displayNameIndex + displayName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
val emojifiedText = str.emojify(
|
||||
notificationViewData.account.emojis,
|
||||
binding.notificationTopText,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationTopText.text = emojifiedText
|
||||
if (statusViewData != null) {
|
||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
||||
binding.notificationContentWarningDescription.visibility =
|
||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningButton.visibility =
|
||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||
if (statusViewData.isExpanded) {
|
||||
binding.notificationContentWarningButton.setText(
|
||||
R.string.post_content_warning_show_less
|
||||
)
|
||||
} else {
|
||||
binding.notificationContentWarningButton.setText(
|
||||
R.string.post_content_warning_show_more
|
||||
)
|
||||
}
|
||||
binding.notificationContentWarningButton.setOnClickListener {
|
||||
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onExpandedChange(
|
||||
!statusViewData.isExpanded,
|
||||
bindingAdapterPosition
|
||||
)
|
||||
}
|
||||
binding.notificationContent.visibility =
|
||||
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
|
||||
}
|
||||
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupContentAndSpoiler(
|
||||
listener: LinkListener,
|
||||
statusViewData: StatusViewData.Concrete,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val shouldShowContentIfSpoiler = statusViewData.isExpanded
|
||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||
binding.notificationContent.visibility = View.GONE
|
||||
} else {
|
||||
binding.notificationContent.visibility = View.VISIBLE
|
||||
}
|
||||
val content = statusViewData.content
|
||||
val emojis = statusViewData.actionable.emojis
|
||||
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
|
||||
binding.buttonToggleNotificationContent.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onNotificationContentCollapsedChange(
|
||||
!statusViewData.isCollapsed,
|
||||
position
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
|
||||
if (statusViewData.isCollapsed) {
|
||||
binding.buttonToggleNotificationContent.setText(
|
||||
R.string.post_content_warning_show_more
|
||||
)
|
||||
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
|
||||
} else {
|
||||
binding.buttonToggleNotificationContent.setText(
|
||||
R.string.post_content_warning_show_less
|
||||
)
|
||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||
}
|
||||
} else {
|
||||
binding.buttonToggleNotificationContent.visibility = View.GONE
|
||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||
}
|
||||
val emojifiedText =
|
||||
content.emojify(
|
||||
emojis,
|
||||
binding.notificationContent,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(
|
||||
binding.notificationContent,
|
||||
emojifiedText,
|
||||
statusViewData.actionable.mentions,
|
||||
statusViewData.actionable.tags,
|
||||
listener
|
||||
)
|
||||
val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
|
||||
statusViewData.actionable.emojis,
|
||||
binding.notificationContentWarningDescription,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationContentWarningDescription.text = emojifiedContentWarning
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
|
||||
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright 2023 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.notifications
|
||||
|
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
internal class StatusViewHolder(
|
||||
binding: ItemStatusBinding,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val accountId: String
|
||||
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
val statusViewData = viewData.statusViewData
|
||||
if (statusViewData == null) {
|
||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
||||
showStatusContent(false)
|
||||
} else {
|
||||
if (payloads.isNullOrEmpty()) {
|
||||
showStatusContent(true)
|
||||
}
|
||||
setupWithStatus(
|
||||
statusViewData,
|
||||
statusActionListener,
|
||||
statusDisplayOptions,
|
||||
payloads?.firstOrNull()
|
||||
)
|
||||
}
|
||||
if (viewData.type == Notification.Type.POLL) {
|
||||
setPollInfo(accountId == viewData.account.id)
|
||||
} else {
|
||||
hideStatusInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +156,9 @@ class ReportStatusesFragment :
|
|||
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)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
|
||||
adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
|||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.Status.Mention
|
||||
|
|
@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
override val data: Flow<PagingData<StatusViewData.Concrete>>
|
||||
get() = viewModel.statusesFlow
|
||||
|
|
@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
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)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
|
||||
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||
|
|
|
|||
|
|
@ -191,7 +191,9 @@ class TimelineFragment :
|
|||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
adapter = TimelinePagingAdapter(
|
||||
statusDisplayOptions,
|
||||
|
|
@ -226,16 +228,16 @@ class TimelineFragment :
|
|||
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)
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
|
||||
}
|
||||
}
|
||||
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)
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic)
|
||||
}
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ class ViewThreadFragment :
|
|||
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)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
adapter = ThreadAdapter(statusDisplayOptions, this)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue