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:

<img width="420" alt="Screenshot of a hashtag-heavy post on Tusky
nightly"
src="https://github.com/user-attachments/assets/09c286e8-6822-482a-904c-5cb3323ea0e1">


After:
![Screenshot of the same post on this
branch](https://github.com/user-attachments/assets/fa99964d-a057-4727-b9f0-1251a199d5f8)
This commit is contained in:
Levi Bard 2024-11-28 19:15:31 +01:00 committed by GitHub
commit d3feca3a10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 190 additions and 15 deletions

View file

@ -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<Mention>,
tags: List<HashTag>?,
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
fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder {
internal fun getTrailingHashtags(content: Spanned): Pair<Int, List<HashTag>> {
// 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
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 {

View file

@ -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) {