Add additional text formatting support.

This commit is contained in:
Cody Henthorne
2023-03-20 12:24:52 -04:00
committed by Greyson Parrelli
parent 1c3636eedd
commit 25028e0e6f
11 changed files with 155 additions and 44 deletions

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
@@ -17,9 +16,6 @@ import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.CharacterStyle;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.Menu;
@@ -63,7 +59,6 @@ import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
public class ComposeText extends EmojiEditText {
private static final char EMOJI_STARTER = ':';
private static final long EMOJI_KEYWORD_DELAY = 1500;
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
@@ -76,13 +71,6 @@ public class ComposeText extends EmojiEditText {
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
private final Runnable keywordSearchRunnable = () -> {
Editable text = getText();
if (text != null && enoughToFilter(text, true)) {
performFiltering(text, true);
}
};
public ComposeText(Context context) {
super(context);
initialize();
@@ -362,7 +350,7 @@ public class ComposeText extends EmojiEditText {
}
if (style != null) {
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
clearComposingText();
@@ -532,6 +520,11 @@ public class ComposeText extends EmojiEditText {
return -1;
}
@Override
protected boolean shouldPersistSignalStylingWhenPasting() {
return true;
}
/**
* Return true if we think the user may be inputting a time.
*/

View File

@@ -1,9 +1,13 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.InputFilter;
import android.text.TextUtils;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
@@ -14,7 +18,9 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashSet;
import java.util.Set;
@@ -35,9 +41,9 @@ public class EmojiEditText extends AppCompatEditText {
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
@@ -57,8 +63,8 @@ public class EmojiEditText extends AppCompatEditText {
}
public void insertEmoji(String emoji) {
final int start = getSelectionStart();
final int end = getSelectionEnd();
final int start = getSelectionStart();
final int end = getSelectionEnd();
getText().replace(Math.min(start, end), Math.max(start, end), emoji);
setSelection(start + emoji.length());
@@ -66,8 +72,11 @@ public class EmojiEditText extends AppCompatEditText {
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
if (drawable instanceof EmojiDrawable) {
invalidate();
} else {
super.invalidateDrawable(drawable);
}
}
@Override
@@ -95,4 +104,50 @@ public class EmojiEditText extends AppCompatEditText {
return result;
}
@Override
public boolean onTextContextMenuItem(int id) {
if (id == android.R.id.paste) {
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
if (clipData != null) {
CharSequence label = clipData.getDescription().getLabel();
CharSequence pendingPaste = getTextFromClipData(clipData);
if (TextUtils.equals(Util.COPY_LABEL, label) && shouldPersistSignalStylingWhenPasting()) {
return super.onTextContextMenuItem(id);
} else if (Build.VERSION.SDK_INT >= 23) {
return super.onTextContextMenuItem(android.R.id.pasteAsPlainText);
} else if (pendingPaste != null) {
Util.copyToClipboard(getContext(), pendingPaste.toString());
return super.onTextContextMenuItem(id);
}
}
} else if (id == android.R.id.copy || id == android.R.id.cut) {
boolean originalResult = super.onTextContextMenuItem(id);
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(getContext());
CharSequence clipText = getTextFromClipData(clipboardManager.getPrimaryClip());
if (clipText != null) {
Util.copyToClipboard(getContext(), clipText);
return true;
}
return originalResult;
}
return super.onTextContextMenuItem(id);
}
private @Nullable CharSequence getTextFromClipData(@Nullable ClipData data) {
if (data != null && data.getItemCount() > 0) {
return data.getItemAt(0).coerceToText(getContext());
} else {
return null;
}
}
protected boolean shouldPersistSignalStylingWhenPasting() {
return false;
}
}

View File

@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversation;
import android.Manifest;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
@@ -284,6 +283,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
@@ -3015,6 +3015,11 @@ public class ConversationParentFragment extends Fragment
return;
}
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && result.getBodyRanges() != null && result.getBodyRanges().getRangesCount() > 0) {
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(result));
return;
}
long thread = this.threadId;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
@@ -3127,6 +3132,12 @@ public class ConversationParentFragment extends Fragment
return new SettableFuture<>(null);
}
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
final String finalBody = body;
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
return new SettableFuture<>(null);
}
final boolean sendPush = sendType.usesSignalTransport();
final long thread = this.threadId;

View File

