Behave like Mastodon web ui and only count URLs as 23 characters when composing (#629)

* Refactor-all-the-things version of the fix for issue #573

* Migrate SpanUtils to kotlin because why not

* Minimal fix for issue #573

* Add tests for compose spanning

* Clean up code suggestions

* Make FakeSpannable.getSpans implementation less awkward

* Add secondary validation pass for urls

* Address code review feedback

* Fixup type filtering in FakeSpannable again

* Make all mentions in compose activity use the default link color
This commit is contained in:
Levi Bard 2018-05-16 19:14:26 +02:00 committed by Konrad Pozniak
commit 7e1f5edeca
5 changed files with 326 additions and 154 deletions

View file

@ -62,6 +62,7 @@ import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
@ -101,7 +102,7 @@ import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.MediaUtils;
import com.keylesspalace.tusky.util.MentionTokenizer;
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;
@ -163,6 +164,8 @@ public final class ComposeActivity
private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_usernames";
private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra";
private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content";
// Mastodon only counts URLs as this long in terms of status character limits
static final int MAXIMUM_URL_LENGTH = 23;
@Inject
public MastodonApi mastodonApi;
@ -453,12 +456,11 @@ public final class ComposeActivity
// Setup the main text field.
textEditor.setOnCommitContentListener(this);
final int mentionColour = ThemeUtils.getColor(this, R.attr.compose_mention_color);
SpanUtils.highlightSpans(textEditor.getText(), mentionColour);
final int mentionColour = textEditor.getLinkTextColors().getDefaultColor();
SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour);
textEditor.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
updateVisibleCharactersLeft();
}
@Override
@ -467,7 +469,8 @@ public final class ComposeActivity
@Override
public void afterTextChanged(Editable editable) {
SpanUtils.highlightSpans(editable, mentionColour);
SpanUtilsKt.highlightSpans(editable, mentionColour);
updateVisibleCharactersLeft();
}
});
@ -765,12 +768,23 @@ public final class ComposeActivity
setStatusVisibility(visibility);
}
private void updateVisibleCharactersLeft() {
int charactersLeft = maximumTootCharacters - textEditor.length();
if (statusHideText) {
charactersLeft -= contentWarningEditor.length();
int calculateRemainingCharacters() {
int offset = 0;
URLSpan[] urlSpans = textEditor.getUrls();
if (urlSpans != null) {
for (URLSpan span : urlSpans) {
offset += Math.max(0, span.getURL().length() - MAXIMUM_URL_LENGTH);
}
}
this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", charactersLeft));
int remaining = maximumTootCharacters - textEditor.length() + offset;
if (statusHideText) {
remaining -= contentWarningEditor.length();
}
return remaining;
}
private void updateVisibleCharactersLeft() {
this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", calculateRemainingCharacters()));
}
private void onContentWarningChanged() {

View file

@ -1,142 +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.text.Spannable;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SpanUtils {
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
* Tag#HASHTAG_RE</a>.
*/
private static final String TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)";
private static Pattern TAG_PATTERN = Pattern.compile(TAG_REGEX, Pattern.CASE_INSENSITIVE);
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
* Account#MENTION_RE</a>
*/
private static final String MENTION_REGEX =
"(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)";
private static Pattern MENTION_PATTERN =
Pattern.compile(MENTION_REGEX, Pattern.CASE_INSENSITIVE);
private static class FindCharsResult {
int charIndex;
int stringIndex;
FindCharsResult() {
charIndex = -1;
stringIndex = -1;
}
}
private static FindCharsResult findChars(String string, int fromIndex, char[] chars) {
FindCharsResult result = new FindCharsResult();
final int length = string.length();
for (int i = fromIndex; i < length; i++) {
char c = string.charAt(i);
for (int j = 0; j < chars.length; j++) {
if (chars[j] == c) {
result.charIndex = j;
result.stringIndex = i;
return result;
}
}
}
return result;
}
private static FindCharsResult findStart(String string, int fromIndex, char[] chars) {
final int length = string.length();
while (fromIndex < length) {
FindCharsResult found = findChars(string, fromIndex, chars);
int i = found.stringIndex;
if (i < 0) {
break;
} else if (i == 0 || i >= 1 && Character.isWhitespace(string.codePointBefore(i))) {
return found;
} else {
fromIndex = i + 1;
}
}
return new FindCharsResult();
}
private static int findEndOfHashtag(String string, int fromIndex) {
Matcher matcher = TAG_PATTERN.matcher(string);
if (fromIndex >= 1) {
fromIndex--;
}
boolean found = matcher.find(fromIndex);
if (found) {
return matcher.end();
} else {
return -1;
}
}
private static int findEndOfMention(String string, int fromIndex) {
Matcher matcher = MENTION_PATTERN.matcher(string);
if (fromIndex >= 1) {
fromIndex--;
}
boolean found = matcher.find(fromIndex);
if (found) {
return matcher.end();
} else {
return -1;
}
}
/** Takes text containing mentions and hashtags and makes them the given colour. */
public static void highlightSpans(Spannable text, int colour) {
// Strip all existing colour spans.
int n = text.length();
ForegroundColorSpan[] oldSpans = text.getSpans(0, n, ForegroundColorSpan.class);
for (int i = oldSpans.length - 1; i >= 0; i--) {
text.removeSpan(oldSpans[i]);
}
// Colour the mentions and hashtags.
String string = text.toString();
int start;
int end = 0;
while (end < n) {
char[] chars = { '#', '@' };
FindCharsResult found = findStart(string, end, chars);
start = found.stringIndex;
if (start < 0) {
break;
}
if (found.charIndex == 0) {
end = findEndOfHashtag(string, start);
} else if (found.charIndex == 1) {
end = findEndOfMention(string, start);
} else {
break;
}
if (end < 0) {
break;
}
text.setSpan(new ForegroundColorSpan(colour), start, end,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
}
}
}

View file

@ -0,0 +1,131 @@
package com.keylesspalace.tusky.util
import android.text.Spannable
import android.text.Spanned
import android.text.style.CharacterStyle
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import java.util.regex.Pattern
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
* Tag#HASHTAG_RE</a>.
*/
private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)"
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
* Account#MENTION_RE</a>
*/
private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"
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
*/
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.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 {
HTTP_URL,
HTTPS_URL,
TAG,
MENTION,
}
private class FindCharsResult {
lateinit var matchType: FoundMatchType
var start: Int = -1
var end: Int = -1
}
private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int) {
val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE)
}
private fun <T> clearSpans(text: Spannable, spanClass: Class<T>) {
for(span in text.getSpans(0, text.length, spanClass)) {
text.removeSpan(span)
}
}
private fun findPattern(string: String, fromIndex: Int): FindCharsResult {
val result = FindCharsResult()
for (i in fromIndex..string.lastIndex) {
val c = string[i]
for (matchType in FoundMatchType.values()) {
val finder = finders[matchType]
if (finder!!.searchCharacter == c
&& ((i - fromIndex) < finder.searchPrefixWidth ||
Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) {
result.matchType = matchType
result.start = Math.max(0, i - finder.searchPrefixWidth)
findEndOfPattern(string, result, finder.pattern)
return result
}
}
}
return result
}
private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) {
val matcher = pattern.matcher(string)
if (matcher.find(result.start)) {
// Once we have API level 26+, we can use named captures...
val end = matcher.end()
result.start = matcher.start()
if (Character.isWhitespace(string.codePointAt(result.start))) {
++result.start
}
when(result.matchType) {
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
}
}
else -> result.end = end
}
}
}
private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle {
return when(matchType) {
FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end))
FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end))
else -> ForegroundColorSpan(colour)
}
}
/** Takes text containing mentions and hashtags and urls and makes them the given colour. */
fun highlightSpans(text: Spannable, colour: Int) {
// Strip all existing colour spans.
for (spanClass in spanClasses) {
clearSpans(text, spanClass)
}
// Colour the mentions and hashtags.
val string = text.toString()
val length = text.length
var start = 0
var end = 0
while (end >= 0 && end < length && start >= 0) {
// Search for url first because it can contain the other characters
val found = findPattern(string, end)
start = found.start
end = found.end
if (start >= 0 && end > start) {
text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
start += finders[found.matchType]!!.searchPrefixWidth
}
}
}