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,
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.Posts -> 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)
) {
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) {
card.authors?.firstOrNull()?.account?.id?.let {
context.startActivity(AccountActivityIntent(context, pachliAccountId, it))

View File

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

View File

@ -121,6 +121,7 @@ class NetworkTimelineRemoteMediator(
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.TrendingStatuses -> api.trendingStatuses(limit = LIMIT_TRENDING_STATUSES)
is Timeline.Link -> api.linkTimeline(url = timeline.url, maxId = maxId, minId = minId, limit = loadSize)
is Timeline.Hashtags -> {
val firstHashtag = timeline.tags.first()
val additionalHashtags = timeline.tags.subList(1, timeline.tags.size)

View File

@ -29,9 +29,15 @@ class TrendingLinkViewHolder(
) : RecyclerView.ViewHolder(binding.root) {
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
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.
*
* 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(
private val recyclerView: RecyclerView,
@ -59,6 +62,11 @@ internal class TrendingLinksAccessibilityDelegate(
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) {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
@ -72,6 +80,10 @@ internal class TrendingLinksAccessibilityDelegate(
viewHolder.link.authors?.firstOrNull()?.account?.let {
info.addAction(openBylineAccountAction)
}
if ((recyclerView.adapter as? TrendingLinksAdapter)?.showTimelineLink == true) {
info.addAction(openTimelineLinkAction)
}
}
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean {
@ -93,6 +105,11 @@ internal class TrendingLinksAccessibilityDelegate(
listener.onClick(viewHolder.link, Target.BYLINE)
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)
}
}

View File

@ -27,8 +27,15 @@ import app.pachli.core.network.model.TrendsLink
import app.pachli.databinding.ItemTrendingLinkBinding
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(
statusDisplayOptions: StatusDisplayOptions,
showTimelineLink: Boolean,
private val onViewLink: PreviewCardView.OnClickListener,
) : ListAdapter<TrendsLink, TrendingLinkViewHolder>(diffCallback) {
var statusDisplayOptions = statusDisplayOptions
@ -37,6 +44,14 @@ class TrendingLinksAdapter(
notifyItemRangeChanged(0, itemCount)
}
var showTimelineLink = showTimelineLink
set(value) {
if (field != value) {
field = value
notifyItemRangeChanged(0, itemCount)
}
}
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
@ -49,7 +64,7 @@ class TrendingLinksAdapter(
}
override fun onBindViewHolder(holder: TrendingLinkViewHolder, position: Int) {
holder.bind(getItem(position), statusDisplayOptions)
holder.bind(getItem(position), statusDisplayOptions, showTimelineLink)
}
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.viewBinding
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.TimelineActivityIntent
import app.pachli.core.network.model.PreviewCard
import app.pachli.core.ui.ActionButtonScrollListener
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.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import io.github.z4kn4fein.semver.constraints.toConstraint
import kotlin.properties.Delegates
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber
@ -74,7 +79,13 @@ class TrendingLinksFragment :
RefreshableFragment,
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)
@ -99,11 +110,6 @@ class TrendingLinksFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
trendingLinksAdapter = TrendingLinksAdapter(viewModel.statusDisplayOptions.value, ::onOpenLink)
setupSwipeRefreshLayout()
setupRecyclerView()
(activity as? ActionButtonActivity)?.actionButton?.let { actionButton ->
actionButton.show()
@ -119,14 +125,47 @@ class TrendingLinksFragment :
}
}
trendingLinksAdapter = TrendingLinksAdapter(
viewModel.statusDisplayOptions.value,
false,
::onOpenLink,
)
setupSwipeRefreshLayout()
setupRecyclerView()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.loadState.collectLatest {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.pachliAccountFlow.distinctUntilChangedBy { it.server }.collect { account ->
trendingLinksAdapter.showTimelineLink = account.server.can(
ServerOperation.ORG_JOINMASTODON_TIMELINES_LINK,
">=1.0.0".toConstraint(),
)
}
}
launch {
viewModel.loadState.collect {
when (it) {
LoadState.Initial -> {
LoadState.Loading -> bindLoading()
is LoadState.Success -> bindSuccess(it)
is LoadState.Error -> bindError(it)
}
}
}
launch {
viewModel.statusDisplayOptions.collectLatest {
trendingLinksAdapter.statusDisplayOptions = it
}
}
}
}
viewModel.accept(InfallibleUiAction.Reload)
}
LoadState.Loading -> {
private fun bindLoading() {
if (!binding.swipeRefreshLayout.isRefreshing) {
binding.progressBar.show()
} else {
@ -134,11 +173,11 @@ class TrendingLinksFragment :
}
}
is LoadState.Success -> {
trendingLinksAdapter.submitList(it.data)
private fun bindSuccess(loadState: LoadState.Success) {
trendingLinksAdapter.submitList(loadState.data)
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isEmpty()) {
if (loadState.data.isEmpty()) {
binding.messageView.setup(BackgroundMessage.Empty())
binding.messageView.show()
} else {
@ -147,42 +186,32 @@ class TrendingLinksFragment :
}
}
is LoadState.Error -> {
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,
it.throwable.message ?: "Error",
loadState.throwable.message ?: "Error",
Snackbar.LENGTH_INDEFINITE,
)
if (it.throwable !is HttpException || it.throwable.code() != 404) {
if (loadState.throwable !is HttpException || loadState.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) {
if (loadState.throwable !is HttpException || loadState.throwable.code() != 404) {
binding.messageView.setup(loadState.throwable) {
viewModel.accept(InfallibleUiAction.Reload)
}
} else {
binding.messageView.setup(it.throwable)
binding.messageView.setup(loadState.throwable)
}
binding.messageView.show()
}
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.statusDisplayOptions.collectLatest {
trendingLinksAdapter.statusDisplayOptions = it
}
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.setOnRefreshListener(this)
@ -238,14 +267,22 @@ class TrendingLinksFragment :
}
private fun onOpenLink(card: PreviewCard, target: Target) {
if (target == Target.BYLINE) {
card.authors?.firstOrNull()?.account?.id?.let {
when (target) {
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))
}
return
}
requireContext().openLink(card.url)
Target.TIMELINE_LINK -> {
val intent = TimelineActivityIntent.link(
requireContext(),
pachliAccountId,
card.url,
)
startActivity(intent)
}
}
}
override fun onResume() {

View File

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

View File

@ -21,19 +21,21 @@ import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.text.HtmlCompat
import app.pachli.R
import app.pachli.core.activity.decodeBlurHash
import app.pachli.core.activity.emojify
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
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.designsystem.R as DR
import app.pachli.core.network.model.PreviewCard
import app.pachli.core.network.model.TrendsLink
import app.pachli.databinding.PreviewCardBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.load.MultiTransformation
@ -66,6 +68,9 @@ class PreviewCardView @JvmOverloads constructor(
/** The author byline */
BYLINE,
/** The link timeline */
TIMELINE_LINK,
}
fun interface OnClickListener {
@ -114,12 +119,15 @@ class PreviewCardView @JvmOverloads constructor(
* @param card The card to bind
* @param sensitive True if the status that contained this card was marked sensitive
* @param statusDisplayOptions
* @param showTimelineLink True if the UI to view a timeline of statuses about this link
* should be shown.
* @param listener
*/
fun bind(
card: PreviewCard,
sensitive: Boolean,
statusDisplayOptions: StatusDisplayOptions,
showTimelineLink: Boolean,
listener: OnClickListener,
): Unit = with(binding) {
cardTitle.text = card.title
@ -135,9 +143,8 @@ class PreviewCardView @JvmOverloads constructor(
previewCardWrapper.setOnClickListener { listener.onClick(card, Target.CARD) }
cardImage.setOnClickListener { listener.onClick(card, Target.IMAGE) }
byline.referencedIds.forEach { id ->
root.findViewById<View>(id).setOnClickListener { listener.onClick(card, Target.BYLINE) }
}
authorInfo.setOnClickListener { listener.onClick(card, Target.BYLINE) }
timelineLink.setOnClickListener { listener.onClick(card, Target.TIMELINE_LINK) }
cardLink.text = card.url
@ -177,14 +184,60 @@ class PreviewCardView @JvmOverloads constructor(
cardImage.hide()
}
card.authors?.firstOrNull()?.account?.let { account ->
val name = account.name.unicodeWrap().emojify(account.emojis, authorInfo, false)
authorInfo.text = authorInfo.context.getString(R.string.preview_card_byline_fmt, name)
var showBylineDivider = false
bylineDivider.hide()
Glide.with(authorInfo.context).load(account.avatar).transform(bylineAvatarTransformation)
// Determine how to show the author info (if present)
val author = card.authors?.firstOrNull()
when {
// 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)
byline.show()
} ?: byline.hide()
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 */

View File

@ -126,12 +126,25 @@
tools:ignore="SelectableText"
tools:text="@tools:sample/lorem" />
<androidx.constraintlayout.widget.Group
android:id="@+id/byline"
android:layout_width="wrap_content"
<TextView
android:id="@+id/timeline_link"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="visible"
app:constraint_referenced_ids="byline_divider,author_info" />
android:background="?selectableItemBackground"
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>
</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_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="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_link">Abrir enlace</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_unknown_mime_type">tuntematon tiedostotyyppi</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_link">Avaa linkki</string>
<string name="error_prepare_media_unknown_file_size">tiedoston kokoa ei voitu määrittää</string>

View File

@ -692,7 +692,7 @@
<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_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_link">Oscail nasc</string>
<string name="search_operator_attachment_dialog_title">Teorainn le poist leis na meáin?</string>

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_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="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_save_filter_failed_fmt">Non se gardou o filtro: %1$s</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="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="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_link">Åpne lenk</string>
<string name="error_load_filter_failed_fmt">Innlasting av filter mislyktes: %1$s</string>

View File

@ -733,10 +733,14 @@
<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="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_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_save_filter_failed_fmt">Saving 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_STATUSES_SCHEDULED
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.model.InstanceV1
import app.pachli.core.network.model.InstanceV2
@ -333,6 +334,11 @@ data class Server(
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 -> {

View File

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

View File

@ -111,4 +111,13 @@ enum class ServerOperation(id: String, versions: List<Version>) {
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")
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: SCHEDULED

View File

@ -585,6 +585,16 @@ class TimelineActivityIntent private constructor(context: Context, pachliAccount
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.
*

View File

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

View File

@ -152,6 +152,14 @@ interface MastodonApi {
@Query("limit") limit: Int? = null,
): 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")
suspend fun notifications(
/** Return results older than this ID */

View File

@ -46,6 +46,9 @@
<!-- Open the link in a preview card -->
<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 -->
<item name="action_copy_item" type="id" />