@@ -8,7 +8,6 @@ import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
/**
@@ -62,7 +61,7 @@ object MessageStyler {
}
if (styleSpan != null) {
span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
appliedStyle = true
}
} else if (range.hasLink() && range.link != null) {
@@ -82,17 +81,14 @@ object MessageStyler {
@JvmStatic
fun hasStyling(text: Spanned): Boolean {
return if (FeatureFlags.textFormatting()) {
text.getSpans(0, text.length, CharacterStyle::class.java)
.any { s -> isSupportedCharacterStyle(s) && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
} else {
false
}
return text
.getSpans(0, text.length, CharacterStyle::class.java)
.any { s -> isSupportedCharacterStyle(s) && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
}
@JvmStatic
fun getStyling(text: CharSequence?): BodyRangeList? {
val bodyRanges = if (text is Spanned && FeatureFlags.textFormatting()) {
val bodyRanges = if (text is Spanned) {
text
.getSpans(0, text.length, CharacterStyle::class.java)
.filter { s -> isSupportedCharacterStyle(s) }
@@ -125,7 +121,7 @@ object MessageStyler {
}
}
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
return when (style) {
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
is StrikethroughSpan -> true

View File

@@ -4,7 +4,6 @@ package org.thoughtcrime.securesms.database.model
import com.google.protobuf.ByteString
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange
/**
@@ -49,7 +48,7 @@ fun BodyRangeList.Builder.addButton(label: String, action: String, start: Int, l
}
fun List<BodyRange>?.toBodyRangeList(): BodyRangeList? {
if (this == null || !FeatureFlags.textFormatting()) {
if (this == null) {
return null
}

View File

@@ -15,6 +15,8 @@ public class UiHints extends SignalStoreValues {
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
private static final String HAS_SEEN_USERNAME_EDUCATION = "uihints.has_seen_username_education";
private static final String HAS_SEEN_TEXT_FORMATTING_ALERT = "uihints.text_formatting.has_seen_alert";
UiHints(@NonNull KeyValueStore store) {
super(store);
}
@@ -26,7 +28,7 @@ public class UiHints extends SignalStoreValues {
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Arrays.asList(NEVER_DISPLAY_PULL_TO_FILTER_TIP, HAS_SEEN_USERNAME_EDUCATION);
return Arrays.asList(NEVER_DISPLAY_PULL_TO_FILTER_TIP, HAS_SEEN_USERNAME_EDUCATION, HAS_SEEN_TEXT_FORMATTING_ALERT);
}
public void markHasSeenGroupSettingsMenuToast() {
@@ -90,4 +92,12 @@ public class UiHints extends SignalStoreValues {
private int getNeverDisplayPullToFilterTip() {
return getInteger(NEVER_DISPLAY_PULL_TO_FILTER_TIP, 0);
}
public boolean hasNotSeenTextFormattingAlert() {
return getBoolean(HAS_SEEN_TEXT_FORMATTING_ALERT, true);
}
public void markHasSeenTextFormattingAlert() {
putBoolean(HAS_SEEN_TEXT_FORMATTING_ALERT, false);
}
}

View File

@@ -18,10 +18,12 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
@@ -71,13 +73,22 @@ class StoryDirectReplyDialogFragment :
composer = view.findViewById(R.id.input)
composer.callback = object : StoryReplyComposer.Callback {
override fun onSendActionClicked() {
val (body, _, bodyRanges) = composer.consumeInput()
lifecycleDisposable += viewModel.sendReply(body, bodyRanges)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__sending_reply, Toast.LENGTH_LONG).show()
dismissAllowingStateLoss()
}
val sendReply = Runnable {
val (body, _, bodyRanges) = composer.consumeInput()
lifecycleDisposable += viewModel.sendReply(body, bodyRanges)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__sending_reply, Toast.LENGTH_LONG).show()
dismissAllowingStateLoss()
}
}
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && composer.input.hasStyling()) {
Dialogs.showFormattedTextDialog(requireContext(), sendReply)
} else {
sendReply.run()
}
}
override fun onReactionClicked(emoji: String) {

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardCallback
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
@@ -56,6 +57,7 @@ import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPager
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer
import org.thoughtcrime.securesms.util.DeleteDialog
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
@@ -350,8 +352,16 @@ class StoryGroupReplyFragment :
}
override fun onSendActionClicked() {
val (body, mentions, bodyRanges) = composer.consumeInput()
performSend(body, mentions, bodyRanges)
val send = Runnable {
val (body, mentions, bodyRanges) = composer.consumeInput()
performSend(body, mentions, bodyRanges)
}
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && composer.input.hasStyling()) {
Dialogs.showFormattedTextDialog(requireContext(), send)
} else {
send.run()
}
}
override fun onPickAnyReactionClicked() {

View File

@@ -18,9 +18,12 @@ package org.thoughtcrime.securesms.util;
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
public class Dialogs {
public static void showAlertDialog(Context context, String title, String message) {
@@ -39,4 +42,16 @@ public class Dialogs {
.setPositiveButton(android.R.string.ok, null)
.show();
}
public static void showFormattedTextDialog(@NonNull Context context, @NonNull Runnable onSendAnyway) {
new MaterialAlertDialogBuilder(context)
.setTitle(R.string.SendingFormattingTextDialog_title)
.setMessage(R.string.SendingFormattingTextDialog_message)
.setNegativeButton(R.string.SendingFormattingTextDialog_cancel_send_button, null)
.setPositiveButton(R.string.SendingFormattingTextDialog_send_anyway_button, (d, w) -> {
SignalStore.uiHints().markHasSeenTextFormattingAlert();
onSendAnyway.run();
})
.show();
}
}

View File

@@ -71,6 +71,8 @@ public class Util {
private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90);
public static final String COPY_LABEL = "text\u00AD";
public static <T> List<T> asList(T... elements) {
List<T> result = new LinkedList<>();
Collections.addAll(result, elements);
@@ -482,7 +484,7 @@ public class Util {
}
public static void copyToClipboard(@NonNull Context context, @NonNull CharSequence text) {
ServiceUtil.getClipboardManager(context).setPrimaryClip(ClipData.newPlainText("text", text));
ServiceUtil.getClipboardManager(context).setPrimaryClip(ClipData.newPlainText(COPY_LABEL, text));
}
@SafeVarargs