Use tags from status when adding handlers to hashtag spans in status content (#2344)

* Migrate LinkHelper to kotlin

* Support tags field on statuses

* Use embedded tags list in status instead of text scraping to embed tag click handler.
Fixes #2283

* Make mentions and tags lists nonnullable

* Make LinkHelper.openLink a Context extension method

* Use builtin extension for uri conversion

* More cleanup in LinkHelper

* Add tests for LinkHelper.getDomain

* Unbreak tags in places that don't have a tag list (e.g. profiles)

* Fixup javadoc
This commit is contained in:
Levi Bard 2022-02-25 18:56:21 +01:00 committed by GitHub
commit addce87eb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1294 additions and 296 deletions

View file

@ -1,251 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
public class LinkHelper {
public static String getDomain(String urlString) {
URI uri;
try {
uri = new URI(urlString);
} catch (URISyntaxException e) {
return "";
}
String host = uri.getHost();
if(host == null) {
return "";
} else if (host.startsWith("www.")) {
return host.substring(4);
} else {
return host;
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @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
*/
public static void setClickableText(TextView view, CharSequence content,
@Nullable List<Status.Mention> mentions, final LinkListener listener) {
SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content);
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {
int start = builder.getSpanStart(span);
int end = builder.getSpanEnd(span);
int flags = builder.getSpanFlags(span);
CharSequence text = builder.subSequence(start, end);
ClickableSpan customSpan = null;
if (text.charAt(0) == '#') {
final String tag = text.subSequence(1, text.length()).toString();
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewTag(tag); }
};
} else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) {
// https://github.com/tuskyapp/Tusky/pull/2339
String id = null;
for (Status.Mention mention : mentions) {
if (mention.getUrl().equals(span.getURL())) {
id = mention.getId();
break;
}
}
if (id != null) {
final String accountId = id;
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); }
};
}
}
if (customSpan == null) {
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) {
listener.onViewUrl(getURL());
}
};
}
builder.removeSpan(span);
builder.setSpan(customSpan, start, end, flags);
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= builder.length() ||
builder.subSequence(end, end + 1).toString().equals("\n")){
builder.insert(end, "\u200B");
}
}
view.setText(builder);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableMentions(
TextView view, @Nullable List<Status.Mention> mentions, final LinkListener listener) {
if (mentions == null || mentions.size() == 0) {
view.setText(null);
return;
}
SpannableStringBuilder builder = new SpannableStringBuilder();
int start = 0;
int end = 0;
int flags;
boolean firstMention = true;
for (Status.Mention mention : mentions) {
String accountUsername = mention.getLocalUsername();
final String accountId = mention.getId();
ClickableSpan customSpan = new NoUnderlineURLSpan(mention.getUrl()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); }
};
end += 1 + accountUsername.length(); // length of @ + username
flags = builder.getSpanFlags(customSpan);
if (firstMention) {
firstMention = false;
} else {
builder.append(" ");
start += 1;
end += 1;
}
builder.append("@");
builder.append(accountUsername);
builder.setSpan(customSpan, start, end, flags);
builder.append("\u200B"); // same reasonning than in setClickableText
end += 1; // shift position to take the previous character into account
start = end;
}
view.setText(builder);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
public static CharSequence createClickableText(String text, String link) {
URLSpan span = new NoUnderlineURLSpan(link);
SpannableStringBuilder clickableText = new SpannableStringBuilder(text);
clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
return clickableText;
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @param url a string containing the url to open
* @param context context
*/
public static void openLink(String url, Context context) {
Uri uri = Uri.parse(url).normalizeScheme();
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("customTabs", false);
if (useCustomTabs) {
openLinkInCustomTab(uri, context);
} else {
openLinkInBrowser(uri, context);
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInBrowser(Uri uri, Context context) {
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w("LinkHelper", "Actvity was not found for intent, " + intent);
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInCustomTab(Uri uri, Context context) {
int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface);
int navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor);
int navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor);
CustomTabColorSchemeParams colorSchemeParams = new CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build();
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build();
try {
customTabsIntent.launchUrl(context, uri);
} catch (ActivityNotFoundException e) {
Log.w("LinkHelper", "Activity was not found for intent " + customTabsIntent);
openLinkInBrowser(uri, context);
}
}
}

View file

