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 colormain
parent
df33d8a999
commit
42b13caffc
@ -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); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,151 @@ |
|||||||
|
package com.keylesspalace.tusky |
||||||
|
|
||||||
|
import android.text.Spannable |
||||||
|
import com.keylesspalace.tusky.util.highlightSpans |
||||||
|
import junit.framework.Assert |
||||||
|
import org.junit.Test |
||||||
|
import org.junit.runner.RunWith |
||||||
|
import org.junit.runners.Parameterized |
||||||
|
|
||||||
|
class SpanUtilsTest { |
||||||
|
@Test |
||||||
|
fun matchesMixedSpans() { |
||||||
|
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(5, spans.size) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun doesntMergeAdjacentURLs() { |
||||||
|
val firstURL = "http://first.thing" |
||||||
|
val secondURL = "https://second.thing" |
||||||
|
val inputSpannable = FakeSpannable("${firstURL} ${secondURL}") |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertEquals(2, spans.size) |
||||||
|
Assert.assertEquals(firstURL.length, spans[0].end - spans[0].start) |
||||||
|
Assert.assertEquals(secondURL.length, spans[1].end - spans[1].start) |
||||||
|
} |
||||||
|
|
||||||
|
@RunWith(Parameterized::class) |
||||||
|
class MatchingTests(private val thingToHighlight: String) { |
||||||
|
companion object { |
||||||
|
@Parameterized.Parameters(name = "{0}") |
||||||
|
@JvmStatic |
||||||
|
fun data(): Iterable<Any> { |
||||||
|
return listOf( |
||||||
|
"@mention", |
||||||
|
"#tag", |
||||||
|
"https://thr.ee/meh?foo=bar&wat=@at#hmm", |
||||||
|
"http://thr.ee/meh?foo=bar&wat=@at#hmm" |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun matchesSpanAtStart() { |
||||||
|
val inputSpannable = FakeSpannable(thingToHighlight) |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertEquals(1, spans.size) |
||||||
|
Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun matchesSpanNotAtStart() { |
||||||
|
val inputSpannable = FakeSpannable(" ${thingToHighlight}") |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertEquals(1, spans.size) |
||||||
|
Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun doesNotMatchSpanEmbeddedInText() { |
||||||
|
val inputSpannable = FakeSpannable("aa${thingToHighlight}aa") |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertTrue(spans.isEmpty()) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun doesNotMatchSpanEmbeddedInAnotherSpan() { |
||||||
|
val inputSpannable = FakeSpannable("@aa${thingToHighlight}aa") |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertEquals(1, spans.size) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun spansDoNotOverlap() { |
||||||
|
val begin = "@begin" |
||||||
|
val end = "#end" |
||||||
|
val inputSpannable = FakeSpannable("${begin} ${thingToHighlight} ${end}") |
||||||
|
highlightSpans(inputSpannable, 0xffffff) |
||||||
|
val spans = inputSpannable.spans |
||||||
|
Assert.assertEquals(3, spans.size) |
||||||
|
|
||||||
|
val middleSpan = spans.single ({ span -> span.start > 0 && span.end < inputSpannable.lastIndex }) |
||||||
|
Assert.assertEquals(begin.length + 1, middleSpan.start) |
||||||
|
Assert.assertEquals(inputSpannable.length - end.length - 1, middleSpan.end) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class FakeSpannable(private val text: String) : Spannable { |
||||||
|
val spans = mutableListOf<BoundedSpan>() |
||||||
|
|
||||||
|
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { |
||||||
|
spans.add(BoundedSpan(what, start, end)) |
||||||
|
} |
||||||
|
|
||||||
|
override fun <T : Any?> getSpans(start: Int, end: Int, type: Class<T>?): Array<T> { |
||||||
|
val matching = if (type == null) { |
||||||
|
ArrayList<T>() |
||||||
|
} else { |
||||||
|
spans.filter ({ it.start >= start && it.end <= end && type?.isAssignableFrom(it.span?.javaClass) }) |
||||||
|
.map({ it -> it.span }) |
||||||
|
.let { ArrayList(it) } |
||||||
|
} |
||||||
|
return matching.toArray() as Array<T> |
||||||
|
} |
||||||
|
|
||||||
|
override fun removeSpan(what: Any?) { |
||||||
|
spans.removeIf({ span -> span.span == what}) |
||||||
|
} |
||||||
|
|
||||||
|
override fun toString(): String { |
||||||
|
return text |
||||||
|
} |
||||||
|
|
||||||
|
override val length: Int |
||||||
|
get() = text.length |
||||||
|
|
||||||
|
class BoundedSpan(val span: Any?, val start: Int, val end: Int) |
||||||
|
|
||||||
|
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
|
||||||
|
override fun getSpanEnd(tag: Any?): Int { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
|
||||||
|
override fun getSpanFlags(tag: Any?): Int { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
|
||||||
|
override fun get(index: Int): Char { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
|
||||||
|
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
|
||||||
|
override fun getSpanStart(tag: Any?): Int { |
||||||
|
throw NotImplementedError() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue