feat: Show posts that mention a trending link (#1153)

Mastodon 4.3 introduced a new API to fetch a timeline of posts that
mention a trending link.

Use that to display a "See <n> posts about ths link" message in a
trending link's preview card (if supported by the server).

Define a new timeline type with associated API call to fetch the
timeline.

Add an accessibilty action to support this.

While I'm here also support author's in preview cards that don't have a
related Fediverse account; show their name in this case.

Fixes #1123
This commit is contained in:
Nik Clayton 2024-12-03 23:00:31 +01:00 committed by GitHub
parent cfab7a9dfe
commit 57be148fbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 317 additions and 109 deletions

View File

@ -161,6 +161,7 @@ data class TabViewData(
icon = R.drawable.ic_favourite_filled_24dp, icon = R.drawable.ic_favourite_filled_24dp,
fragment = { TimelineFragment.newInstance(pachliAccountId, timeline) }, fragment = { TimelineFragment.newInstance(pachliAccountId, timeline) },
) )
is Timeline.Link -> throw IllegalArgumentException("can't add to tab: $timeline")
is Timeline.User.Pinned -> throw IllegalArgumentException("can't add to tab: $timeline") is Timeline.User.Pinned -> throw IllegalArgumentException("can't add to tab: $timeline")
is Timeline.User.Posts -> throw IllegalArgumentException("can't add to tab: $timeline") is Timeline.User.Posts -> throw IllegalArgumentException("can't add to tab: $timeline")
is Timeline.User.Replies -> throw IllegalArgumentException("can't add to tab: $timeline") is Timeline.User.Replies -> throw IllegalArgumentException("can't add to tab: $timeline")

View File

@ -895,7 +895,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
(!viewData.isCollapsible || !viewData.isCollapsed) (!viewData.isCollapsible || !viewData.isCollapsed)
) { ) {
cardView.visibility = View.VISIBLE cardView.visibility = View.VISIBLE
cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions) { card, target -> cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target ->
if (target == PreviewCardView.Target.BYLINE) { if (target == PreviewCardView.Target.BYLINE) {
card.authors?.firstOrNull()?.account?.id?.let { card.authors?.firstOrNull()?.account?.id?.let {
context.startActivity(AccountActivityIntent(context, pachliAccountId, it)) context.startActivity(AccountActivityIntent(context, pachliAccountId, it))

View File

@ -772,6 +772,7 @@ class TimelineFragment :
Timeline.Notifications, Timeline.Notifications,
Timeline.TrendingHashtags, Timeline.TrendingHashtags,
Timeline.TrendingLinks, Timeline.TrendingLinks,
is Timeline.Link,
-> return -> return
} }
} }

View File

@ -121,6 +121,7 @@ class NetworkTimelineRemoteMediator(
Timeline.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = loadSize) Timeline.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = loadSize)
Timeline.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = loadSize) Timeline.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = loadSize)
Timeline.TrendingStatuses -> api.trendingStatuses(limit = LIMIT_TRENDING_STATUSES) Timeline.TrendingStatuses -> api.trendingStatuses(limit = LIMIT_TRENDING_STATUSES)
is Timeline.Link -> api.linkTimeline(url = timeline.url, maxId = maxId, minId = minId, limit = loadSize)
is Timeline.Hashtags -> { is Timeline.Hashtags -> {
val firstHashtag = timeline.tags.first() val firstHashtag = timeline.tags.first()
val additionalHashtags = timeline.tags.subList(1, timeline.tags.size) val additionalHashtags = timeline.tags.subList(1, timeline.tags.size)

View File

@ -29,9 +29,15 @@ class TrendingLinkViewHolder(
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
internal lateinit var link: TrendsLink internal lateinit var link: TrendsLink
fun bind(link: TrendsLink, statusDisplayOptions: StatusDisplayOptions) { /**
* @param link
* @param statusDisplayOptions
* @param showTimelineLink True if the UI to view a timeline of statuses about this link
* should be shown.
*/
fun bind(link: TrendsLink, statusDisplayOptions: StatusDisplayOptions, showTimelineLink: Boolean) {
this.link = link this.link = link
binding.statusCardView.bind(link, sensitive = false, statusDisplayOptions, onClick) binding.statusCardView.bind(link, sensitive = false, statusDisplayOptions, showTimelineLink, onClick)
} }
} }

View File

@ -36,6 +36,9 @@ import dagger.hilt.android.EntryPointAccessors
* Each item shows an action to open the link. * Each item shows an action to open the link.
* *
* If present, an item to show the author's account is also included. * If present, an item to show the author's account is also included.
*
* If supported, an item to show a timeline of statuses that mention this link
* is included.
*/ */
internal class TrendingLinksAccessibilityDelegate( internal class TrendingLinksAccessibilityDelegate(
private val recyclerView: RecyclerView, private val recyclerView: RecyclerView,
@ -59,6 +62,11 @@ internal class TrendingLinksAccessibilityDelegate(
context.getString(R.string.action_open_byline_account), context.getString(R.string.action_open_byline_account),
) )
private val openTimelineLinkAction = AccessibilityActionCompat(
app.pachli.core.ui.R.id.action_timeline_link,
context.getString(R.string.action_timeline_link),
)
private val delegate = object : ItemDelegate(this) { private val delegate = object : ItemDelegate(this) {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info) super.onInitializeAccessibilityNodeInfo(host, info)
@ -72,6 +80,10 @@ internal class TrendingLinksAccessibilityDelegate(
viewHolder.link.authors?.firstOrNull()?.account?.let { viewHolder.link.authors?.firstOrNull()?.account?.let {
info.addAction(openBylineAccountAction) info.addAction(openBylineAccountAction)
} }
if ((recyclerView.adapter as? TrendingLinksAdapter)?.showTimelineLink == true) {
info.addAction(openTimelineLinkAction)
}
} }
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean {
@ -93,6 +105,11 @@ internal class TrendingLinksAccessibilityDelegate(
listener.onClick(viewHolder.link, Target.BYLINE) listener.onClick(viewHolder.link, Target.BYLINE)
true true
} }
app.pachli.core.ui.R.id.action_timeline_link -> {
interrupt()
listener.onClick(viewHolder.link, Target.TIMELINE_LINK)
true
}
else -> super.performAccessibilityAction(host, action, args) else -> super.performAccessibilityAction(host, action, args)
} }
} }