@ -0,0 +1,239 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@file:JvmName("LinkHelper")
package com.keylesspalace.tusky.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status.Mention
import com.keylesspalace.tusky.interfaces.LinkListener
fun getDomain(urlString: String?): String {
val host = urlString?.toUri()?.host
return when {
host == null -> ""
host.startsWith("www.") -> host.substring(4)
else -> host
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @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
*/
fun setClickableText(view: TextView, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, listener: LinkListener) {
view.text = SpannableStringBuilder.valueOf(content).apply {
getSpans(0, content.length, URLSpan::class.java).forEach {
setClickableText(it, this, mentions, tags, listener)
}
}
view.movementMethod = LinkMovementMethod.getInstance()
}
@VisibleForTesting
fun setClickableText(
span: URLSpan,
builder: SpannableStringBuilder,
mentions: List<Mention>,
tags: List<HashTag>?,
listener: LinkListener
) = builder.apply {
val start = getSpanStart(span)
val end = getSpanEnd(span)
val flags = getSpanFlags(span)
val text = subSequence(start, end)
val customSpan = when (text[0]) {
'#' -> getCustomSpanForTag(text, tags, span, listener)
'@' -> getCustomSpanForMention(mentions, span, listener)
else -> null
} ?: object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewUrl(url)
}
removeSpan(span)
setSpan(customSpan, start, end, flags)
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= length || subSequence(end, end + 1).toString() == "\n") {
insert(end, "\u200B")
}
}
@VisibleForTesting
fun getTagName(text: CharSequence, tags: List<HashTag>?, span: URLSpan): String? {
return when (tags) {
null -> text.subSequence(1, text.length).toString()
else -> tags.firstOrNull { it.url == span.url }?.name
}
}
private fun getCustomSpanForTag(text: CharSequence, tags: List<HashTag>?, span: URLSpan, listener: LinkListener): ClickableSpan? {
return getTagName(text, tags, span)?.let {
object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewTag(it)
}
}
}
private fun getCustomSpanForMention(mentions: List<Mention>, span: URLSpan, listener: LinkListener): ClickableSpan? {
// https://github.com/tuskyapp/Tusky/pull/2339
return mentions.firstOrNull { it.url == span.url }?.let {
getCustomSpanForMentionUrl(span.url, it.id, listener)
}
}
private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan {
return object : NoUnderlineURLSpan(url) {
override fun onClick(view: View) = listener.onViewAccount(mentionId)
}
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: LinkListener) {
if (mentions?.isEmpty() != false) {
view.text = null
return
}
view.text = SpannableStringBuilder().apply {
var start = 0
var end = 0
var flags: Int
var firstMention = true
for (mention in mentions) {
val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener)
end += 1 + mention.username.length // length of @ + username
flags = getSpanFlags(customSpan)
if (firstMention) {
firstMention = false
} else {
append(" ")
start += 1
end += 1
}
append("@")
append(mention.username)
setSpan(customSpan, start, end, flags)
append("\u200B") // same reasoning as in setClickableText
end += 1 // shift position to take the previous character into account
start = end
}
}
view.movementMethod = LinkMovementMethod.getInstance()
}
fun createClickableText(text: String, link: String): CharSequence {
return SpannableStringBuilder(text).apply {
setSpan(NoUnderlineURLSpan(link), 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @receiver the Context to open the link from
* @param url a string containing the url to open
*/
fun Context.openLink(url: String) {
val uri = url.toUri().normalizeScheme()
val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("customTabs", false)
if (useCustomTabs) {
openLinkInCustomTab(uri, this)
} else {
openLinkInBrowser(uri, this)
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
private fun openLinkInBrowser(uri: Uri?, context: Context) {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Actvity was not found for intent, $intent")
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
private fun openLinkInCustomTab(uri: Uri, context: Context) {
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
openLinkInBrowser(uri, context)
}
}
private const val TAG = "LinkHelper"

View file

@ -182,7 +182,7 @@ class ListStatusAccessibilityDelegate(
android.R.layout.simple_list_item_1,
textLinks
)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
) { _, which -> host.context.openLink(links[which].link) }
.show()
.let { forceFocus(it.listView) }
}

View file

@ -29,6 +29,6 @@ open class NoUnderlineURLSpan(
}
override fun onClick(view: View) {
LinkHelper.openLink(url, view.context)
view.context.openLink(url)
}
}