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