Resets the paging3 changes of 3159 back to the (java) fragment code before.
Should be the basis for further not-so-rattling improvements.
This commit is contained in:
parent
40d771d60f
commit
add62129f8
21 changed files with 2279 additions and 1565 deletions
|
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
/*
|
||||
* 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.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowViewHolder(
|
||||
private val binding: ItemFollowBinding,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val linkListener: LinkListener
|
||||
) : 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
|
||||
)
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
|
||||
account.emojis,
|
||||
binding.notificationAccountNote,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
|
||||
private fun setupButtons(listener: NotificationActionListener, accountId: String) {
|
||||
binding.root.setOnClickListener { listener.onViewAccount(accountId) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,691 +0,0 @@
|
|||
/*
|
||||
* 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.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.RecyclerView.NO_POSITION
|
||||
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
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 = viewModel.account.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 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().getOrNull(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SyntheticAccessor")
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
newState != SCROLL_STATE_IDLE && return
|
||||
|
||||
// Save the ID of the first notification visible in the list, so the user's
|
||||
// reading position is always restorable.
|
||||
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
|
||||
header = NotificationsLoadStateAdapter { adapter.retry() },
|
||||
footer = NotificationsLoadStateAdapter { adapter.retry() }
|
||||
)
|
||||
|
||||
(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 {
|
||||
if (getView() != null) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// update post timestamps
|
||||
val updateTimestampFlow = flow {
|
||||
while (true) {
|
||||
delay(60000)
|
||||
emit(Unit)
|
||||
}
|
||||
}.onEach {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, 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.throwable.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 != 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the uiState. Nothing is done with it, but if you don't collect it then
|
||||
// accessing viewModel.uiState.value (e.g., when the filter dialog is created)
|
||||
// returns an empty object.
|
||||
launch { viewModel.uiState.collect() }
|
||||
|
||||
// Update status display from statusDisplayOptions. If the new options request
|
||||
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
|
||||
launch {
|
||||
viewModel.statusDisplayOptions
|
||||
.collectLatest {
|
||||
// NOTE this this also triggered (emitted?) on resume.
|
||||
|
||||
adapter.statusDisplayOptions = it
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, 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.errorphant_offline,
|
||||
R.string.error_network
|
||||
) { adapter.retry() }
|
||||
}
|
||||
else -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.errorphant_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)
|
||||
val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
|
||||
menu.findItem(R.id.action_refresh)?.apply {
|
||||
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
|
||||
sizeDp = 20
|
||||
colorInt = iconColor
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.action_edit_notification_filter)?.apply {
|
||||
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply {
|
||||
sizeDp = 20
|
||||
colorInt = iconColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_refresh -> {
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
onRefresh()
|
||||
true
|
||||
}
|
||||
R.id.load_newest -> {
|
||||
viewModel.accept(InfallibleUiAction.LoadNewest)
|
||||
true
|
||||
}
|
||||
R.id.action_edit_notification_filter -> {
|
||||
showFilterDialog()
|
||||
true
|
||||
}
|
||||
R.id.action_clear_notifications -> {
|
||||
confirmClearNotifications()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
binding.progressBar.isVisible = false
|
||||
adapter.refresh()
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
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().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
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, viewModel.statusDisplayOptions.value.showSensitiveMedia),
|
||||
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)
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
}
|
||||
|
||||
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://${viewModel.account.domain}/admin/reports/$reportId"
|
||||
)
|
||||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
// Empty -- this fragment doesn't remove items
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsFragment"
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
)
|
||||
}
|
||||
|
||||
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.entries[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,
|
||||
statusActionListener
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW_REQUEST -> {
|
||||
FollowRequestViewHolder(
|
||||
ItemFollowRequestBinding.inflate(inflater, parent, false),
|
||||
accountActionListener,
|
||||
statusActionListener,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
/*
|
||||
* 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.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.statusMetaInfo.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue