mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add ability to clear or toggle formatting.
This commit is contained in:
@@ -284,15 +284,11 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
public boolean hasStyling() {
|
||||
CharSequence trimmed = getTextTrimmed();
|
||||
return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
|
||||
return (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
|
||||
}
|
||||
|
||||
public @Nullable BodyRangeList getStyling() {
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
return MessageStyler.getStyling(getTextTrimmed());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return MessageStyler.getStyling(getTextTrimmed());
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
@@ -306,87 +302,94 @@ public class ComposeText extends EmojiEditText {
|
||||
mentionValidatorWatcher = new MentionValidatorWatcher();
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
|
||||
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
MenuItem copy = menu.findItem(android.R.id.copy);
|
||||
MenuItem cut = menu.findItem(android.R.id.cut);
|
||||
MenuItem paste = menu.findItem(android.R.id.paste);
|
||||
int copyOrder = copy != null ? copy.getOrder() : 0;
|
||||
int cutOrder = cut != null ? cut.getOrder() : 0;
|
||||
int pasteOrder = paste != null ? paste.getOrder() : 0;
|
||||
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
|
||||
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
MenuItem copy = menu.findItem(android.R.id.copy);
|
||||
MenuItem cut = menu.findItem(android.R.id.cut);
|
||||
MenuItem paste = menu.findItem(android.R.id.paste);
|
||||
int copyOrder = copy != null ? copy.getOrder() : 0;
|
||||
int cutOrder = cut != null ? cut.getOrder() : 0;
|
||||
int pasteOrder = paste != null ? paste.getOrder() : 0;
|
||||
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
|
||||
|
||||
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
|
||||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
|
||||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
|
||||
return true;
|
||||
Editable text = getText();
|
||||
|
||||
if (text != null) {
|
||||
int start = getSelectionStart();
|
||||
int end = getSelectionEnd();
|
||||
if (MessageStyler.hasStyling(text, start, end)) {
|
||||
menu.add(0, R.id.edittext_clear_formatting, largestOrder, getContext().getString(R.string.TextFormatting_clear_formatting));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
Editable text = getText();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text == null) {
|
||||
return false;
|
||||
}
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
Editable text = getText();
|
||||
|
||||
if (item.getItemId() != R.id.edittext_bold &&
|
||||
item.getItemId() != R.id.edittext_italic &&
|
||||
item.getItemId() != R.id.edittext_strikethrough &&
|
||||
item.getItemId() != R.id.edittext_monospace &&
|
||||
item.getItemId() != R.id.edittext_spoiler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int start = getSelectionStart();
|
||||
int end = getSelectionEnd();
|
||||
|
||||
CharSequence charSequence = text.subSequence(start, end);
|
||||
SpannableString replacement = new SpannableString(charSequence);
|
||||
Object style = null;
|
||||
|
||||
if (item.getItemId() == R.id.edittext_bold) {
|
||||
style = MessageStyler.boldStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_italic) {
|
||||
style = MessageStyler.italicStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_strikethrough) {
|
||||
style = MessageStyler.strikethroughStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_monospace) {
|
||||
style = MessageStyler.monoStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_spoiler) {
|
||||
style = MessageStyler.spoilerStyle(MessageStyler.COMPOSE_ID, start, charSequence.length());
|
||||
}
|
||||
|
||||
if (style != null) {
|
||||
replacement.setSpan(style, 0, charSequence.length(), MessageStyler.SPAN_FLAGS);
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
|
||||
text.replace(start, end, replacement);
|
||||
|
||||
mode.finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
if (text == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {}
|
||||
});
|
||||
}
|
||||
if (item.getItemId() != R.id.edittext_bold &&
|
||||
item.getItemId() != R.id.edittext_italic &&
|
||||
item.getItemId() != R.id.edittext_strikethrough &&
|
||||
item.getItemId() != R.id.edittext_monospace &&
|
||||
item.getItemId() != R.id.edittext_spoiler &&
|
||||
item.getItemId() != R.id.edittext_clear_formatting)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int start = getSelectionStart();
|
||||
int end = getSelectionEnd();
|
||||
BodyRangeList.BodyRange.Style style = null;
|
||||
|
||||
if (item.getItemId() == R.id.edittext_bold) {
|
||||
style = BodyRangeList.BodyRange.Style.BOLD;
|
||||
} else if (item.getItemId() == R.id.edittext_italic) {
|
||||
style = BodyRangeList.BodyRange.Style.ITALIC;
|
||||
} else if (item.getItemId() == R.id.edittext_strikethrough) {
|
||||
style = BodyRangeList.BodyRange.Style.STRIKETHROUGH;
|
||||
} else if (item.getItemId() == R.id.edittext_monospace) {
|
||||
style = BodyRangeList.BodyRange.Style.MONOSPACE;
|
||||
} else if (item.getItemId() == R.id.edittext_spoiler) {
|
||||
style = BodyRangeList.BodyRange.Style.SPOILER;
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
|
||||
if (style != null) {
|
||||
MessageStyler.toggleStyle(style, text, start, end);
|
||||
} else if (item.getItemId() == R.id.edittext_clear_formatting) {
|
||||
MessageStyler.clearStyling(text, start, end);
|
||||
}
|
||||
|
||||
mode.finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {}
|
||||
});
|
||||
}
|
||||
|
||||
private void setHintWithChecks(@Nullable CharSequence newHint) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.conversation.MessageStyler.isSupportedStyle
|
||||
/**
|
||||
* Formatting should only grow when appending until a white space character is entered/pasted.
|
||||
*
|
||||
* This watcher observes changes to the text and will grow supported style ranges as necessary
|
||||
* This watcher observes changes to the text and will shrink supported style ranges as necessary
|
||||
* to provide the desired behavior.
|
||||
*/
|
||||
class ComposeTextStyleWatcher : TextWatcher {
|
||||
@@ -45,29 +45,32 @@ class ComposeTextStyleWatcher : TextWatcher {
|
||||
s.removeSpan(markerAnnotation)
|
||||
|
||||
try {
|
||||
if (editStart <= 0 || editEnd < 0 || editStart >= editEnd) {
|
||||
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
|
||||
return
|
||||
}
|
||||
|
||||
val change = s.subSequence(editStart, editEnd)
|
||||
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
|
||||
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
|
||||
textSnapshotPriorToChange = null
|
||||
return
|
||||
}
|
||||
textSnapshotPriorToChange = null
|
||||
|
||||
var newEnd = editStart
|
||||
for (i in change.indices) {
|
||||
if (!StringUtil.isVisuallyEmpty(change[i])) {
|
||||
newEnd++
|
||||
if (StringUtil.isVisuallyEmpty(change[i])) {
|
||||
newEnd = editStart + i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
s.getSpans(editStart - 1, editStart, Object::class.java)
|
||||
s.getSpans(editStart, editEnd, Object::class.java)
|
||||
.filter { it.isSupportedStyle() }
|
||||
.forEach { style ->
|
||||
val styleStart = s.getSpanStart(style)
|
||||
val styleEnd = s.getSpanEnd(style)
|
||||
|
||||
if (styleEnd == editStart && styleStart < styleEnd) {
|
||||
if (styleEnd == editEnd && styleStart < styleEnd) {
|
||||
s.removeSpan(style)
|
||||
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
|
||||
} else if (styleStart >= styleEnd) {
|
||||
@@ -75,7 +78,6 @@ class ComposeTextStyleWatcher : TextWatcher {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
textSnapshotPriorToChange = null
|
||||
s.getSpans(editStart, editEnd, Object::class.java)
|
||||
.filter { it.isSupportedStyle() }
|
||||
.forEach { style ->
|
||||
|
||||
@@ -10,7 +10,10 @@ import android.text.style.StyleSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList.BodyRange
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
|
||||
import java.lang.Integer.max
|
||||
import java.lang.Integer.min
|
||||
|
||||
/**
|
||||
* Helper for parsing and applying styles. Most notably with [BodyRangeList].
|
||||
@@ -18,7 +21,7 @@ import org.thoughtcrime.securesms.util.PlaceholderURLSpan
|
||||
object MessageStyler {
|
||||
|
||||
const val MONOSPACE = "monospace"
|
||||
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
|
||||
const val DRAFT_ID = "DRAFT"
|
||||
const val COMPOSE_ID = "COMPOSE"
|
||||
const val QUOTE_ID = "QUOTE"
|
||||
@@ -57,7 +60,7 @@ object MessageStyler {
|
||||
|
||||
var appliedStyle = false
|
||||
var hasLinks = false
|
||||
var bottomButton: BodyRangeList.BodyRange.Button? = null
|
||||
var bottomButton: BodyRange.Button? = null
|
||||
|
||||
messageRanges
|
||||
.rangesList
|
||||
@@ -68,17 +71,18 @@ object MessageStyler {
|
||||
|
||||
if (range.hasStyle()) {
|
||||
val styleSpan: Any? = when (range.style) {
|
||||
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
|
||||
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
|
||||
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
|
||||
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
|
||||
BodyRangeList.BodyRange.Style.SPOILER -> {
|
||||
BodyRange.Style.BOLD -> boldStyle()
|
||||
BodyRange.Style.ITALIC -> italicStyle()
|
||||
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
|
||||
BodyRange.Style.MONOSPACE -> monoStyle()
|
||||
BodyRange.Style.SPOILER -> {
|
||||
val spoiler = spoilerStyle(id, range.start, range.length)
|
||||
if (hideSpoilerText) {
|
||||
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), start, end, SPAN_FLAGS)
|
||||
}
|
||||
spoiler
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -102,9 +106,111 @@ object MessageStyler {
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun hasStyling(text: Spanned): Boolean {
|
||||
fun toggleStyle(style: BodyRange.Style, text: Spannable, start: Int, end: Int) {
|
||||
if (start >= end) {
|
||||
return
|
||||
}
|
||||
|
||||
val toggleRange = start..end
|
||||
val spanAndRanges = text
|
||||
.getSpans(start, end, Object::class.java)
|
||||
.asSequence()
|
||||
.filter { it.isStyle(style) }
|
||||
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
|
||||
.toMutableList()
|
||||
|
||||
val isForceAdditive = spanAndRanges.hasGapsBetween(toggleRange)
|
||||
var shouldAddRange = true
|
||||
|
||||
val iterator = spanAndRanges.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val existingStyle = iterator.next()
|
||||
if (toggleRange == existingStyle.range) {
|
||||
text.removeSpan(existingStyle.span)
|
||||
iterator.remove()
|
||||
shouldAddRange = false
|
||||
} else if (toggleRange.containedIn(existingStyle.range)) {
|
||||
text.removeSpan(existingStyle.span)
|
||||
iterator.remove()
|
||||
text.setSpan(style.toStyleSpan(existingStyle.range.first, toggleRange.first), existingStyle.range.first, toggleRange.first, SPAN_FLAGS)
|
||||
text.setSpan(style.toStyleSpan(toggleRange.last, existingStyle.range.last), toggleRange.last, existingStyle.range.last, SPAN_FLAGS)
|
||||
shouldAddRange = false
|
||||
break
|
||||
} else if (toggleRange.covers(existingStyle.range) && isForceAdditive) {
|
||||
text.removeSpan(existingStyle.span)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAddRange) {
|
||||
val styleSpan = style.toStyleSpan(start, end - start)
|
||||
text.setSpan(styleSpan, start, end, SPAN_FLAGS)
|
||||
spanAndRanges += SpanAndRange(styleSpan, start..end)
|
||||
}
|
||||
|
||||
spanAndRanges.sortWith { (_, lhs), (_, rhs) ->
|
||||
val compareStart = lhs.first.compareTo(rhs.first)
|
||||
if (compareStart == 0) {
|
||||
lhs.last.compareTo(rhs.last)
|
||||
} else {
|
||||
compareStart
|
||||
}
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while (index < spanAndRanges.size) {
|
||||
val spanAndRange = spanAndRanges[index]
|
||||
val nextSpanAndRange = if (index < spanAndRanges.lastIndex) spanAndRanges[index + 1] else null
|
||||
if (spanAndRange.range.first == spanAndRange.range.last) {
|
||||
text.removeSpan(spanAndRange.span)
|
||||
spanAndRanges.removeAt(index)
|
||||
} else if (nextSpanAndRange != null && spanAndRange.range.overlapsStart(nextSpanAndRange.range)) {
|
||||
text.removeSpan(nextSpanAndRange.span)
|
||||
spanAndRanges.removeAt(index + 1)
|
||||
text.removeSpan(spanAndRange.span)
|
||||
spanAndRanges.removeAt(index)
|
||||
|
||||
val mergedRange = min(nextSpanAndRange.range.first, spanAndRange.range.first)..max(nextSpanAndRange.range.last, spanAndRange.range.last)
|
||||
val styleSpan = style.toStyleSpan(mergedRange.first, mergedRange.last - mergedRange.first)
|
||||
text.setSpan(styleSpan, mergedRange.first, mergedRange.last, SPAN_FLAGS)
|
||||
spanAndRanges.add(index, SpanAndRange(styleSpan, mergedRange))
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clearStyling(text: Spannable, start: Int, end: Int) {
|
||||
val clearRange = start..end
|
||||
|
||||
text
|
||||
.getSpans(start, end, Object::class.java)
|
||||
.asSequence()
|
||||
.filter { it.isSupportedStyle() }
|
||||
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
|
||||
.forEach { spanAndRange ->
|
||||
if (clearRange.covers(spanAndRange.range)) {
|
||||
text.removeSpan(spanAndRange.span)
|
||||
} else if (clearRange.containedIn(spanAndRange.range)) {
|
||||
text.removeSpan(spanAndRange.span)
|
||||
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
|
||||
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last - clearRange.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
|
||||
} else if (clearRange.overlapsStart(spanAndRange.range)) {
|
||||
text.removeSpan(spanAndRange.span)
|
||||
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
|
||||
} else if (clearRange.overlapsEnd(spanAndRange.range)) {
|
||||
text.removeSpan(spanAndRange.span)
|
||||
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun hasStyling(text: Spanned, start: Int = 0, end: Int = text.length): Boolean {
|
||||
return text
|
||||
.getSpans(0, text.length, Object::class.java)
|
||||
.getSpans(start, end, Object::class.java)
|
||||
.any { s -> s.isSupportedStyle() && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
|
||||
}
|
||||
|
||||
@@ -118,19 +224,19 @@ object MessageStyler {
|
||||
val spanStart = text.getSpanStart(span)
|
||||
val spanLength = text.getSpanEnd(span) - spanStart
|
||||
|
||||
val style: BodyRangeList.BodyRange.Style? = when (span) {
|
||||
val style: BodyRange.Style? = when (span) {
|
||||
is StyleSpan -> {
|
||||
when (span.style) {
|
||||
Typeface.BOLD -> BodyRangeList.BodyRange.Style.BOLD
|
||||
Typeface.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
|
||||
Typeface.BOLD -> BodyRange.Style.BOLD
|
||||
Typeface.ITALIC -> BodyRange.Style.ITALIC
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
|
||||
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
|
||||
is StrikethroughSpan -> BodyRange.Style.STRIKETHROUGH
|
||||
is TypefaceSpan -> BodyRange.Style.MONOSPACE
|
||||
is Annotation -> {
|
||||
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
|
||||
BodyRangeList.BodyRange.Style.SPOILER
|
||||
BodyRange.Style.SPOILER
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -139,7 +245,7 @@ object MessageStyler {
|
||||
}
|
||||
|
||||
if (spanLength > 0 && style != null) {
|
||||
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
|
||||
BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -157,22 +263,72 @@ object MessageStyler {
|
||||
|
||||
fun Any.isSupportedStyle(): Boolean {
|
||||
return when (this) {
|
||||
is CharacterStyle -> isSupportedCharacterStyle(this)
|
||||
is CharacterStyle -> isSupportedCharacterStyle()
|
||||
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
|
||||
return when (style) {
|
||||
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
|
||||
private fun Any.isSupportedCharacterStyle(): Boolean {
|
||||
return when (this) {
|
||||
is StyleSpan -> style == Typeface.ITALIC || style == Typeface.BOLD
|
||||
is StrikethroughSpan -> true
|
||||
is TypefaceSpan -> style.family == MONOSPACE
|
||||
is TypefaceSpan -> family == MONOSPACE
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRangeList.BodyRange.Button? = null) {
|
||||
private fun Any.isStyle(style: BodyRange.Style): Boolean {
|
||||
return when (this) {
|
||||
is CharacterStyle -> isCharacterStyle(style)
|
||||
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this) && style == BodyRange.Style.SPOILER
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun CharacterStyle.isCharacterStyle(style: BodyRange.Style): Boolean {
|
||||
return when (this) {
|
||||
is StyleSpan -> (this.style == Typeface.ITALIC && style == BodyRange.Style.ITALIC) || (this.style == Typeface.BOLD && style == BodyRange.Style.BOLD)
|
||||
is StrikethroughSpan -> style == BodyRange.Style.STRIKETHROUGH
|
||||
is TypefaceSpan -> this.family == MONOSPACE && style == BodyRange.Style.MONOSPACE
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyStyleSpan(span: Any, start: Int, length: Int): Any? {
|
||||
return when (span) {
|
||||
is StyleSpan -> {
|
||||
when (span.style) {
|
||||
Typeface.BOLD -> boldStyle()
|
||||
Typeface.ITALIC -> italicStyle()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
is StrikethroughSpan -> strikethroughStyle()
|
||||
is TypefaceSpan -> monoStyle()
|
||||
is Annotation -> {
|
||||
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
|
||||
spoilerStyle(COMPOSE_ID, start, length)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
|
||||
}
|
||||
}
|
||||
|
||||
private fun BodyRange.Style.toStyleSpan(start: Int, length: Int): Any {
|
||||
return when (this) {
|
||||
BodyRange.Style.BOLD -> boldStyle()
|
||||
BodyRange.Style.ITALIC -> italicStyle()
|
||||
BodyRange.Style.SPOILER -> spoilerStyle(COMPOSE_ID, start, length)
|
||||
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
|
||||
BodyRange.Style.MONOSPACE -> monoStyle()
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRange.Button? = null) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val NO_STYLE = Result()
|
||||
@@ -181,4 +337,49 @@ object MessageStyler {
|
||||
fun none(): Result = NO_STYLE
|
||||
}
|
||||
}
|
||||
|
||||
private data class SpanAndRange(val span: Any, val range: IntRange)
|
||||
|
||||
private fun IntRange.overlapsStart(other: IntRange): Boolean {
|
||||
return this.first <= other.first && this.last > other.first
|
||||
}
|
||||
|
||||
private fun IntRange.overlapsEnd(other: IntRange): Boolean {
|
||||
return this.first < other.last && this.last >= other.last
|
||||
}
|
||||
|
||||
private fun IntRange.containedIn(other: IntRange): Boolean {
|
||||
return this.first >= other.first && this.last <= other.last
|
||||
}
|
||||
|
||||
private fun IntRange.covers(other: IntRange): Boolean {
|
||||
return this.first <= other.first && this.last >= other.last
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a sorted, non-overlapping list of ranges does not cover the provided [toggleRange] completely. That is,
|
||||
* there is a value that does not exists in any of the ranges in the list but does exist in [toggleRange]
|
||||
*
|
||||
* For example, a list of ranges [[0..5], [7..10]] and a toggle range of [0..10] has a gap for the value 6. If
|
||||
* the list was [[0..5], [6..8], [9..10]] there would be no gaps.
|
||||
*/
|
||||
private fun List<SpanAndRange>.hasGapsBetween(toggleRange: IntRange): Boolean {
|
||||
val startingRangeIndex = indexOfFirst { it.range.first <= toggleRange.first && it.range.last >= toggleRange.first }
|
||||
if (startingRangeIndex == -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
val endingRangeIndex = indexOfFirst { it.range.first <= toggleRange.last && it.range.last >= toggleRange.last }
|
||||
if (endingRangeIndex == -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (i in startingRangeIndex until endingRangeIndex) {
|
||||
if (this[i].range.last != this[i + 1].range.first) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ public final class FeatureFlags {
|
||||
private static final String CHAT_FILTERS = "android.chat.filters.3";
|
||||
private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2";
|
||||
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
|
||||
private static final String TEXT_FORMATTING = "android.textFormatting.3";
|
||||
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
|
||||
private static final String AD_HOC_CALLING = "android.calling.ad.hoc.2";
|
||||
private static final String EDIT_MESSAGE_SEND = "android.editMessage.send.2";
|
||||
@@ -163,7 +162,6 @@ public final class FeatureFlags {
|
||||
CHAT_FILTERS,
|
||||
PAYPAL_ONE_TIME_DONATIONS,
|
||||
PAYPAL_RECURRING_DONATIONS,
|
||||
TEXT_FORMATTING,
|
||||
ANY_ADDRESS_PORTS_KILL_SWITCH,
|
||||
EDIT_MESSAGE_SEND,
|
||||
MAX_ATTACHMENT_COUNT,
|
||||
@@ -231,7 +229,6 @@ public final class FeatureFlags {
|
||||
CREDIT_CARD_PAYMENTS,
|
||||
PAYMENTS_REQUEST_ACTIVATE_FLOW,
|
||||
CDS_HARD_LIMIT,
|
||||
TEXT_FORMATTING,
|
||||
EDIT_MESSAGE_SEND,
|
||||
MAX_ATTACHMENT_COUNT,
|
||||
MAX_ATTACHMENT_SIZE_MB
|
||||
@@ -574,13 +571,6 @@ public final class FeatureFlags {
|
||||
return getBoolean(PAYPAL_RECURRING_DONATIONS, Environment.IS_STAGING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not we should show text formatting options.
|
||||
*/
|
||||
public static boolean textFormatting() {
|
||||
return getBoolean(TEXT_FORMATTING, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable RingRTC field trial for "AnyAddressPortsKillSwitch"
|
||||
*/
|
||||
|
||||
@@ -31,4 +31,5 @@
|
||||
<item name="edittext_strikethrough" type="id" />
|
||||
<item name="edittext_monospace" type="id" />
|
||||
<item name="edittext_spoiler" type="id" />
|
||||
<item name="edittext_clear_formatting" type="id" />
|
||||
</resources>
|
||||
|
||||
@@ -5920,6 +5920,8 @@
|
||||
<string name="TextFormatting_monospace">Monospace</string>
|
||||
<!-- Popup menu label for applying spoiler style -->
|
||||
<string name="TextFormatting_spoiler">Spoiler</string>
|
||||
<!-- Popup menu label for clearing applied formatting -->
|
||||
<string name="TextFormatting_clear_formatting">Clear formatting</string>
|
||||
|
||||
<!-- UsernameEducationFragment -->
|
||||
<!-- Continue button which takes the user to the add a username screen -->
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.app.Application
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.thoughtcrime.securesms.assertIs
|
||||
import org.thoughtcrime.securesms.assertIsNull
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList.BodyRange.Style
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class MessageStylerTest {
|
||||
|
||||
private lateinit var text: Spannable
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
text = SpannableString("This is a really long string for testing. Also thank you to all our beta testers!")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonOverlappingDifferentStyles() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
|
||||
MessageStyler.toggleStyle(Style.ITALIC, text, 10, 15)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 2
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 5
|
||||
}
|
||||
|
||||
bodyRange.rangesList[1].apply {
|
||||
style assertIs Style.ITALIC
|
||||
start assertIs 10
|
||||
length assertIs 5
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingDifferentStyles() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
|
||||
MessageStyler.toggleStyle(Style.ITALIC, text, 3, 10)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 2
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 5
|
||||
}
|
||||
|
||||
bodyRange.rangesList[1].apply {
|
||||
style assertIs Style.ITALIC
|
||||
start assertIs 3
|
||||
length assertIs 7
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingBeginning() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 3, 10)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 1
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 10
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingEnd() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 3, 10)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 1
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 10
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingContained() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 2
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 4
|
||||
}
|
||||
|
||||
bodyRange.rangesList[1].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 6
|
||||
length assertIs 4
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingCovering() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 1
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 10
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingExact() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)
|
||||
|
||||
bodyRange.assertIsNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingCoveringMultiple() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 3)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 6, 8)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 1
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 10
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingEndAndBeginning() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 3)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 6, 8)
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 2, 7)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 1
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 8
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearFormatting() {
|
||||
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
|
||||
MessageStyler.clearStyling(text, 3, 7)
|
||||
|
||||
val bodyRange = MessageStyler.getStyling(text)!!
|
||||
|
||||
bodyRange.rangesCount assertIs 2
|
||||
|
||||
bodyRange.rangesList[0].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 0
|
||||
length assertIs 3
|
||||
}
|
||||
|
||||
bodyRange.rangesList[1].apply {
|
||||
style assertIs Style.BOLD
|
||||
start assertIs 7
|
||||
length assertIs 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.hamcrest.MatcherAssert
|
||||
import org.hamcrest.Matchers
|
||||
|
||||
fun <T : Any?> T.assertIsNull() {
|
||||
MatcherAssert.assertThat(this, Matchers.nullValue())
|
||||
}
|
||||
|
||||
fun <T : Any?> T.assertIsNotNull() {
|
||||
MatcherAssert.assertThat(this, Matchers.notNullValue())
|
||||
}
|
||||
|
||||
infix fun <T : Any?> T.assertIs(expected: T) {
|
||||
MatcherAssert.assertThat(this, Matchers.`is`(expected))
|
||||
}
|
||||
|
||||
infix fun <T : Any> T.assertIsNot(expected: T) {
|
||||
MatcherAssert.assertThat(this, Matchers.not(Matchers.`is`(expected)))
|
||||
}
|
||||
|
||||
infix fun <E, T : Collection<E>> T.assertIsSize(expected: Int) {
|
||||
MatcherAssert.assertThat(this, Matchers.hasSize(expected))
|
||||
}
|
||||
Reference in New Issue
Block a user