Skip to content

Commit

Permalink
mastodon-web-like trailing hashtag bar (#4761)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
Tak authored Nov 28, 2024
1 parent 9fb4e03 commit d3feca3
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class ViewEditsAdapter(
emojifiedText,
emptyList(),
emptyList(),
listener
listener,
)

if (edit.poll == null) {
Expand Down
73 changes: 69 additions & 4 deletions app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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
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 {
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 19 additions & 1 deletion app/src/main/res/layout/item_status.xml
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,24 @@
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />

<com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_trailing_hashtags_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="14dp"
android:hyphenationFrequency="full"
android:importantForAccessibility="no"
android:textColor="?android:textColorPrimary"
android:textIsSelectable="true"
android:textSize="?attr/status_text_medium"
android:singleLine="true"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
tools:text="#one #two #three #four #five #six #seven #eight #nine #ten" />

<ImageButton
android:id="@+id/status_reply"
style="@style/TuskyImageButton"
Expand All @@ -350,7 +368,7 @@
app:layout_constraintEnd_toStartOf="@id/status_inset"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:layout_constraintTop_toBottomOf="@id/status_trailing_hashtags_content"
app:srcCompat="@drawable/ic_reply_24dp"
tools:ignore="NegativeMargin" />

Expand Down
19 changes: 18 additions & 1 deletion app/src/main/res/layout/item_status_detailed.xml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,23 @@
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />

<com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_trailing_hashtags_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:hyphenationFrequency="full"
android:importantForAccessibility="no"
android:textColor="?android:textColorPrimary"
android:textIsSelectable="true"
android:textSize="?attr/status_text_medium"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="#one #two #three #four #five #six #seven #eight #nine #ten" />

<TextView
android:id="@+id/status_meta_info"
android:layout_width="0dp"
Expand All @@ -309,7 +326,7 @@
android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:layout_constraintTop_toBottomOf="@id/status_trailing_hashtags_content"
tools:text="21 Dec 2018 18:45" />

<View
Expand Down
68 changes: 68 additions & 0 deletions app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,74 @@ class LinkHelperTest {
}
}

@Test
fun `get trailing hashtags with empty content returns empty list`() {
assert(getTrailingHashtags(SpannableStringBuilder("")).second.isEmpty())
}

@Test
fun `get trailing hashtags with no hashtags returns empty list`() {
assert(getTrailingHashtags(SpannableStringBuilder("some untagged content")).second.isEmpty())
}

@Test
fun `get trailing hashtags with all inline hashtags returns empty list`() {
assert(getTrailingHashtags(SpannableStringBuilder("some #inline #tagged #content")).second.isEmpty())
}

@Test
fun `get trailing hashtags with one tag`() {
val content = SpannableStringBuilder("some content followed by tags:\n").apply {
tags.first().let { append("#${it.name}", URLSpan(it.url), 0) }
}

val (_, trailingHashtags) = getTrailingHashtags(content)
assertEquals(tags.first().name, trailingHashtags.single().name)
assertEquals(tags.first().url, trailingHashtags.single().url)
}

@Test
fun `get trailing hashtags with multiple tags`() {
for (separator in listOf(" ", "\t", "\n", "\r\n")) {
val content = SpannableStringBuilder("some content followed by tags:\n").apply {
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)
}
}
}

@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 {
Expand Down

0 comments on commit d3feca3

Please sign in to comment.