View File

@ -27,8 +27,15 @@ import app.pachli.core.network.model.TrendsLink
import app.pachli.databinding.ItemTrendingLinkBinding import app.pachli.databinding.ItemTrendingLinkBinding
import app.pachli.view.PreviewCardView import app.pachli.view.PreviewCardView
/**
* @param statusDisplayOptions
* @param showTimelineLink If true, show a link to a timeline with statuses that
* mention this link.
* @param onViewLink
*/
class TrendingLinksAdapter( class TrendingLinksAdapter(
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
showTimelineLink: Boolean,
private val onViewLink: PreviewCardView.OnClickListener, private val onViewLink: PreviewCardView.OnClickListener,
) : ListAdapter<TrendsLink, TrendingLinkViewHolder>(diffCallback) { ) : ListAdapter<TrendsLink, TrendingLinkViewHolder>(diffCallback) {
var statusDisplayOptions = statusDisplayOptions var statusDisplayOptions = statusDisplayOptions
@ -37,6 +44,14 @@ class TrendingLinksAdapter(
notifyItemRangeChanged(0, itemCount) notifyItemRangeChanged(0, itemCount)
} }
var showTimelineLink = showTimelineLink
set(value) {
if (field != value) {
field = value
notifyItemRangeChanged(0, itemCount)
}
}
init { init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
} }
@ -49,7 +64,7 @@ class TrendingLinksAdapter(
} }
override fun onBindViewHolder(holder: TrendingLinkViewHolder, position: Int) { override fun onBindViewHolder(holder: TrendingLinkViewHolder, position: Int) {
holder.bind(getItem(position), statusDisplayOptions) holder.bind(getItem(position), statusDisplayOptions, showTimelineLink)
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {

View File

@ -45,7 +45,9 @@ import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.core.model.ServerOperation
import app.pachli.core.navigation.AccountActivityIntent import app.pachli.core.navigation.AccountActivityIntent
import app.pachli.core.navigation.TimelineActivityIntent
import app.pachli.core.network.model.PreviewCard import app.pachli.core.network.model.PreviewCard
import app.pachli.core.ui.ActionButtonScrollListener import app.pachli.core.ui.ActionButtonScrollListener
import app.pachli.core.ui.BackgroundMessage import app.pachli.core.ui.BackgroundMessage
@ -60,8 +62,11 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import io.github.z4kn4fein.semver.constraints.toConstraint
import kotlin.properties.Delegates import kotlin.properties.Delegates
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
import timber.log.Timber import timber.log.Timber
@ -74,7 +79,13 @@ class TrendingLinksFragment :
RefreshableFragment, RefreshableFragment,
MenuProvider { MenuProvider {
private val viewModel: TrendingLinksViewModel by viewModels() private val viewModel: TrendingLinksViewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TrendingLinksViewModel.Factory> { factory ->
factory.create(requireArguments().getLong(ARG_PACHLI_ACCOUNT_ID))
}
},
)
private val binding by viewBinding(FragmentTrendingLinksBinding::bind) private val binding by viewBinding(FragmentTrendingLinksBinding::bind)
@ -99,11 +110,6 @@ class TrendingLinksFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
trendingLinksAdapter = TrendingLinksAdapter(viewModel.statusDisplayOptions.value, ::onOpenLink)
setupSwipeRefreshLayout()
setupRecyclerView()
(activity as? ActionButtonActivity)?.actionButton?.let { actionButton -> (activity as? ActionButtonActivity)?.actionButton?.let { actionButton ->
actionButton.show() actionButton.show()
@ -119,68 +125,91 @@ class TrendingLinksFragment :
} }
} }
trendingLinksAdapter = TrendingLinksAdapter(
viewModel.statusDisplayOptions.value,
false,
::onOpenLink,
)
setupSwipeRefreshLayout()
setupRecyclerView()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.loadState.collectLatest { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
when (it) { launch {
LoadState.Initial -> { viewModel.pachliAccountFlow.distinctUntilChangedBy { it.server }.collect { account ->
viewModel.accept(InfallibleUiAction.Reload) trendingLinksAdapter.showTimelineLink = account.server.can(
ServerOperation.ORG_JOINMASTODON_TIMELINES_LINK,
">=1.0.0".toConstraint(),
)
} }
}
LoadState.Loading -> { launch {
if (!binding.swipeRefreshLayout.isRefreshing) { viewModel.loadState.collect {
binding.progressBar.show() when (it) {
} else { LoadState.Loading -> bindLoading()
binding.progressBar.hide() is LoadState.Success -> bindSuccess(it)
is LoadState.Error -> bindError(it)
} }
} }
}
is LoadState.Success -> { launch {
trendingLinksAdapter.submitList(it.data) viewModel.statusDisplayOptions.collectLatest {
binding.progressBar.hide() trendingLinksAdapter.statusDisplayOptions = it
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isEmpty()) {
binding.messageView.setup(BackgroundMessage.Empty())
binding.messageView.show()
} else {
binding.messageView.hide()
binding.recyclerView.show()
}
}
is LoadState.Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.hide()
if (trendingLinksAdapter.itemCount != 0) {
val snackbar = Snackbar.make(
binding.root,
it.throwable.message ?: "Error",
Snackbar.LENGTH_INDEFINITE,
)
if (it.throwable !is HttpException || it.throwable.code() != 404) {
snackbar.setAction("Retry") { viewModel.accept(InfallibleUiAction.Reload) }
}
snackbar.show()
} else {
if (it.throwable !is HttpException || it.throwable.code() != 404) {
binding.messageView.setup(it.throwable) {
viewModel.accept(InfallibleUiAction.Reload)
}
} else {
binding.messageView.setup(it.throwable)
}
binding.messageView.show()
}
} }
} }
} }
} }
viewLifecycleOwner.lifecycleScope.launch { viewModel.accept(InfallibleUiAction.Reload)
viewModel.statusDisplayOptions.collectLatest { }
trendingLinksAdapter.statusDisplayOptions = it
private fun bindLoading() {
if (!binding.swipeRefreshLayout.isRefreshing) {
binding.progressBar.show()
} else {
binding.progressBar.hide()
}
}
private fun bindSuccess(loadState: LoadState.Success) {
trendingLinksAdapter.submitList(loadState.data)
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (loadState.data.isEmpty()) {
binding.messageView.setup(BackgroundMessage.Empty())
binding.messageView.show()
} else {
binding.messageView.hide()
binding.recyclerView.show()
}
}
private fun bindError(loadState: LoadState.Error) {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.hide()
if (trendingLinksAdapter.itemCount != 0) {
val snackbar = Snackbar.make(
binding.root,
loadState.throwable.message ?: "Error",
Snackbar.LENGTH_INDEFINITE,
)
if (loadState.throwable !is HttpException || loadState.throwable.code() != 404) {
snackbar.setAction("Retry") { viewModel.accept(InfallibleUiAction.Reload) }
} }
snackbar.show()
} else {
if (loadState.throwable !is HttpException || loadState.throwable.code() != 404) {
binding.messageView.setup(loadState.throwable) {
viewModel.accept(InfallibleUiAction.Reload)
}
} else {
binding.messageView.setup(loadState.throwable)
}
binding.messageView.show()
} }
} }
@ -238,14 +267,22 @@ class TrendingLinksFragment :
} }
private fun onOpenLink(card: PreviewCard, target: Target) { private fun onOpenLink(card: PreviewCard, target: Target) {
if (target == Target.BYLINE) { when (target) {
card.authors?.firstOrNull()?.account?.id?.let { Target.CARD -> requireContext().openLink(card.url)
Target.IMAGE -> requireContext().openLink(card.url)
Target.BYLINE -> card.authors?.firstOrNull()?.account?.id?.let {
startActivity(AccountActivityIntent(requireContext(), pachliAccountId, it)) startActivity(AccountActivityIntent(requireContext(), pachliAccountId, it))
} }
return
}
requireContext().openLink(card.url) Target.TIMELINE_LINK -> {
val intent = TimelineActivityIntent.link(
requireContext(),
pachliAccountId,
card.url,
)
startActivity(intent)
}
}
} }
override fun onResume() { override fun onResume() {

View File

@ -19,7 +19,9 @@ package app.pachli.components.trending.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.components.account.AccountViewModel
import app.pachli.components.trending.TrendingLinksRepository import app.pachli.components.trending.TrendingLinksRepository
import app.pachli.core.common.extensions.stateFlow
import app.pachli.core.common.extensions.throttleFirst import app.pachli.core.common.extensions.throttleFirst
import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
@ -27,20 +29,22 @@ import app.pachli.core.network.model.TrendsLink
import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed interface UiAction sealed interface UiAction
@ -50,23 +54,38 @@ sealed interface InfallibleUiAction : UiAction {
} }
sealed interface LoadState { sealed interface LoadState {
data object Initial : LoadState
data object Loading : LoadState data object Loading : LoadState
data class Success(val data: List<TrendsLink>) : LoadState data class Success(val data: List<TrendsLink>) : LoadState
data class Error(val throwable: Throwable) : LoadState data class Error(val throwable: Throwable) : LoadState
} }
@HiltViewModel @HiltViewModel(assistedFactory = TrendingLinksViewModel.Factory::class)
class TrendingLinksViewModel @Inject constructor( class TrendingLinksViewModel @AssistedInject constructor(
@Assisted private val pachliAccountId: Long,
private val repository: TrendingLinksRepository, private val repository: TrendingLinksRepository,
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
accountManager: AccountManager, accountManager: AccountManager,
) : ViewModel() { ) : ViewModel() {
val activeAccount = accountManager.activeAccount!! val pachliAccountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
.filterNotNull()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
private val _loadState = MutableStateFlow<LoadState>(LoadState.Initial) private val reload = MutableSharedFlow<Unit>(replay = 1)
val loadState = _loadState.asStateFlow()
val loadState = stateFlow(viewModelScope, LoadState.Loading) {
reload.flatMapLatest {
flow {
emit(LoadState.Loading)
emit(
repository.getTrendingLinks().fold(
{ list -> LoadState.Success(list) },
{ throwable -> LoadState.Error(throwable) },
),
)
}
}.flowWhileShared(SharingStarted.WhileSubscribed(5000))
}
val showFabWhileScrolling = sharedPreferencesRepository.changes val showFabWhileScrolling = sharedPreferencesRepository.changes
.filter { it == null || it == PrefKeys.FAB_HIDE } .filter { it == null || it == PrefKeys.FAB_HIDE }
@ -85,17 +104,14 @@ class TrendingLinksViewModel @Inject constructor(
uiAction uiAction
.throttleFirst() .throttleFirst()
.filterIsInstance<InfallibleUiAction.Reload>() .filterIsInstance<InfallibleUiAction.Reload>()
.onEach { invalidate() } .onEach { reload.emit(Unit) }
.collect() .collect()
} }
} }
private fun invalidate() = viewModelScope.launch { @AssistedFactory
_loadState.update { LoadState.Loading } interface Factory {
val response = repository.getTrendingLinks() /** Creates [AccountViewModel] with [pachliAccountId] as the active account. */
response.fold( fun create(pachliAccountId: Long): TrendingLinksViewModel
{ list -> _loadState.update { LoadState.Success(list) } },
{ throwable -> _loadState.update { LoadState.Error(throwable) } },
)
} }
} }

