Ignore "@instance..." part of username when computing status length (#3392)
* Move compose.* tests to own namespace * Ignore "@instance..." part of username when computing status length In a status with a mention ("@foo@example.org") only the "@foo" part should be included in the calculated status length. It wasn't, so the app was prevening people from posting statuses that should have been allowed. Fix this. - Lift the length calculation code in to a separate static function (easier and faster to test) - Add a `MentionSpan` type, to reuse existing code for detecting mentions - Fix a bug in `FakeSpannable.getSpans()` (it was returning the outer type, not the wrapped inner span) - Add additional fast tests The tests made sense under the `components.compose.ComposeActivity` package, so I also created that and moved the existing ComposeActivity tests there. Fixes https://github.com/tuskyapp/Tusky/issues/3339 * Static import assertEquals
This commit is contained in:
parent
f309c7750f
commit
6dfdaec425
|
@ -31,6 +31,8 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.URLSpan
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
@ -91,6 +93,7 @@ import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||||
|
import com.keylesspalace.tusky.util.MentionSpan
|
||||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||||
import com.keylesspalace.tusky.util.getInitialLanguages
|
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||||
import com.keylesspalace.tusky.util.getLocaleList
|
import com.keylesspalace.tusky.util.getLocaleList
|
||||||
|
@ -883,20 +886,11 @@ class ComposeActivity :
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun calculateTextLength(): Int {
|
fun calculateTextLength(): Int {
|
||||||
var offset = 0
|
return statusLength(
|
||||||
val urlSpans = binding.composeEditField.urls
|
binding.composeEditField.text,
|
||||||
if (urlSpans != null) {
|
binding.composeContentWarningField.text,
|
||||||
for (span in urlSpans) {
|
charactersReservedPerUrl
|
||||||
// it's expected that this will be negative
|
)
|
||||||
// when the url length is less than the reserved character count
|
|
||||||
offset += (span.url.length - charactersReservedPerUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var length = binding.composeEditField.length() - offset
|
|
||||||
if (viewModel.showContentWarning.value) {
|
|
||||||
length += binding.composeContentWarningField.length()
|
|
||||||
}
|
|
||||||
return length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -1352,5 +1346,53 @@ class ComposeActivity :
|
||||||
fun canHandleMimeType(mimeType: String?): Boolean {
|
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
|
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the effective status length.
|
||||||
|
*
|
||||||
|
* Some text is counted differently:
|
||||||
|
*
|
||||||
|
* In the status body:
|
||||||
|
*
|
||||||
|
* - URLs always count for [urlLength] characters irrespective of their actual length
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#links)
|
||||||
|
* - Mentions ("@user@some.instance") only count the "@user" part
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#mentions)
|
||||||
|
* - Hashtags are always treated as their actual length, including the "#"
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#hashtags)
|
||||||
|
*
|
||||||
|
* Content warning text is always treated as its full length, URLs and other entities
|
||||||
|
* are not treated differently.
|
||||||
|
*
|
||||||
|
* @param body status body text
|
||||||
|
* @param contentWarning optional content warning text
|
||||||
|
* @param urlLength the number of characters attributed to URLs
|
||||||
|
* @return the effective status length
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
||||||
|
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
|
||||||
|
.fold(0) { acc, span ->
|
||||||
|
// Accumulate a count of characters to be *ignored* in the final length
|
||||||
|
acc + when (span) {
|
||||||
|
is MentionSpan -> {
|
||||||
|
// Ignore everything from the second "@" (if present)
|
||||||
|
span.url.length - (
|
||||||
|
span.url.indexOf("@", 1).takeIf { it >= 0 }
|
||||||
|
?: span.url.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Expected to be negative if the URL length < maxUrlLength
|
||||||
|
span.url.length - urlLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content warning text is treated as is, URLs or mentions there are not special
|
||||||
|
contentWarning?.let { length += it.length }
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -156,7 +156,7 @@ private fun getCustomSpanForMention(mentions: List<Mention>, span: URLSpan, list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan {
|
private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan {
|
||||||
return object : NoUnderlineURLSpan(url) {
|
return object : MentionSpan(url) {
|
||||||
override fun onClick(view: View) = listener.onViewAccount(mentionId)
|
override fun onClick(view: View) = listener.onViewAccount(mentionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,9 +19,14 @@ import android.text.TextPaint
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
|
||||||
open class NoUnderlineURLSpan(
|
open class NoUnderlineURLSpan constructor(val url: String) : URLSpan(url) {
|
||||||
url: String
|
|
||||||
) : URLSpan(url) {
|
// This should not be necessary. But if you don't do this the [StatusLengthTest] tests
|
||||||
|
// fail. Without this, accessing the `url` property, or calling `getUrl()` (which should
|
||||||
|
// automatically call through to [UrlSpan.getURL]) returns null.
|
||||||
|
override fun getURL(): String {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateDrawState(ds: TextPaint) {
|
override fun updateDrawState(ds: TextPaint) {
|
||||||
super.updateDrawState(ds)
|
super.updateDrawState(ds)
|
||||||
|
@ -32,3 +37,8 @@ open class NoUnderlineURLSpan(
|
||||||
view.context.openLink(url)
|
view.context.openLink(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mentions of other users ("@user@example.org")
|
||||||
|
*/
|
||||||
|
open class MentionSpan(url: String) : NoUnderlineURLSpan(url)
|
||||||
|
|
|
@ -131,6 +131,7 @@ private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, star
|
||||||
return when (matchType) {
|
return when (matchType) {
|
||||||
FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end))
|
FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end))
|
||||||
FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end))
|
FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end))
|
||||||
|
FoundMatchType.MENTION -> MentionSpan(string.substring(start, end))
|
||||||
else -> ForegroundColorSpan(colour)
|
else -> ForegroundColorSpan(colour)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -136,7 +136,7 @@ class SpanUtilsTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
|
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
|
||||||
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it) }
|
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it.span) }
|
||||||
.map { it.span }
|
.map { it.span }
|
||||||
.toTypedArray() as Array<T>
|
.toTypedArray() as Array<T>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* Copyright 2018 charlag
|
/*
|
||||||
|
* Copyright 2018 Tusky Contributors
|
||||||
*
|
*
|
||||||
* This file is a part of Tusky.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -11,15 +12,17 @@
|
||||||
* Public License for more details.
|
* Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>.
|
||||||
|
*/
|
||||||
|
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Looper.getMainLooper
|
import android.os.Looper.getMainLooper
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Tusky Contributors
|
||||||
|
*
|
||||||
|
* 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.components.compose.ComposeActivity
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.SpanUtilsTest
|
||||||
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
|
import com.keylesspalace.tusky.util.highlightSpans
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class StatusLengthTest(
|
||||||
|
private val text: String,
|
||||||
|
private val expectedLength: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
@Parameterized.Parameters(name = "{0}")
|
||||||
|
@JvmStatic
|
||||||
|
fun data(): Iterable<Any> {
|
||||||
|
return listOf(
|
||||||
|
arrayOf("", 0),
|
||||||
|
arrayOf(" ", 1),
|
||||||
|
arrayOf("123", 3),
|
||||||
|
// "@user@server" should be treated as "@user"
|
||||||
|
arrayOf("123 @example@example.org", 12),
|
||||||
|
// URLs under 23 chars are treated as 23 chars
|
||||||
|
arrayOf("123 http://example.url", 27),
|
||||||
|
// URLs over 23 chars are treated as 23 chars
|
||||||
|
arrayOf("123 http://urlthatislongerthan23characters.example.org", 27),
|
||||||
|
// Short hashtags are treated as is
|
||||||
|
arrayOf("123 #basictag", 13),
|
||||||
|
// Long hashtags are *also* treated as is (not treated as 23, like URLs)
|
||||||
|
arrayOf("123 #atagthatislongerthan23characters", 37)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun statusLength_matchesExpectations() {
|
||||||
|
val spannedText = SpanUtilsTest.FakeSpannable(text)
|
||||||
|
highlightSpans(spannedText, 0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expectedLength,
|
||||||
|
ComposeActivity.statusLength(spannedText, null, 23)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun statusLength_withCwText_matchesExpectations() {
|
||||||
|
val spannedText = SpanUtilsTest.FakeSpannable(text)
|
||||||
|
highlightSpans(spannedText, 0)
|
||||||
|
|
||||||
|
val cwText = SpanUtilsTest.FakeSpannable(
|
||||||
|
"a @example@example.org #hashtagmention and http://example.org URL"
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
expectedLength + cwText.length,
|
||||||
|
ComposeActivity.statusLength(spannedText, cwText, 23)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky.components.compose.ComposeTokenizer
|
||||||
|
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
|
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
Loading…
Reference in New Issue