Revert "Generalize url scheme parsing/highlighting (#1008)"
This reverts commit 85610a8311
.
This commit is contained in:
parent
85593b519b
commit
eeaadea905
10 changed files with 279 additions and 317 deletions
|
@ -300,7 +300,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
|
|||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView)
|
||||
setClickableText(accountNoteTextView, emojifiedNote, null, this)
|
||||
LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this)
|
||||
|
||||
accountLockedImageView.visible(account.locked)
|
||||
accountBadgeTextView.visible(account.bot)
|
||||
|
@ -365,7 +365,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
|
|||
if (account.isRemote()) {
|
||||
accountRemoveView.show()
|
||||
accountRemoveView.setOnClickListener {
|
||||
openLink(account.url, this)
|
||||
LinkHelper.openLink(account.url, this)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -567,7 +567,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
|
|||
R.id.action_open_in_web -> {
|
||||
// If the account isn't loaded yet, eat the input.
|
||||
if (loadedAccount != null) {
|
||||
openLink(loadedAccount?.url, this)
|
||||
LinkHelper.openLink(loadedAccount?.url, this)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import android.widget.LinearLayout
|
|||
import com.keylesspalace.tusky.entity.SearchResults
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
@ -156,7 +156,7 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
|
||||
@VisibleForTesting
|
||||
open fun openLink(url: String) {
|
||||
openLink(url, this)
|
||||
LinkHelper.openLink(url, this)
|
||||
}
|
||||
|
||||
private fun showQuerySheet() {
|
||||
|
|
|
@ -86,7 +86,7 @@ import com.keylesspalace.tusky.util.DownsizeImageTask;
|
|||
import com.keylesspalace.tusky.util.ListUtils;
|
||||
import com.keylesspalace.tusky.util.MentionTagTokenizer;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
import com.keylesspalace.tusky.util.SpanUtils;
|
||||
import com.keylesspalace.tusky.util.SpanUtilsKt;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.view.ComposeOptionsListener;
|
||||
|
@ -501,7 +501,7 @@ public final class ComposeActivity
|
|||
// Setup the main text field.
|
||||
textEditor.setOnCommitContentListener(this);
|
||||
final int mentionColour = textEditor.getLinkTextColors().getDefaultColor();
|
||||
SpanUtils.highlightSpans(textEditor.getText(), mentionColour);
|
||||
SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour);
|
||||
textEditor.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
|
@ -513,7 +513,7 @@ public final class ComposeActivity
|
|||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
SpanUtils.highlightSpans(editable, mentionColour);
|
||||
SpanUtilsKt.highlightSpans(editable, mentionColour);
|
||||
updateVisibleCharactersLeft();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import kotlinx.android.synthetic.main.item_account_field.view.*
|
||||
|
||||
class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() {
|
||||
|
@ -44,7 +44,7 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView
|
|||
val field = fields[position]
|
||||
viewHolder.nameTextView.text = field.name
|
||||
val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView)
|
||||
setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
|
||||
LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
|
||||
|
||||
if(field.verifiedAt != null) {
|
||||
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
|
|
|
@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.text.InputFilter;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
|
@ -27,6 +28,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|||
import com.keylesspalace.tusky.util.DateUtils;
|
||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.view.MediaPreviewImageView;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
|
241
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
Normal file
241
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
Normal file
|
@ -0,0 +1,241 @@
|
|||
/* 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.preference.PreferenceManager;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
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 com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
|
||||
import java.lang.CharSequence;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
public class LinkHelper {
|
||||
private static String getDomain(String urlString) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(urlString);
|
||||
} catch (URISyntaxException e) {
|
||||
return "";
|
||||
}
|
||||
String host = uri.getHost();
|
||||
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, Spanned content,
|
||||
@Nullable Status.Mention[] mentions, final LinkListener listener) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(content);
|
||||
URLSpan[] urlSpans = content.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 ClickableSpanNoUnderline() {
|
||||
@Override
|
||||
public void onClick(View widget) { listener.onViewTag(tag); }
|
||||
};
|
||||
} else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) {
|
||||
String accountUsername = text.subSequence(1, text.length()).toString();
|
||||
/* There may be multiple matches for users on different instances with the same
|
||||
* username. If a match has the same domain we know it's for sure the same, but if
|
||||
* that can't be found then just go with whichever one matched last. */
|
||||
String id = null;
|
||||
for (Status.Mention mention : mentions) {
|
||||
if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) {
|
||||
id = mention.getId();
|
||||
if (mention.getUrl().contains(getDomain(span.getURL()))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
final String accountId = id;
|
||||
customSpan = new ClickableSpanNoUnderline() {
|
||||
@Override
|
||||
public void onClick(View widget) { listener.onViewAccount(accountId); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (customSpan == null) {
|
||||
customSpan = new CustomURLSpan(span.getURL()) {
|
||||
@Override
|
||||
public void onClick(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.setLinksClickable(true);
|
||||
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 Status.Mention[] mentions, final LinkListener listener) {
|
||||
if (mentions == null || mentions.length == 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 ClickableSpanNoUnderline() {
|
||||
@Override
|
||||
public void onClick(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("URLSpan", "Actvity was not found for intent, " + intent.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.getColorById(context, "custom_tab_toolbar");
|
||||
|
||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
|
||||
builder.setToolbarColor(toolbarColor);
|
||||
CustomTabsIntent customTabsIntent = builder.build();
|
||||
try {
|
||||
String packageName = CustomTabsHelper.getPackageNameToUse(context);
|
||||
|
||||
//If we cant find a package name, it means theres no browser that supports
|
||||
//Chrome Custom Tabs installed. So, we fallback to the webview
|
||||
if (packageName == null) {
|
||||
openLinkInBrowser(uri, context);
|
||||
} else {
|
||||
customTabsIntent.intent.setPackage(packageName);
|
||||
customTabsIntent.launchUrl(context, uri);
|
||||
}
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,262 +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>. */
|
||||
|
||||
@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.preference.PreferenceManager
|
||||
import android.text.ParcelableSpan
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
import java.util.HashSet
|
||||
|
||||
const val ZERO_WIDTH_SPACE = "\u200B"
|
||||
private const val TAG = "LinkHelper"
|
||||
|
||||
/**
|
||||
* 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: Spanned, mentions: Array<Status.Mention>?, listener: LinkListener) {
|
||||
val builder = SpannableStringBuilder(content)
|
||||
highlightSpans(builder, view.linkTextColors.defaultColor)
|
||||
val urlSpans = builder.getSpans(0, content.length, URLSpan::class.java)
|
||||
for (span in urlSpans) {
|
||||
replaceSpan(builder, span, getLinkSpan(span.url, listener))
|
||||
}
|
||||
|
||||
val otherSpans = builder.getSpans(0, content.length, ForegroundColorSpan::class.java)
|
||||
val usedMentionIds = HashSet<String>()
|
||||
|
||||
for (span in otherSpans) {
|
||||
val start = builder.getSpanStart(span)
|
||||
val end = builder.getSpanEnd(span)
|
||||
val text = builder.subSequence(start, end)
|
||||
|
||||
val customSpan = when (text[0]) {
|
||||
'#' -> getTagSpan(text.substring(1), listener)
|
||||
'@' -> {
|
||||
if (!mentions.isNullOrEmpty()) {
|
||||
/* There may be multiple matches for users on different instances with the same
|
||||
* username. If a match has the same domain we know it's for sure the same, but if
|
||||
* that can't be found then just go with whichever one matched last. */
|
||||
firstUnusedMention(text.substring(1), mentions, usedMentionIds)?.let { id ->
|
||||
usedMentionIds.add(id)
|
||||
getAccountSpan(id, listener)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
replaceSpan(builder, span, customSpan)
|
||||
}
|
||||
|
||||
view.text = builder
|
||||
view.linksClickable = true
|
||||
view.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a span within a spannable string builder
|
||||
* @param builder the builder to replace spans within
|
||||
* @param oldSpan the span to be replaced
|
||||
* @param newSpan the new span to be used
|
||||
*/
|
||||
private fun replaceSpan(builder: SpannableStringBuilder, oldSpan: ParcelableSpan?, newSpan: ClickableSpan?) {
|
||||
val start = builder.getSpanStart(oldSpan)
|
||||
val end = builder.getSpanEnd(oldSpan)
|
||||
val flags = builder.getSpanFlags(oldSpan)
|
||||
|
||||
builder.removeSpan(oldSpan)
|
||||
builder.setSpan(newSpan, 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[end] == '\n') {
|
||||
builder.insert(end, ZERO_WIDTH_SPACE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first account id with matching username from mentions that isn't contained in usedIds,
|
||||
* or the id of the last matching account, if all matching ids are already contained
|
||||
* @param username the username to match
|
||||
* @param mentions the mentions to search
|
||||
* @param usedIds the collection of ids already used
|
||||
*/
|
||||
private fun firstUnusedMention(username: String, mentions: Array<Status.Mention>, usedIds: Collection<String>): String? {
|
||||
var id: String? = null
|
||||
for (mention in mentions) {
|
||||
if (mention.localUsername.equals(username, true)) {
|
||||
id = mention.id
|
||||
if (!usedIds.contains(id)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private fun getTagSpan(tag: String, listener: LinkListener): ClickableSpan {
|
||||
return object : ClickableSpanNoUnderline() {
|
||||
override fun onClick(widget: View) {
|
||||
listener.onViewTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAccountSpan(id: String?, listener: LinkListener): ClickableSpan {
|
||||
return object : ClickableSpanNoUnderline() {
|
||||
override fun onClick(widget: View) {
|
||||
listener.onViewAccount(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLinkSpan(url: String, listener: LinkListener): ClickableSpan {
|
||||
return object: CustomURLSpan(url) {
|
||||
override fun onClick(widget: View?) {
|
||||
listener.onViewUrl(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Array<Status.Mention>?, listener: LinkListener) {
|
||||
if (mentions.isNullOrEmpty()) {
|
||||
view.text = null
|
||||
return
|
||||
}
|
||||
val builder = SpannableStringBuilder()
|
||||
var start = 0
|
||||
var end = 0
|
||||
var firstMention = true
|
||||
|
||||
for (mention in mentions) {
|
||||
val accountUsername = mention.localUsername
|
||||
val customSpan = getAccountSpan(mention.id, listener)
|
||||
|
||||
end += 1 + accountUsername!!.length // length of @ + username
|
||||
val 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(ZERO_WIDTH_SPACE) // same reasoning as in setClickableText
|
||||
end += 1 // shift position to take the previous character into account
|
||||
start = end
|
||||
}
|
||||
view.text = builder
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
fun openLink(url: String?, context: Context) {
|
||||
val uri = Uri.parse(url).normalizeScheme()
|
||||
|
||||
val 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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
fun openLinkInCustomTab(uri: Uri, context: Context) {
|
||||
val toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar")
|
||||
|
||||
val builder = CustomTabsIntent.Builder()
|
||||
builder.setToolbarColor(toolbarColor)
|
||||
val customTabsIntent = builder.build()
|
||||
try {
|
||||
val packageName = CustomTabsHelper.getPackageNameToUse(context)
|
||||
|
||||
//If we cant find a package name, it means theres no browser that supports
|
||||
//Chrome Custom Tabs installed. So, we fallback to the webview
|
||||
if (packageName == null) {
|
||||
openLinkInBrowser(uri, context)
|
||||
} else {
|
||||
customTabsIntent.intent.setPackage(packageName)
|
||||
customTabsIntent.launchUrl(context, uri)
|
||||
}
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w(TAG, "Activity was not found for intent, $customTabsIntent")
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
@file:JvmName("SpanUtils")
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.text.Spannable
|
||||
|
@ -20,24 +19,25 @@ private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)"
|
|||
*/
|
||||
private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"
|
||||
|
||||
private const val WORD_START_PATTERN = "^|\\b"
|
||||
private const val SCHEME_PATTERN = "\\p{Alpha}[\\p{Alpha}\\d\\.\\-\\+]+"
|
||||
private const val URL_REGEX = "(?:(${WORD_START_PATTERN})(${SCHEME_PATTERN})://[^\\s]+)"
|
||||
private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)"
|
||||
private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)"
|
||||
|
||||
/**
|
||||
* Dump of android.util.Patterns.WEB_URL (with added schemes)
|
||||
* Dump of android.util.Patterns.WEB_URL
|
||||
*/
|
||||
private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?:(${WORD_START_PATTERN})(${SCHEME_PATTERN}))://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))")
|
||||
private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?i:http|https|rtsp)://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))")
|
||||
|
||||
private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java)
|
||||
private val finders = mapOf(
|
||||
FoundMatchType.URL to PatternFinder(':', URL_REGEX, 0),
|
||||
FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5),
|
||||
FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6),
|
||||
FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1),
|
||||
FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1)
|
||||
)
|
||||
|
||||
private enum class FoundMatchType {
|
||||
URL,
|
||||
HTTP_URL,
|
||||
HTTPS_URL,
|
||||
TAG,
|
||||
MENTION,
|
||||
}
|
||||
|
@ -59,38 +59,22 @@ private fun <T> clearSpans(text: Spannable, spanClass: Class<T>) {
|
|||
}
|
||||
|
||||
private fun findPattern(string: String, fromIndex: Int): FindCharsResult {
|
||||
var foundResult: FindCharsResult? = null
|
||||
val result = FindCharsResult()
|
||||
for (i in fromIndex..string.lastIndex) {
|
||||
val c = string[i]
|
||||
for ((matchType, finder) in finders) {
|
||||
if (finder.searchCharacter == c &&
|
||||
(finder.searchPrefixWidth == 0 ||
|
||||
(i - fromIndex) < finder.searchPrefixWidth ||
|
||||
for (matchType in FoundMatchType.values()) {
|
||||
val finder = finders[matchType]
|
||||
if (finder!!.searchCharacter == c
|
||||
&& ((i - fromIndex) < finder.searchPrefixWidth ||
|
||||
Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) {
|
||||
val result = FindCharsResult()
|
||||
result.matchType = matchType
|
||||
val patternStart = if (finder.searchPrefixWidth == 0) {
|
||||
fromIndex
|
||||
} else {
|
||||
Math.max(0, i - finder.searchPrefixWidth)
|
||||
}
|
||||
result.start = 0
|
||||
findEndOfPattern(string.substring(patternStart), result, finder.pattern)
|
||||
if (result.start >= 0 && result.end > result.start) {
|
||||
result.start += patternStart
|
||||
result.end += patternStart
|
||||
if (foundResult == null || result.start < foundResult.start) {
|
||||
foundResult = result
|
||||
result.start = Math.max(0, i - finder.searchPrefixWidth)
|
||||
findEndOfPattern(string, result, finder.pattern)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundResult != null) {
|
||||
return foundResult
|
||||
}
|
||||
}
|
||||
return FindCharsResult()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) {
|
||||
|
@ -103,7 +87,7 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P
|
|||
++result.start
|
||||
}
|
||||
when(result.matchType) {
|
||||
FoundMatchType.URL -> {
|
||||
FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> {
|
||||
// Preliminary url patterns are fast/permissive, now we'll do full validation
|
||||
if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) {
|
||||
result.end = end
|
||||
|
@ -116,7 +100,8 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P
|
|||
|
||||
private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle {
|
||||
return when(matchType) {
|
||||
FoundMatchType.URL -> CustomURLSpan(string.substring(start, end))
|
||||
FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end))
|
||||
FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end))
|
||||
else -> ForegroundColorSpan(colour)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,9 +19,9 @@ import android.content.Context
|
|||
import android.util.AttributeSet
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import kotlinx.android.synthetic.main.card_license.view.*
|
||||
|
||||
class LicenseCard
|
||||
|
@ -49,7 +49,7 @@ class LicenseCard
|
|||
licenseCardLink.hide()
|
||||
} else {
|
||||
licenseCardLink.text = link
|
||||
setOnClickListener { openLink(link, context) }
|
||||
setOnClickListener { LinkHelper.openLink(link, context) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,11 +10,11 @@ import org.junit.runners.Parameterized
|
|||
class SpanUtilsTest {
|
||||
@Test
|
||||
fun matchesMixedSpans() {
|
||||
val input = "one #one two dat://tw.wo?no=yes three @three four https://fo.ur/meh?foo=bar&wat=@at#hmm five #five ipfs://si.xx/?pick=up#sticks seven @seven "
|
||||
val input = "one #one two @two three https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five"
|
||||
val inputSpannable = FakeSpannable(input)
|
||||
highlightSpans(inputSpannable, 0xffffff)
|
||||
val spans = inputSpannable.spans
|
||||
Assert.assertEquals(7, spans.size)
|
||||
Assert.assertEquals(5, spans.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -38,9 +38,6 @@ class SpanUtilsTest {
|
|||
return listOf(
|
||||
"@mention",
|
||||
"#tag",
|
||||
"dat://thr.ee/meh?foo=bar&wat=@at#hmm",
|
||||
"ssb://thr.ee/meh?foo=bar&wat=@at#hmm",
|
||||
"ipfs://thr.ee/meh?foo=bar&wat=@at#hmm",
|
||||
"https://thr.ee/meh?foo=bar&wat=@at#hmm",
|
||||
"http://thr.ee/meh?foo=bar&wat=@at#hmm"
|
||||
)
|
||||
|
@ -67,7 +64,7 @@ class SpanUtilsTest {
|
|||
|
||||
@Test
|
||||
fun doesNotMatchSpanEmbeddedInText() {
|
||||
val inputSpannable = FakeSpannable("__${thingToHighlight}__")
|
||||
val inputSpannable = FakeSpannable("aa${thingToHighlight}aa")
|
||||
highlightSpans(inputSpannable, 0xffffff)
|
||||
val spans = inputSpannable.spans
|
||||
Assert.assertTrue(spans.isEmpty())
|
||||
|
@ -79,7 +76,6 @@ class SpanUtilsTest {
|
|||
highlightSpans(inputSpannable, 0xffffff)
|
||||
val spans = inputSpannable.spans
|
||||
Assert.assertEquals(1, spans.size)
|
||||
Assert.assertEquals(0, spans[0].start)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Reference in a new issue