View File

@ -21,19 +21,21 @@ import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.text.HtmlCompat
import app.pachli.R import app.pachli.R
import app.pachli.core.activity.decodeBlurHash import app.pachli.core.activity.decodeBlurHash
import app.pachli.core.activity.emojify import app.pachli.core.activity.emojify
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.string.unicodeWrap import app.pachli.core.common.string.unicodeWrap
import app.pachli.core.common.util.formatNumber
import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.core.network.model.PreviewCard import app.pachli.core.network.model.PreviewCard
import app.pachli.core.network.model.TrendsLink
import app.pachli.databinding.PreviewCardBinding import app.pachli.databinding.PreviewCardBinding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.MultiTransformation
@ -66,6 +68,9 @@ class PreviewCardView @JvmOverloads constructor(
/** The author byline */ /** The author byline */
BYLINE, BYLINE,
/** The link timeline */
TIMELINE_LINK,
} }
fun interface OnClickListener { fun interface OnClickListener {
@ -114,12 +119,15 @@ class PreviewCardView @JvmOverloads constructor(
* @param card The card to bind * @param card The card to bind
* @param sensitive True if the status that contained this card was marked sensitive * @param sensitive True if the status that contained this card was marked sensitive
* @param statusDisplayOptions * @param statusDisplayOptions
* @param showTimelineLink True if the UI to view a timeline of statuses about this link
* should be shown.
* @param listener * @param listener
*/ */
fun bind( fun bind(
card: PreviewCard, card: PreviewCard,
sensitive: Boolean, sensitive: Boolean,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
showTimelineLink: Boolean,
listener: OnClickListener, listener: OnClickListener,
): Unit = with(binding) { ): Unit = with(binding) {
cardTitle.text = card.title cardTitle.text = card.title
@ -135,9 +143,8 @@ class PreviewCardView @JvmOverloads constructor(
previewCardWrapper.setOnClickListener { listener.onClick(card, Target.CARD) } previewCardWrapper.setOnClickListener { listener.onClick(card, Target.CARD) }
cardImage.setOnClickListener { listener.onClick(card, Target.IMAGE) } cardImage.setOnClickListener { listener.onClick(card, Target.IMAGE) }
byline.referencedIds.forEach { id -> authorInfo.setOnClickListener { listener.onClick(card, Target.BYLINE) }
root.findViewById<View>(id).setOnClickListener { listener.onClick(card, Target.BYLINE) } timelineLink.setOnClickListener { listener.onClick(card, Target.TIMELINE_LINK) }
}
cardLink.text = card.url cardLink.text = card.url
@ -177,14 +184,60 @@ class PreviewCardView @JvmOverloads constructor(
cardImage.hide() cardImage.hide()
} }
card.authors?.firstOrNull()?.account?.let { account -> var showBylineDivider = false
val name = account.name.unicodeWrap().emojify(account.emojis, authorInfo, false) bylineDivider.hide()
authorInfo.text = authorInfo.context.getString(R.string.preview_card_byline_fmt, name)
Glide.with(authorInfo.context).load(account.avatar).transform(bylineAvatarTransformation) // Determine how to show the author info (if present)
.placeholder(DR.drawable.avatar_default).into(bylineAvatarTarget) val author = card.authors?.firstOrNull()
byline.show() when {
} ?: byline.hide() // Author has an account, link to that, with their avatar.
author?.account != null -> {
val name = author.account?.name.unicodeWrap().emojify(author.account?.emojis, authorInfo, false)
authorInfo.text = HtmlCompat.fromHtml(
authorInfo.context.getString(R.string.preview_card_byline_fediverse_account_fmt, name),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)
Glide.with(authorInfo.context).load(author.account?.avatar).transform(bylineAvatarTransformation)
.placeholder(DR.drawable.avatar_default).into(bylineAvatarTarget)
authorInfo.show()
showBylineDivider = true
}
// Author has a name but no account. Show the name, clear the avatar.
// It's not enough that the name is present, it can't be empty, because of
// https://github.com/mastodon/mastodon/issues/33139).
!author?.name.isNullOrBlank() -> {
authorInfo.text = HtmlCompat.fromHtml(
authorInfo.context.getString(R.string.preview_card_byline_name_only_fmt, author?.name),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)
authorInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
authorInfo.show()
showBylineDivider = true
}
else -> authorInfo.hide()
}
// TrendsLink cards have data about the usage. Show this if the server
// can generate the timeline.
if (card is TrendsLink && showTimelineLink) {
val count = card.history.sumOf { it.uses }
timelineLink.text = HtmlCompat.fromHtml(
context.getString(
R.string.preview_card_timeline_link_fmt,
formatNumber(count.toLong()),
),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)
timelineLink.show()
showBylineDivider = true
} else {
timelineLink.hide()
}
if (showBylineDivider) bylineDivider.show()
} }
/** Adjusts the layout parameters to place the image above the information views */ /** Adjusts the layout parameters to place the image above the information views */

View File

@ -126,12 +126,25 @@
tools:ignore="SelectableText" tools:ignore="SelectableText"
tools:text="@tools:sample/lorem" /> tools:text="@tools:sample/lorem" />
<androidx.constraintlayout.widget.Group <TextView
android:id="@+id/byline" android:id="@+id/timeline_link"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="visible" android:background="?selectableItemBackground"
app:constraint_referenced_ids="byline_divider,author_info" /> android:drawablePadding="10dp"
android:ellipsize="end"
android:gravity="start|center"
android:lines="1"
android:paddingStart="2dp"
android:paddingTop="6dp"
android:paddingEnd="2dp"
android:paddingBottom="2dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/author_info"
tools:ignore="SelectableText"
tools:text="@tools:sample/lorem" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</merge> </merge>

View File

@ -638,7 +638,7 @@
<string name="error_media_uploader_upload_not_found_fmt">no se encontró la subida de medios con ID de %1$s</string> <string name="error_media_uploader_upload_not_found_fmt">no se encontró la subida de medios con ID de %1$s</string>
<string name="error_prepare_media_unknown_file_size">no se pudo determinar el tamaño del archivo</string> <string name="error_prepare_media_unknown_file_size">no se pudo determinar el tamaño del archivo</string>
<string name="error_prepare_media_unknown_mime_type">se desconoce el tipo de archivo</string> <string name="error_prepare_media_unknown_mime_type">se desconoce el tipo de archivo</string>
<string name="preview_card_byline_fmt">Ver más de %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">Ver más de %1$s</string>
<string name="action_open_byline_account">Mostrar el perfil del autor del artículo</string> <string name="action_open_byline_account">Mostrar el perfil del autor del artículo</string>
<string name="action_open_link">Abrir enlace</string> <string name="action_open_link">Abrir enlace</string>
<string name="compose_warn_language_dialog_title">Comprueba el idioma de la publicación</string> <string name="compose_warn_language_dialog_title">Comprueba el idioma de la publicación</string>

View File

@ -628,7 +628,7 @@
<string name="error_prepare_media_content_resolver_unsupported_scheme_fmt">sisältöselvittäjä-URI:n kaavaa ei tueta: %1$s</string> <string name="error_prepare_media_content_resolver_unsupported_scheme_fmt">sisältöselvittäjä-URI:n kaavaa ei tueta: %1$s</string>
<string name="error_prepare_media_unknown_mime_type">tuntematon tiedostotyyppi</string> <string name="error_prepare_media_unknown_mime_type">tuntematon tiedostotyyppi</string>
<string name="error_pick_media_fmt">Julkaisuun ei voitu liittää tiedostoa: %1$s</string> <string name="error_pick_media_fmt">Julkaisuun ei voitu liittää tiedostoa: %1$s</string>
<string name="preview_card_byline_fmt">Katso lisää täältä: %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">Katso lisää täältä: %1$s</string>
<string name="action_open_byline_account">Näytä julkaisun tekijän profiili</string> <string name="action_open_byline_account">Näytä julkaisun tekijän profiili</string>
<string name="action_open_link">Avaa linkki</string> <string name="action_open_link">Avaa linkki</string>
<string name="error_prepare_media_unknown_file_size">tiedoston kokoa ei voitu määrittää</string> <string name="error_prepare_media_unknown_file_size">tiedoston kokoa ei voitu määrittää</string>
@ -793,4 +793,4 @@
<string name="main_viewmodel_error_refresh_account">Tilin päivittäminen epäonnistui, virhe oli:\n\n%1$s\n\nVoit jatkaa, mutta listasi ja suodattimesi saattavat olla eivät ehkä ole ajan tasalla.</string> <string name="main_viewmodel_error_refresh_account">Tilin päivittäminen epäonnistui, virhe oli:\n\n%1$s\n\nVoit jatkaa, mutta listasi ja suodattimesi saattavat olla eivät ehkä ole ajan tasalla.</string>
<string name="action_relogin">Kirjaudu uudestaan</string> <string name="action_relogin">Kirjaudu uudestaan</string>
<string name="upload_failed_modify_attachment">Muokkaa liitettä</string> <string name="upload_failed_modify_attachment">Muokkaa liitettä</string>
</resources> </resources>

View File

@ -692,7 +692,7 @@
<string name="pref_title_notification_method">Modh fógartha</string> <string name="pref_title_notification_method">Modh fógartha</string>
<string name="pref_notification_method_all_pull">Fetched thart ar uair amháin gach 15 nóiméad. Tapáil le haghaidh sonraí.</string> <string name="pref_notification_method_all_pull">Fetched thart ar uair amháin gach 15 nóiméad. Tapáil le haghaidh sonraí.</string>
<string name="pref_title_notification_battery_optimisation">Leas iomlán a bhaint ceallraí</string> <string name="pref_title_notification_battery_optimisation">Leas iomlán a bhaint ceallraí</string>
<string name="preview_card_byline_fmt">Féach níos mó ó %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">Féach níos mó ó %1$s</string>
<string name="action_open_byline_account">Taispeáin próifíl údar an ailt</string> <string name="action_open_byline_account">Taispeáin próifíl údar an ailt</string>
<string name="action_open_link">Oscail nasc</string> <string name="action_open_link">Oscail nasc</string>
<string name="search_operator_attachment_dialog_title">Teorainn le poist leis na meáin?</string> <string name="search_operator_attachment_dialog_title">Teorainn le poist leis na meáin?</string>
@ -824,4 +824,4 @@
<string name="filter_action_none">Taispeáin</string> <string name="filter_action_none">Taispeáin</string>
<string name="pref_account_notification_filters_label_not_followed">… ní leanann tú</string> <string name="pref_account_notification_filters_label_not_followed">… ní leanann tú</string>
<string name="account_filter_placeholder_type_follow_fmt">Lean úsáideoir @<b>%1s</b></string> <string name="account_filter_placeholder_type_follow_fmt">Lean úsáideoir @<b>%1s</b></string>
</resources> </resources>

View File

@ -626,7 +626,7 @@
<string name="error_prepare_media_unknown_mime_type">tipo de ficheiro descoñecido</string> <string name="error_prepare_media_unknown_mime_type">tipo de ficheiro descoñecido</string>
<string name="error_prepare_media_unsupported_mime_type_fmt">o servidor non é compatible co tipo de ficheiro: %1$s</string> <string name="error_prepare_media_unsupported_mime_type_fmt">o servidor non é compatible co tipo de ficheiro: %1$s</string>
<string name="error_pick_media_fmt">Non se anexou o ficheiro á publicación: %1$s</string> <string name="error_pick_media_fmt">Non se anexou o ficheiro á publicación: %1$s</string>
<string name="preview_card_byline_fmt">Le máis de %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">Le máis de %1$s</string>
<string name="error_load_filter_failed_fmt">Fallou a carga do filtro: %1$s</string> <string name="error_load_filter_failed_fmt">Fallou a carga do filtro: %1$s</string>
<string name="error_save_filter_failed_fmt">Non se gardou o filtro: %1$s</string> <string name="error_save_filter_failed_fmt">Non se gardou o filtro: %1$s</string>
<string name="search_operator_attachment_dialog_image_label">Imaxes</string> <string name="search_operator_attachment_dialog_image_label">Imaxes</string>

View File

@ -735,7 +735,7 @@
<string name="search_operator_attachment_no_media_label">Ingen media</string> <string name="search_operator_attachment_no_media_label">Ingen media</string>
<string name="error_prepare_media_unknown_mime_type">filtypen er ukjent</string> <string name="error_prepare_media_unknown_mime_type">filtypen er ukjent</string>
<string name="error_pick_media_fmt">Kunne ikke legge filen ved innlegget: %1$s</string> <string name="error_pick_media_fmt">Kunne ikke legge filen ved innlegget: %1$s</string>
<string name="preview_card_byline_fmt">Se mer fra %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">Se mer fra %1$s</string>
<string name="action_open_byline_account">Vis artikkelforfatterens profil</string> <string name="action_open_byline_account">Vis artikkelforfatterens profil</string>
<string name="action_open_link">Åpne lenk</string> <string name="action_open_link">Åpne lenk</string>
<string name="error_load_filter_failed_fmt">Innlasting av filter mislyktes: %1$s</string> <string name="error_load_filter_failed_fmt">Innlasting av filter mislyktes: %1$s</string>
@ -777,4 +777,4 @@
</plurals> </plurals>
<string name="main_viewmodel_error_refresh_account">Å gjenoppfriske kontoen mislyktes med den følgende feilen:\n\n%1$s\n\nDu kan fortsette, men det kan hende at dine lister og filtere kan være ufullstendige.</string> <string name="main_viewmodel_error_refresh_account">Å gjenoppfriske kontoen mislyktes med den følgende feilen:\n\n%1$s\n\nDu kan fortsette, men det kan hende at dine lister og filtere kan være ufullstendige.</string>
<string name="action_relogin">Logg inn på nytt</string> <string name="action_relogin">Logg inn på nytt</string>
</resources> </resources>

View File

@ -733,10 +733,14 @@
<string name="error_prepare_media_unknown_mime_type">file\'s type is not known</string> <string name="error_prepare_media_unknown_mime_type">file\'s type is not known</string>
<string name="error_pick_media_fmt">Could not attach file to post: %1$s</string> <string name="error_pick_media_fmt">Could not attach file to post: %1$s</string>
<string name="preview_card_byline_fmt">See more from %1$s</string> <string name="preview_card_byline_fediverse_account_fmt">By &lt;b>%1$s&lt;/b>. See more posts</string>
<string name="preview_card_byline_name_only_fmt">By &lt;b>%1$s&lt;/b></string>
<string name="action_open_byline_account">Show article author\'s profile</string> <string name="action_open_byline_account">Show article author\'s profile</string>
<string name="action_open_link">Open link</string> <string name="action_open_link">Open link</string>
<string name="preview_card_timeline_link_fmt">See &lt;b>%1$s&lt;/b> posts about this link</string>
<string name="action_timeline_link">Posts about this link</string>
<string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string> <string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string>
<string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string> <string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string>
<string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string> <string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string>

View File

@ -58,6 +58,7 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SE
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_TIMELINES_LINK
import app.pachli.core.network.R import app.pachli.core.network.R
import app.pachli.core.network.model.InstanceV1 import app.pachli.core.network.model.InstanceV1
import app.pachli.core.network.model.InstanceV2 import app.pachli.core.network.model.InstanceV2
@ -333,6 +334,11 @@ data class Server(
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion() c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
} }
} }
// Link timelines
when {
v >= "4.3.0".toVersion() -> c[ORG_JOINMASTODON_TIMELINES_LINK] = "1.0.0".toVersion()
}
} }
GOTOSOCIAL -> { GOTOSOCIAL -> {

View File

@ -137,6 +137,7 @@ enum class FilterContext {
Timeline.TrendingStatuses, Timeline.TrendingStatuses,
Timeline.TrendingHashtags, Timeline.TrendingHashtags,
Timeline.TrendingLinks, Timeline.TrendingLinks,
is Timeline.Link,
-> PUBLIC -> PUBLIC
Timeline.Conversations -> null Timeline.Conversations -> null
} }

View File

@ -111,4 +111,13 @@ enum class ServerOperation(id: String, versions: List<Version>) {
Version(major = 1), Version(major = 1),
), ),
), ),
/** Fetch statuses that mention a specific URL. */
ORG_JOINMASTODON_TIMELINES_LINK(
"org.joinmastodon.timelines.link",
listOf(
// Initial introduction in Mastodon 4.3.0
Version(major = 1),
),
),
} }

