From d3feca3a10121981b5ca56e90636df293bd90eb8 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Thu, 28 Nov 2024 19:15:31 +0100 Subject: [PATCH] mastodon-web-like trailing hashtag bar (#4761) Rationale: Since the mastodon web UI has started stripping "trailing" hashtags from post content and shoving it into an ellipsized section at the bottom of posts, the general hashtag : content ratio is rising. This is an attempt at adopting a similar functionality for Tusky. Before: Screenshot of a hashtag-heavy post on Tusky
nightly After: ![Screenshot of the same post on this branch](https://github.com/user-attachments/assets/fa99964d-a057-4727-b9f0-1251a199d5f8) --- .../tusky/TabPreferenceActivity.kt | 8 +- .../tusky/adapter/StatusBaseViewHolder.java | 7 +- .../StatusNotificationViewHolder.kt | 2 +- .../viewthread/edits/ViewEditsAdapter.kt | 2 +- .../keylesspalace/tusky/util/LinkHelper.kt | 73 ++++++++++++++++++- .../keylesspalace/tusky/util/StringUtils.kt | 6 ++ app/src/main/res/layout/item_status.xml | 20 ++++- .../main/res/layout/item_status_detailed.xml | 19 ++++- .../tusky/util/LinkHelperTest.kt | 68 +++++++++++++++++ 9 files changed, 190 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index e7ab6c75c1..2e212b8ab4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -40,11 +40,11 @@ import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.databinding.DialogAddHashtagBinding import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.hashtagPattern import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint -import java.util.regex.Pattern import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -71,10 +71,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec resources.getDimension(R.dimen.selected_drag_item_elevation) } - private val hashtagRegex by unsafeLazy { - Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) - } - private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { toggleFab(false) @@ -285,7 +281,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private fun validateHashtag(input: CharSequence?): Boolean { val trimmedInput = input?.trim() ?: "" - return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches() + return trimmedInput.isNotEmpty() && hashtagPattern.matcher(trimmedInput).matches() } private fun updateAvailableTabs() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 49894626e5..56466396c8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -121,6 +121,7 @@ public static class Key { protected final ConstraintLayout statusContainer; private final TextView translationStatusView; private final Button untranslateButton; + private final TextView trailingHashtagView; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); @@ -183,6 +184,7 @@ protected StatusBaseViewHolder(@NonNull View itemView) { translationStatusView = itemView.findViewById(R.id.status_translation_status); untranslateButton = itemView.findViewById(R.id.status_button_untranslate); + trailingHashtagView = itemView.findViewById(R.id.status_trailing_hashtags_content); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); @@ -284,7 +286,7 @@ private void setTextVisible(boolean sensitive, if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); - LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); + LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener, this.trailingHashtagView); for (int i = 0; i < mediaLabels.length; ++i) { updateMediaLabel(i, sensitive, true); } @@ -295,6 +297,9 @@ private void setTextVisible(boolean sensitive, } } else { hidePoll(); + if (trailingHashtagView != null) { + trailingHashtagView.setVisibility(View.GONE); + } LinkHelper.setClickableMentions(this.content, mentions, listener); } if (TextUtils.isEmpty(this.content.getText())) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt index e1a51c3c67..5c3202cfc3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -346,7 +346,7 @@ internal class StatusNotificationViewHolder( emojifiedText, statusViewData.actionable.mentions, statusViewData.actionable.tags, - listener + listener, ) val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify( statusViewData.actionable.emojis, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 006d90b021..ca01bc6c47 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -130,7 +130,7 @@ class ViewEditsAdapter( emojifiedText, emptyList(), emptyList(), - listener + listener, ) if (edit.poll == null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 7d83857c36..6694b7fc1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.settings.PrefKeys import java.net.URI import java.net.URISyntaxException +import java.util.regex.Pattern fun getDomain(urlString: String?): String { val host = urlString?.toUri()?.host @@ -70,27 +71,91 @@ fun getDomain(urlString: String?): String { * @param content containing text with mentions, links, or hashtags * @param mentions any '@' mentions which are known to be in the content * @param listener to notify about particular spans that are clicked + * @param trailingHashtagView a text view to fill with trailing / out-of-band hashtags */ fun setClickableText( view: TextView, content: CharSequence, mentions: List, tags: List?, - listener: LinkListener + listener: LinkListener, + trailingHashtagView: TextView? = null, ) { val spannableContent = markupHiddenUrls(view, content) + val (endOfContent, trailingHashtags) = when { + trailingHashtagView == null || tags.isNullOrEmpty() -> Pair(spannableContent.length, emptyList()) + else -> getTrailingHashtags(spannableContent) + } + var inlineHashtagSpanCount = 0 view.text = spannableContent.apply { styleQuoteSpans(view) - getSpans(0, spannableContent.length, URLSpan::class.java).forEach { span -> + getSpans(0, endOfContent, URLSpan::class.java).forEach { span -> + if (get(getSpanStart(span)) == '#') { + inlineHashtagSpanCount += 1 + } setClickableText(span, this, mentions, tags, listener) } - } + }.subSequence(0, endOfContent).trimEnd() + view.movementMethod = NoTrailingSpaceLinkMovementMethod + + val showHashtagBar = (trailingHashtags.isNotEmpty() || inlineHashtagSpanCount != tags?.size) + // I don't _love_ setting the visibility here, but the alternative is to duplicate the logic in other places + trailingHashtagView?.visible(showHashtagBar) + + if (showHashtagBar) { + trailingHashtagView?.apply { + text = SpannableStringBuilder().apply { + tags?.forEachIndexed { index, tag -> + val text = "#${tag.name}" + append(text, getCustomSpanForTag(text, tags, URLSpan(tag.url), listener), 0) + if (index != tags.lastIndex) { + append(" ") + } + } + } + } + } +} + +private val trailingHashtagExpression by unsafeLazy { + Pattern.compile("""$WORD_BREAK_EXPRESSION(#$HASHTAG_EXPRESSION$WORD_BREAK_FROM_SPACE_EXPRESSION+)*""", Pattern.CASE_INSENSITIVE) +} + +/** + * Find the "trailing" hashtags in spanned content + * These are hashtags in lines consisting *only* of hashtags at the end of the post + */ +@VisibleForTesting +internal fun getTrailingHashtags(content: Spanned): Pair> { + // split() instead of lines() because we need to be able to account for the length of the removed delimiter + val trailingContentLength = content.split('\r', '\n').asReversed().takeWhile { line -> + line.isBlank() || trailingHashtagExpression.matcher(line).matches() + }.sumOf { it.length + 1 } // length + 1 to include the stripped line ending character + + return when (trailingContentLength) { + 0 -> Pair(content.length, emptyList()) + else -> { + val trailingContentOffset = content.length - trailingContentLength + Pair( + trailingContentOffset, + content.getSpans(trailingContentOffset, content.length, URLSpan::class.java) + .filter { content[content.getSpanStart(it)] == '#' } // just in case + .map { spanToHashtag(content, it) } + ) + } + } } +// URLSpan("#tag", url) -> Hashtag("tag", url) +private fun spanToHashtag(content: Spanned, span: URLSpan) = HashTag( + content.subSequence(content.getSpanStart(span) + 1, content.getSpanEnd(span)).toString(), + span.url, +) + @VisibleForTesting -fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { +internal fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { val spannableContent = SpannableStringBuilder(content) val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) val obscuredLinkSpans = originalSpans.filter { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 3a9388d65d..fe04cd95ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -3,10 +3,16 @@ package com.keylesspalace.tusky.util import android.text.Spanned +import java.util.regex.Pattern import kotlin.random.Random private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +const val WORD_BREAK_EXPRESSION = """(^|$|[^\p{L}\p{N}_])""" +const val WORD_BREAK_FROM_SPACE_EXPRESSION = """(^|$|\s)""" +const val HASHTAG_EXPRESSION = "([\\w_]*[\\p{Alpha}_][\\w_]*)" +val hashtagPattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE) + fun randomAlphanumericString(count: Int): String { val chars = CharArray(count) for (i in 0 until count) { diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index e3889b6315..9062574240 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -338,6 +338,24 @@ app:layout_constraintTop_toBottomOf="@id/status_poll_button" tools:text="7 votes • 7 hours remaining" /> + + diff --git a/app/src/main/res/layout/item_status_detailed.xml b/app/src/main/res/layout/item_status_detailed.xml index 27814c563e..b5dd653c3e 100644 --- a/app/src/main/res/layout/item_status_detailed.xml +++ b/app/src/main/res/layout/item_status_detailed.xml @@ -295,6 +295,23 @@ app:layout_constraintTop_toBottomOf="@id/status_poll_button" tools:text="7 votes • 7 hours remaining" /> + + + assertEquals(tag.name, trailingHashtags[index].name) + assertEquals(tag.url, trailingHashtags[index].url) + } + } + } + + @Test + fun `get trailing hashtags ignores inline tags`() { + for (separator in listOf(" ", "\t", "\n", "\r\n")) { + val content = SpannableStringBuilder("some content with inline tag ").apply { + append("#inline", URLSpan("https://example.com/tag/inline"), 0) + append(" followed by trailing tags\n") + for (tag in tags) { + append(separator) + append("#${tag.name}", URLSpan(tag.url), 0) + append(separator) + } + } + + val (_, trailingHashtags) = getTrailingHashtags(content) + assertEquals(tags.size, trailingHashtags.size) + tags.forEachIndexed { index, tag -> + assertEquals(tag.name, trailingHashtags[index].name) + assertEquals(tag.url, trailingHashtags[index].url) + } + } + } + @RunWith(Parameterized::class) class UrlMatchingTests(private val url: String, private val expectedResult: Boolean) { companion object {