From 57be148fbf64b67881fb2769d9c872b75c59b764 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 3 Dec 2024 23:00:31 +0100 Subject: [PATCH] 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 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 --- app/src/main/java/app/pachli/TabViewData.kt | 1 + .../pachli/adapter/StatusBaseViewHolder.kt | 2 +- .../components/timeline/TimelineFragment.kt | 1 + .../NetworkTimelineRemoteMediator.kt | 1 + .../trending/TrendingLinkViewHolder.kt | 10 +- .../TrendingLinksAccessibilityDelegate.kt | 17 ++ .../trending/TrendingLinksAdapter.kt | 17 +- .../trending/TrendingLinksFragment.kt | 161 +++++++++++------- .../viewmodel/TrendingLinksViewModel.kt | 52 ++++-- .../java/app/pachli/view/PreviewCardView.kt | 75 ++++++-- app/src/main/res/layout/preview_card.xml | 23 ++- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fi/strings.xml | 4 +- app/src/main/res/values-ga/strings.xml | 4 +- app/src/main/res/values-gl/strings.xml | 2 +- app/src/main/res/values-nb-rNO/strings.xml | 4 +- app/src/main/res/values/strings.xml | 6 +- .../app/pachli/core/data/model/Server.kt | 6 + .../app/pachli/core/model/ContentFilters.kt | 1 + .../app/pachli/core/model/ServerOperation.kt | 9 + .../kotlin/app/pachli/core/model/Timeline.kt | 6 + .../app/pachli/core/navigation/Navigation.kt | 10 ++ .../core/network/model/FilterContext.kt | 1 + .../core/network/retrofit/MastodonApi.kt | 8 + core/ui/src/main/res/values/actions.xml | 3 + 25 files changed, 317 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/app/pachli/TabViewData.kt b/app/src/main/java/app/pachli/TabViewData.kt index 3690b17fe..ad3a6375a 100644 --- a/app/src/main/java/app/pachli/TabViewData.kt +++ b/app/src/main/java/app/pachli/TabViewData.kt @@ -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") diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index 824fd8ae4..3ba2a468b 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -895,7 +895,7 @@ abstract class StatusBaseViewHolder 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)) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 4ee45a2a1..f7b6642c3 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -772,6 +772,7 @@ class TimelineFragment : Timeline.Notifications, Timeline.TrendingHashtags, Timeline.TrendingLinks, + is Timeline.Link, -> return } } diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 110cb137c..7a6df7b95 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -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) diff --git a/app/src/main/java/app/pachli/components/trending/TrendingLinkViewHolder.kt b/app/src/main/java/app/pachli/components/trending/TrendingLinkViewHolder.kt index a81dfb96f..02390ad4e 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingLinkViewHolder.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingLinkViewHolder.kt @@ -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) } } diff --git a/app/src/main/java/app/pachli/components/trending/TrendingLinksAccessibilityDelegate.kt b/app/src/main/java/app/pachli/components/trending/TrendingLinksAccessibilityDelegate.kt index 737321460..9211249c2 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingLinksAccessibilityDelegate.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingLinksAccessibilityDelegate.kt @@ -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) } } diff --git a/app/src/main/java/app/pachli/components/trending/TrendingLinksAdapter.kt b/app/src/main/java/app/pachli/components/trending/TrendingLinksAdapter.kt index 7ef9c864b..7956871f2 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingLinksAdapter.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingLinksAdapter.kt @@ -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(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 { diff --git a/app/src/main/java/app/pachli/components/trending/TrendingLinksFragment.kt b/app/src/main/java/app/pachli/components/trending/TrendingLinksFragment.kt index 8eef4ee93..4ecc304c6 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingLinksFragment.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingLinksFragment.kt @@ -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 { 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,68 +125,91 @@ class TrendingLinksFragment : } } + trendingLinksAdapter = TrendingLinksAdapter( + viewModel.statusDisplayOptions.value, + false, + ::onOpenLink, + ) + + setupSwipeRefreshLayout() + setupRecyclerView() + viewLifecycleOwner.lifecycleScope.launch { - viewModel.loadState.collectLatest { - when (it) { - LoadState.Initial -> { - viewModel.accept(InfallibleUiAction.Reload) + 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(), + ) } + } - LoadState.Loading -> { - if (!binding.swipeRefreshLayout.isRefreshing) { - binding.progressBar.show() - } else { - binding.progressBar.hide() + launch { + viewModel.loadState.collect { + when (it) { + LoadState.Loading -> bindLoading() + is LoadState.Success -> bindSuccess(it) + is LoadState.Error -> bindError(it) } } - - is LoadState.Success -> { - trendingLinksAdapter.submitList(it.data) - binding.progressBar.hide() - 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() - } + } + launch { + viewModel.statusDisplayOptions.collectLatest { + trendingLinksAdapter.statusDisplayOptions = it } } } } - viewLifecycleOwner.lifecycleScope.launch { - viewModel.statusDisplayOptions.collectLatest { - trendingLinksAdapter.statusDisplayOptions = it + viewModel.accept(InfallibleUiAction.Reload) + } + + 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) { - 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() { diff --git a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingLinksViewModel.kt b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingLinksViewModel.kt index f2eac70ad..c9699ec35 100644 --- a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingLinksViewModel.kt +++ b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingLinksViewModel.kt @@ -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) : 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.Initial) - val loadState = _loadState.asStateFlow() + private val reload = MutableSharedFlow(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() - .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 } } diff --git a/app/src/main/java/app/pachli/view/PreviewCardView.kt b/app/src/main/java/app/pachli/view/PreviewCardView.kt index 851ee4c40..454103b79 100644 --- a/app/src/main/java/app/pachli/view/PreviewCardView.kt +++ b/app/src/main/java/app/pachli/view/PreviewCardView.kt @@ -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(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) - .placeholder(DR.drawable.avatar_default).into(bylineAvatarTarget) - byline.show() - } ?: byline.hide() + // 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) + 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 */ diff --git a/app/src/main/res/layout/preview_card.xml b/app/src/main/res/layout/preview_card.xml index 878bc9475..8a735b0af 100644 --- a/app/src/main/res/layout/preview_card.xml +++ b/app/src/main/res/layout/preview_card.xml @@ -126,12 +126,25 @@ tools:ignore="SelectableText" tools:text="@tools:sample/lorem" /> - + 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" /> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d6d670bb1..0047f582d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -638,7 +638,7 @@ no se encontró la subida de medios con ID de %1$s no se pudo determinar el tamaño del archivo se desconoce el tipo de archivo - Ver más de %1$s + Ver más de %1$s Mostrar el perfil del autor del artículo Abrir enlace Comprueba el idioma de la publicación diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b26b3b813..1adc6bbd7 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -628,7 +628,7 @@ sisältöselvittäjä-URI:n kaavaa ei tueta: %1$s tuntematon tiedostotyyppi Julkaisuun ei voitu liittää tiedostoa: %1$s - Katso lisää täältä: %1$s + Katso lisää täältä: %1$s Näytä julkaisun tekijän profiili Avaa linkki tiedoston kokoa ei voitu määrittää @@ -793,4 +793,4 @@ 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. Kirjaudu uudestaan Muokkaa liitettä - \ No newline at end of file + diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 6c60b248b..7760279b1 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -692,7 +692,7 @@ Modh fógartha Fetched thart ar uair amháin gach 15 nóiméad. Tapáil le haghaidh sonraí. Leas iomlán a bhaint ceallraí - Féach níos mó ó %1$s + Féach níos mó ó %1$s Taispeáin próifíl údar an ailt Oscail nasc Teorainn le poist leis na meáin? @@ -824,4 +824,4 @@ Taispeáin … ní leanann tú Lean úsáideoir @%1s - \ No newline at end of file + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 3bf07f699..638448bda 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -626,7 +626,7 @@ tipo de ficheiro descoñecido o servidor non é compatible co tipo de ficheiro: %1$s Non se anexou o ficheiro á publicación: %1$s - Le máis de %1$s + Le máis de %1$s Fallou a carga do filtro: %1$s Non se gardou o filtro: %1$s Imaxes diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 7b9c94f1d..947c683da 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -735,7 +735,7 @@ Ingen media filtypen er ukjent Kunne ikke legge filen ved innlegget: %1$s - Se mer fra %1$s + Se mer fra %1$s Vis artikkelforfatterens profil Åpne lenk Innlasting av filter mislyktes: %1$s @@ -777,4 +777,4 @@ Å 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. Logg inn på nytt - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f2941396..38e694942 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -733,10 +733,14 @@ file\'s type is not known Could not attach file to post: %1$s - See more from %1$s + By <b>%1$s</b>. See more posts + By <b>%1$s</b> Show article author\'s profile Open link + See <b>%1$s</b> posts about this link + Posts about this link + Loading filter failed: %1$s Saving filter failed: %1$s Deleting filter failed: %1$s diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt index 4c1cee0a4..5dd16ad55 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt @@ -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 -> { diff --git a/core/model/src/main/kotlin/app/pachli/core/model/ContentFilters.kt b/core/model/src/main/kotlin/app/pachli/core/model/ContentFilters.kt index 2850adc89..f563c7780 100644 --- a/core/model/src/main/kotlin/app/pachli/core/model/ContentFilters.kt +++ b/core/model/src/main/kotlin/app/pachli/core/model/ContentFilters.kt @@ -137,6 +137,7 @@ enum class FilterContext { Timeline.TrendingStatuses, Timeline.TrendingHashtags, Timeline.TrendingLinks, + is Timeline.Link, -> PUBLIC Timeline.Conversations -> null } diff --git a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt index 036441312..41c35c932 100644 --- a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt +++ b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt @@ -111,4 +111,13 @@ enum class ServerOperation(id: String, versions: List) { 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), + ), + ), } diff --git a/core/model/src/main/kotlin/app/pachli/core/model/Timeline.kt b/core/model/src/main/kotlin/app/pachli/core/model/Timeline.kt index 2b7b4df16..8af8f1e84 100644 --- a/core/model/src/main/kotlin/app/pachli/core/model/Timeline.kt +++ b/core/model/src/main/kotlin/app/pachli/core/model/Timeline.kt @@ -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 diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index b3576d11e..8ec9756d0 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -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. * diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterContext.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterContext.kt index 1323c8c50..0ba36e80f 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterContext.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterContext.kt @@ -73,6 +73,7 @@ enum class FilterContext { Timeline.TrendingStatuses, Timeline.TrendingHashtags, Timeline.TrendingLinks, + is Timeline.Link, -> PUBLIC Timeline.Conversations -> null } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index 79b8ee4e7..0f0840057 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -152,6 +152,14 @@ interface MastodonApi { @Query("limit") limit: Int? = null, ): Response> + @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> + @GET("api/v1/notifications") suspend fun notifications( /** Return results older than this ID */ diff --git a/core/ui/src/main/res/values/actions.xml b/core/ui/src/main/res/values/actions.xml index 05abc8279..390b75370 100644 --- a/core/ui/src/main/res/values/actions.xml +++ b/core/ui/src/main/res/values/actions.xml @@ -46,6 +46,9 @@ + + +