View File

@ -112,6 +112,12 @@ sealed interface Timeline : Parcelable {
@TypeLabel("trending_statuses") @TypeLabel("trending_statuses")
data object TrendingStatuses : Timeline data object TrendingStatuses : Timeline
/** Timeline of statuses that mention [url]. */
@Parcelize
@TypeLabel("link")
@JsonClass(generateAdapter = true)
data class Link(val url: String) : Timeline
// TODO: DRAFTS // TODO: DRAFTS
// TODO: SCHEDULED // TODO: SCHEDULED

View File

@ -585,6 +585,16 @@ class TimelineActivityIntent private constructor(context: Context, pachliAccount
putExtra(EXTRA_TIMELINE, Timeline.Hashtags(listOf(hashtag))) putExtra(EXTRA_TIMELINE, Timeline.Hashtags(listOf(hashtag)))
} }
/**
* Show statuses that reference a trending link.
*
* @param context
*
*/
fun link(context: Context, pachliAccountId: Long, url: String) = TimelineActivityIntent(context, pachliAccountId).apply {
putExtra(EXTRA_TIMELINE, Timeline.Link(url))
}
/** /**
* Show statuses from a list. * Show statuses from a list.
* *

View File

@ -73,6 +73,7 @@ enum class FilterContext {
Timeline.TrendingStatuses, Timeline.TrendingStatuses,
Timeline.TrendingHashtags, Timeline.TrendingHashtags,
Timeline.TrendingLinks, Timeline.TrendingLinks,
is Timeline.Link,
-> PUBLIC -> PUBLIC
Timeline.Conversations -> null Timeline.Conversations -> null
} }

View File

@ -152,6 +152,14 @@ interface MastodonApi {
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
): Response<List<Status>> ): Response<List<Status>>
@GET("api/v1/timelines/link")
suspend fun linkTimeline(
@Query("url") url: String,
@Query("max_id") maxId: String? = null,
@Query("min_id") minId: String? = null,
@Query("limit") limit: Int? = null,
): Response<List<Status>>
@GET("api/v1/notifications") @GET("api/v1/notifications")
suspend fun notifications( suspend fun notifications(
/** Return results older than this ID */ /** Return results older than this ID */

View File

@ -46,6 +46,9 @@
<!-- Open the link in a preview card --> <!-- Open the link in a preview card -->
<item name="action_open_link" type="id" /> <item name="action_open_link" type="id" />
<!-- Open the timeline of statuses that mention a link. -->
<item name="action_timeline_link" type="id" />
<!-- Copy the item --> <!-- Copy the item -->
<item name="action_copy_item" type="id" /> <item name="action_copy_item" type="id" />