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: 
This commit is contained in:
parent
9fb4e03ba7
commit
d3feca3a10
9 changed files with 190 additions and 15 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
} else {
|
||||
hidePoll();
|
||||
if (trailingHashtagView != null) {
|
||||
trailingHashtagView.setVisibility(View.GONE);
|
||||
}
|
||||
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
||||
}
|
||||
if (TextUtils.isEmpty(this.content.getText())) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ class ViewEditsAdapter(
|
|||
emojifiedText,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
listener
|
||||
listener,
|
||||
)
|
||||
|
||||
if (edit.poll == null) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue