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:
parent
f822234995
commit
addce87eb6
34 changed files with 1294 additions and 296 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
239
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
Normal file
239
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
Normal 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"
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,6 @@ open class NoUnderlineURLSpan(
|
|||
}
|
||||
|
||||
override fun onClick(view: View) {
|
||||
LinkHelper.openLink(url, view.context)
|
||||
view.context.openLink(url)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue