Add swipe-to-reply quotes

This commit is contained in:
Hocuri 2020-10-11 20:40:48 +02:00 committed by link2xt
parent 924695d6cf
commit c720df5d5c
43 changed files with 1667 additions and 176 deletions

View file

@ -1410,6 +1410,27 @@ JNIEXPORT void Java_com_b44t_messenger_DcMsg_setLocation(JNIEnv *env, jobject ob
}
JNIEXPORT void Java_com_b44t_messenger_DcMsg_setQuoteCPtr(JNIEnv *env, jobject obj, jlong quoteCPtr)
{
dc_msg_set_quote(get_dc_msg(env, obj), (dc_msg_t*)quoteCPtr);
}
JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getQuotedText(JNIEnv *env, jobject obj)
{
char* temp = dc_msg_get_quoted_text(get_dc_msg(env, obj));
jstring ret = JSTRING_NEW(temp);
dc_str_unref(temp);
return ret;
}
JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getQuotedMsgCPtr(JNIEnv *env, jobject obj)
{
return (jlong)dc_msg_get_quoted_msg(get_dc_msg(env, obj));
}
/*******************************************************************************
* DcContact

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval" >
<solid android:color="#22000000" />
</shape>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval" >
<solid android:color="#99ffffff" />
</shape>

View file

@ -7,7 +7,6 @@
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:clickable="true"
android:paddingTop="6dp"
android:paddingBottom="6dp"
@ -16,25 +15,17 @@
android:background="?attr/input_panel_bg_color">
<FrameLayout
android:id="@+id/input_field_frame_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_toggle"
app:layout_constraintTop_toBottomOf="@id/quote_view"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false">
<LinearLayout
android:id="@+id/recording_container"
android:layout_width="match_parent"
@ -183,11 +174,10 @@
</FrameLayout>
</LinearLayout>
</FrameLayout>
<org.thoughtcrime.securesms.components.AnimatingToggle
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/input_field_frame_layout"
app:layout_constraintBottom_toBottomOf="parent"
android:id="@+id/button_toggle"
android:layout_width="40dp"
android:layout_height="40dp"
@ -221,4 +211,15 @@
android:background="@drawable/circle_touch_highlight_background" />
</org.thoughtcrime.securesms.components.AnimatingToggle>
<org.thoughtcrime.securesms.components.QuoteView
android:id="@+id/quote_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginBottom="6dp"
android:layout_marginEnd="6dp"
android:visibility="gone"
app:message_type="preview"
tools:visibility="visible" />
</org.thoughtcrime.securesms.components.InputPanel>

View file

@ -25,6 +25,18 @@
android:clipToPadding="false"
android:clipChildren="false">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/reply_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_alignStart="@id/body_bubble"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:alpha="0"
app:srcCompat="?menu_reply_icon"
android:tint="?icon_tint"
android:layout_alignLeft="@id/body_bubble" />
<FrameLayout
android:id="@+id/contact_photo_container"
android:layout_width="36dp"
@ -87,6 +99,19 @@
</LinearLayout>
<org.thoughtcrime.securesms.components.QuoteView
android:id="@+id/quote_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:visibility="gone"
app:message_type="incoming"
app:quote_colorPrimary="?attr/conversation_item_quote_text_color"
app:quote_colorSecondary="?attr/conversation_item_quote_text_color"
tools:visibility="visible"/>
<ViewStub
android:id="@+id/image_view_stub"
android:layout_width="wrap_content"

View file

@ -23,6 +23,18 @@
android:clipToPadding="false"
android:clipChildren="false">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/reply_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_alignStart="@id/body_bubble"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:alpha="0"
app:srcCompat="?menu_reply_icon"
android:tint="?icon_tint"
android:layout_alignLeft="@id/body_bubble" />
<LinearLayout
android:id="@+id/body_bubble"
android:layout_width="wrap_content"
@ -65,6 +77,19 @@
</LinearLayout>
<org.thoughtcrime.securesms.components.QuoteView
android:id="@+id/quote_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:visibility="gone"
app:message_type="incoming"
app:quote_colorPrimary="?attr/conversation_item_quote_text_color"
app:quote_colorSecondary="?attr/conversation_item_quote_text_color"
tools:visibility="visible"/>
<ViewStub
android:id="@+id/image_view_stub"
android:layout_width="wrap_content"

150
res/layout/quote_view.xml Normal file
View file

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/quote_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/quote_main"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/quote_bar"
android:layout_width="@dimen/quote_corner_radius_bottom"
android:layout_height="match_parent"
android:background="@color/purple_400"
tools:tint="@color/purple_400" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:orientation="vertical"
android:layout_weight="1">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/quote_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.Caption"
android:textColor="@color/core_black"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
tools:visibility="gone"
tools:text="Peter Parker" />
<TextView
android:id="@+id/media_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
style="@style/Signal.Text.Caption"
android:textColor="@color/gray95"
android:textStyle="italic"
android:visibility="gone"
tools:text="Photo"
tools:visibility="visible"
android:layout_marginRight="8dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/quote_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.Quote"
android:ellipsize="end"
android:maxLines="3"
tools:text="With great power comes great responsibility."
tools:visibility="visible" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/quote_attachment_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.CircleColorImageView
android:id="@+id/quote_attachment_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="5dp"
android:layout_marginRight="5dp"
android:contentDescription="@string/file"
android:padding="4dp"
android:scaleType="center"
android:src="@drawable/ic_insert_drive_file_white_24dp"
app:circleColor="@color/document_icon" />
</LinearLayout>
<ImageView
android:id="@+id/quote_thumbnail"
android:layout_width="@dimen/quote_thumb_size"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:visibility="gone"
tools:visibility="gone" />
<FrameLayout
android:id="@+id/quote_video_overlay"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/circle_universal_overlay"
android:layout_gravity="center"
android:longClickable="false"
android:visibility="gone"
tools:visibility="gone">
<ImageView
android:layout_width="13dp"
android:layout_height="16dp"
android:layout_marginStart="11dp"
android:layout_marginTop="8dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/triangle_right"
android:layout_marginLeft="11dp" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/quote_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:layout_marginTop="6dp"
android:layout_gravity="top|end"
android:background="@drawable/dismiss_background"
android:src="@drawable/ic_close_white_18dp"
android:tint="?quote_dismiss_button_tint"
android:layout_marginRight="6dp" />
</merge>

View file

@ -7,7 +7,6 @@
<item android:title="@string/menu_reply"
android:id="@+id/menu_context_reply"
android:visible="false"
android:icon="?menu_reply_icon"
app:showAsAction="always" />
@ -37,4 +36,7 @@
android:icon="?menu_forward_icon"
app:showAsAction="always" />
<item android:title="@string/reply_privately"
android:id="@+id/menu_context_reply_privately"
app:showAsAction="collapseActionView" />
</menu>

View file

@ -119,6 +119,9 @@
<attr name="pref_icon_tint" format="color"/>
<attr name="quote_missing_icon_color" format="color" />
<attr name="quote_dismiss_button_tint" format="color" />
<declare-styleable name="CustomDefaultPreference">
<attr name="custom_pref_toggle" format="string"/>
</declare-styleable>
@ -236,4 +239,18 @@
<attr name="timeFrame" format="integer"/>
</declare-styleable>
<declare-styleable name="QuoteView">
<attr name="message_type" format="enum">
<enum name="preview" value="0" />
<enum name="outgoing" value="1" />
<enum name="incoming" value="2" />
</attr>
<attr name="quote_colorPrimary" format="color" />
<attr name="quote_colorSecondary" format="color" />
</declare-styleable>
<declare-styleable name="AdaptiveActionsToolbar">
<attr name="aat_max_shown" format="integer" />
</declare-styleable>
</resources>

View file

@ -18,7 +18,7 @@
<dimen name="message_bubble_shadow_distance">1.5dp</dimen>
<dimen name="message_bubble_horizontal_padding">8dp</dimen>
<dimen name="message_bubble_horizontal_padding_half">4dp</dimen>
<dimen name="message_bubble_top_padding">8dp</dimen>
<dimen name="message_bubble_top_padding">6dp</dimen>
<dimen name="message_bubble_collapsed_footer_padding">6dp</dimen>
<dimen name="message_bubble_edge_margin">32dp</dimen>
<dimen name="message_bubble_bottom_padding">6dp</dimen>
@ -41,6 +41,10 @@
<dimen name="conversation_group_left_gutter">52dp</dimen>
<dimen name="conversation_vertical_message_spacing_collapse">1dp</dimen>
<dimen name="quote_corner_radius_large">10dp</dimen>
<dimen name="quote_corner_radius_bottom">2.5dp</dimen>
<dimen name="quote_corner_radius_preview">18dp</dimen>
<dimen name="quick_camera_shutter_ring_size">52dp</dimen>
<dimen name="contact_selection_actions_tap_area">10dp</dimen>
@ -50,5 +54,6 @@
<dimen name="slider_thumbOutlineSize">4dp</dimen>
<dimen name="slider_displayTextFontSize">12sp</dimen>
<dimen name="slider_displayTextBasicOffsetY">8dp</dimen>
<dimen name="quote_thumb_size">45dp</dimen>
</resources>

View file

@ -709,5 +709,7 @@
<string name="pref_reliable_service_explain">Requires a permanent notification</string>
<string name="perm_enable_bg_reminder_title">Tap here to receive messages while Delta Chat is in the background.</string>
<string name="perm_enable_bg_already_done">You already allowed Delta Chat to receive messages in the background.\n\nIf messages still do not arrive in background, please also check your system settings.</string>
<string name="ConversationFragment_quoted_message_not_found">Original message not found</string>
<string name="reply_privately">Reply privately</string>
</resources>

View file

@ -11,6 +11,11 @@
<item name="android:fontFamily">sans-serif</item>
</style>
<style name="Signal.Text.Quote" parent="Base.TextAppearance.AppCompat.Small">
<item name="android:textSize">14sp</item>
<item name="android:fontFamily">sans-serif</item>
</style>
<style name="Signal.Text.Caption" parent="Base.TextAppearance.AppCompat.Caption">
<item name="android:textSize">12sp</item>
<item name="android:fontFamily">sans-serif</item>

View file

@ -178,6 +178,8 @@
<item name="icon_tint">@color/grey_700</item>
<item name="icon_tint_dark">@color/grey_100</item>
<item name="quote_dismiss_button_tint">@color/gray70</item>
<item name="group_members_dialog_icon">@drawable/ic_group_grey600_24dp</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.Fix</item>
@ -323,6 +325,7 @@
<item name="icon_tint">@color/grey_100</item>
<item name="icon_tint_dark">?icon_tint</item>
<item name="quote_dismiss_button_tint">@color/core_white</item>
<item name="group_members_dialog_icon">@drawable/ic_group_white_24dp</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.Fix</item>

View file

@ -1,5 +1,6 @@
package com.b44t.messenger;
import android.graphics.Color;
import android.util.Log;
public class DcContact {
@ -60,4 +61,9 @@ public class DcContact {
// working with raw c-data
private long contactCPtr; // CAVE: the name is referenced in the JNI
private native void unrefContactCPtr();
public int getArgbColor() {
int rgb = getColor();
return Color.argb(0xFF, Color.red(rgb), Color.green(rgb), Color.blue(rgb));
}
}

View file

@ -105,6 +105,13 @@ public class DcMsg {
public native void setDimension (int width, int height);
public native void setDuration (int duration);
public native void setLocation (float latitude, float longitude);
public void setQuote (DcMsg quote) { setQuoteCPtr(quote.msgCPtr); }
public native String getQuotedText ();
public DcMsg getQuotedMsg () {
long cPtr = getQuotedMsgCPtr();
return cPtr != 0 ? new DcMsg(cPtr) : null;
}
public File getFileAsFile() {
if(getFile()==null)
@ -189,4 +196,6 @@ public class DcMsg {
private long msgCPtr; // CAVE: the name is referenced in the JNI
private native void unrefMsgCPtr ();
private native long getSummaryCPtr (long chatCPtr);
private native void setQuoteCPtr (long quoteCPtr);
private native long getQuotedMsgCPtr ();
};

View file

@ -27,5 +27,6 @@ public interface BindableConversationItem extends Unbindable {
void setEventListener(@Nullable EventListener listener);
interface EventListener {
void onQuoteClicked(DcMsg messageRecord);
}
}

View file

@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
@ -110,6 +111,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.guava.Optional;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.recode.VideoRecoder;
import org.thoughtcrime.securesms.videochat.VideochatUtil;
@ -154,6 +156,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public static final String CHAT_ID_EXTRA = "chat_id";
public static final String TEXT_EXTRA = "draft_text";
public static final String STARTING_POSITION_EXTRA = "starting_position";
public static final String SCROLL_TO_MSG_ID_EXTRA = "scroll_to_msg_id_extra";
private static final int PICK_GALLERY = 1;
private static final int PICK_DOCUMENT = 2;
@ -765,6 +768,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
composeText.setSelection(composeText.getText().length());
}
DcMsg quote = draft.getQuotedMsg();
if (quote != null) {
handleReplyMessage(quote);
}
String filename = draft.getFile();
if (filename.isEmpty() || !new File(filename).exists()) {
future.set(!text.isEmpty());
@ -1047,12 +1055,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
final SettableFuture<Integer> future = new SettableFuture<>();
DcMsg msg = null;
Optional<QuoteModel> quote = inputPanel.getQuote();
Integer recompress = 0;
// for a quick ui feedback, we clear the related controls immediately on sending messages.
// for drafts, however, we do not change the controls, the activity may be resumed.
if (action==ACTION_SEND_OUT) {
composeText.setText("");
inputPanel.clearQuote();
}
if(slideDeck!=null) {
@ -1095,6 +1105,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
msg.setText(body);
}
if (quote.isPresent()) {
if (msg == null) msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT);
msg.setQuote(quote.get().getQuotedMsg());
}
// msg may still be null to clear drafts
new AsyncTask<Object, Void, Void>() {
@Override
@ -1440,7 +1455,25 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
@Override
public void handleReplyMessage(DcMsg messageRecord) {
public void handleReplyMessage(DcMsg msg) {
// If you modify these lines you may also want to modify ConversationItem.setQuote():
Recipient author = dcContext.getRecipient(dcContext.getContact(msg.getFromId()));
SlideDeck slideDeck = new SlideDeck();
if (msg.getType() != DcMsg.DC_MSG_TEXT) {
slideDeck.addSlide(MediaUtil.getSlideForMsg(this, msg));
}
String text = msg.getSummarytext(500);
inputPanel.setQuote(GlideApp.with(this),
msg,
msg.getTimestamp(),
author,
text,
slideDeck);
inputPanel.clickOnComposeInput();
}
@Override

View file

@ -21,6 +21,8 @@ import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -49,6 +51,8 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import static org.thoughtcrime.securesms.ConversationItem.PULSE_HIGHLIGHT_MILLIS;
/**
* A DC adapter for a conversation thread. Ultimately
* used by ConversationActivity to display a conversation
@ -92,6 +96,8 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
private @NonNull DcChat dcChat;
private @NonNull int[] dcMsgList = new int[0];
private int positionToPulseHighlight = -1;
private int positionCurrentlyPulseHighlighting = -1;
private long pulseHighlightingSince = -1;
private int lastSeenPosition = -1;
private long lastSeen = -1;
@ -225,12 +231,24 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
ConversationAdapter.ViewHolder holder = (ConversationAdapter.ViewHolder)viewHolder;
boolean pulseHighlight = position == positionToPulseHighlight;
long now = System.currentTimeMillis();
if (position == positionToPulseHighlight) {
positionToPulseHighlight = -1;
positionCurrentlyPulseHighlighting = position;
pulseHighlightingSince = now;
}
long elapsed = now - pulseHighlightingSince;
boolean pulseHighlight = (positionCurrentlyPulseHighlighting == position && elapsed < PULSE_HIGHLIGHT_MILLIS);
holder.getItem().bind(getMsg(position), dcChat, glideRequests, locale, batchSelected, recipient, pulseHighlight);
}
if (pulseHighlight) {
positionToPulseHighlight = -1;
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder.itemView instanceof ConversationItem) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
}
}

View file

@ -74,6 +74,7 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar;
import org.thoughtcrime.securesms.videochat.VideochatUtil;
import java.util.Collections;
@ -106,6 +107,7 @@ public class ConversationFragment extends Fragment
private Recipient recipient;
private long chatId;
private int startingPosition;
private int startingMsgId;
private boolean firstLoad;
private ActionMode actionMode;
private Locale locale;
@ -160,6 +162,12 @@ public class ConversationFragment extends Fragment
list.setLayoutManager(layoutManager);
list.setItemAnimator(null);
new ConversationItemSwipeCallback(
msg -> actionMode == null &&
dcContext.getChat(msg.getChatId()).canSend(),
this::handleReplyMessage
).attachToRecyclerView(list);
// setLayerType() is needed to allow larger items (long texts in our case)
// with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be rendered into a texture"
list.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
@ -282,6 +290,7 @@ public class ConversationFragment extends Fragment
this.chatId = this.getActivity().getIntent().getIntExtra(ConversationActivity.CHAT_ID_EXTRA, -1);
this.recipient = Recipient.from(getActivity(), Address.fromChat((int)this.chatId));
this.startingPosition = this.getActivity().getIntent().getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1);
this.startingMsgId = this.getActivity().getIntent().getIntExtra(ConversationActivity.SCROLL_TO_MSG_ID_EXTRA, -1);
this.firstLoad = true;
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
@ -311,10 +320,16 @@ public class ConversationFragment extends Fragment
if (messageRecords.size() > 1) {
menu.findItem(R.id.menu_context_details).setVisible(false);
menu.findItem(R.id.menu_context_save_attachment).setVisible(false);
menu.findItem(R.id.menu_context_reply).setVisible(false);
menu.findItem(R.id.menu_context_reply_privately).setVisible(false);
} else {
DcMsg messageRecord = messageRecords.iterator().next();
DcChat chat = getListAdapter().getChat();
menu.findItem(R.id.menu_context_details).setVisible(true);
menu.findItem(R.id.menu_context_save_attachment).setVisible(messageRecord.hasFile());
menu.findItem(R.id.menu_context_reply).setVisible(chat.canSend());
boolean showReplyPrivately = chat.isGroup() && !messageRecord.isOutgoing();
menu.findItem(R.id.menu_context_reply_privately).setVisible(showReplyPrivately);
}
}
@ -455,10 +470,33 @@ public class ConversationFragment extends Fragment
private void handleResendMessage(final DcMsg message) {
}
@SuppressLint("RestrictedApi")
private void handleReplyMessage(final DcMsg message) {
if (getActivity() != null) {
//noinspection ConstantConditions
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
}
listener.handleReplyMessage(message);
}
private void handleReplyMessagePrivately(final DcMsg msg) {
if (getActivity() != null) {
int privateChatId = dcContext.createChatByContactId(msg.getFromId());
DcMsg replyMsg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT);
replyMsg.setQuote(msg);
dcContext.setDraft(privateChatId, replyMsg);
Intent intent = new Intent(getActivity(), ConversationActivity.class);
intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, privateChatId);
getActivity().startActivity(intent);
getActivity().finish();
} else {
Log.e(TAG, "Activity was null");
}
}
private void handleSaveAttachment(final DcMsg message) {
SaveAttachmentTask.showWarningDialog(getContext(), (dialogInterface, i) -> {
Permissions.with(getActivity())
@ -492,13 +530,19 @@ public class ConversationFragment extends Fragment
pixelOffset = (firstView == null) ? 0 : list.getBottom() - firstView.getBottom() - list.getPaddingBottom();
}
if (getContext() == null) {
Log.e(TAG, "reloadList: getContext() was null");
return;
}
int[] msgs = DcHelper.getContext(getContext()).getChatMsgs((int) chatId, 0, 0);
adapter.changeData(msgs);
int lastSeenPosition = adapter.getLastSeenPosition();
if (firstLoad) {
if (startingPosition >= 0) {
scrollToStartingPosition(startingPosition);
scrollAndHighlight(startingPosition, false);
} else if (startingMsgId >= 0) {
scrollToMsgId(startingMsgId);
} else {
scrollToLastSeenPosition(lastSeenPosition);
}
@ -529,10 +573,14 @@ public class ConversationFragment extends Fragment
floatingLocationButton.setVisibility(dcContext.isSendingLocationsToChat((int) chatId)? View.VISIBLE : View.GONE);
}
private void scrollToStartingPosition(final int startingPosition) {
private void scrollAndHighlight(final int pos, boolean smooth) {
list.post(() -> {
list.getLayoutManager().scrollToPosition(startingPosition);
getListAdapter().pulseHighlightItem(startingPosition);
if (smooth) {
list.smoothScrollToPosition(pos);
} else {
list.scrollToPosition(pos);
}
getListAdapter().pulseHighlightItem(pos);
});
}
@ -546,7 +594,9 @@ public class ConversationFragment extends Fragment
ConversationAdapter adapter = (ConversationAdapter)list.getAdapter();
int position = adapter.msgIdToPosition(msgId);
if (position!=-1) {
scrollToStartingPosition(position);
scrollAndHighlight(position, false);
} else {
Log.e(TAG, "msgId {} not found for scrolling");
}
}
@ -735,7 +785,9 @@ public class ConversationFragment extends Fragment
if (getListAdapter().getSelectedItems().size() == 0) {
actionMode.finish();
} else {
setCorrectMenuVisibility(actionMode.getMenu());
Menu menu = actionMode.getMenu();
setCorrectMenuVisibility(menu);
AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size()));
}
}
@ -768,6 +820,48 @@ public class ConversationFragment extends Fragment
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
}
}
@Override
public void onQuoteClicked(DcMsg messageRecord) {
DcMsg quoted = messageRecord.getQuotedMsg();
if (quoted == null) {
Log.i(TAG, "Clicked on a quote whose original message we never had.");
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT).show();
return;
}
int foreignChatId = quoted.getChatId();
if (foreignChatId != 0 && foreignChatId != chatId) {
Intent intent = new Intent(getActivity(), ConversationActivity.class);
intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, foreignChatId);
intent.putExtra(ConversationActivity.SCROLL_TO_MSG_ID_EXTRA, quoted.getId());
if (getActivity() != null) {
getActivity().startActivity(intent);
getActivity().finish();
} else {
Log.e(TAG, "Activity was null");
}
} else {
LinearLayoutManager layout = ((LinearLayoutManager) list.getLayoutManager());
boolean smooth = false;
ConversationAdapter adapter = (ConversationAdapter) list.getAdapter();
if (adapter == null) return;
int position = adapter.msgIdToPosition(quoted.getId());
if (layout != null) {
int distance1 = Math.abs(position - layout.findFirstVisibleItemPosition());
int distance2 = Math.abs(position - layout.findLastVisibleItemPosition());
int distance = Math.min(distance1, distance2);
smooth = distance < 15;
Log.i(TAG, "Scrolling to quote, smoth: " + smooth + ", distance: " + distance);
}
if (position != -1) {
scrollAndHighlight(position, smooth);
} else {
Log.e(TAG, "msgId {} not found for scrolling");
}
}
}
}
@Override
@ -799,6 +893,7 @@ public class ConversationFragment extends Fragment
}
setCorrectMenuVisibility(menu);
AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
return true;
}
@ -848,6 +943,9 @@ public class ConversationFragment extends Fragment
handleReplyMessage(getSelectedMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_reply_privately:
handleReplyMessagePrivately(getSelectedMessageRecord());
return true;
}
return false;

View file

@ -25,12 +25,15 @@ import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import android.graphics.Rect;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
@ -47,13 +50,16 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.QuoteView;
import org.thoughtcrime.securesms.connect.ApplicationDcContext;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.DocumentSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.LongClickCopySpan;
@ -81,8 +87,11 @@ public class ConversationItem extends LinearLayout
{
private static final String TAG = ConversationItem.class.getSimpleName();
private static final Rect SWIPE_RECT = new Rect();
private static final Pattern CMD_PATTERN = Pattern.compile("(?<=^|\\s)/[a-zA-Z][a-zA-Z@\\d_/.-]{0,254}");
private static final int MAX_MEASURE_CALLS = 3;
static long PULSE_HIGHLIGHT_MILLIS = 500;
private DcMsg messageRecord;
private DcChat dcChat;
@ -92,12 +101,14 @@ public class ConversationItem extends LinearLayout
private GlideRequests glideRequests;
protected ViewGroup bodyBubble;
protected View reply;
@Nullable private QuoteView quoteView;
private TextView bodyText;
private ConversationItemFooter footer;
private TextView groupSender;
private View groupSenderHolder;
private AvatarImageView contactPhoto;
private ViewGroup contactPhotoHolder;
protected ViewGroup contactPhotoHolder;
private ViewGroup container;
private @NonNull Set<DcMsg> batchSelected = new HashSet<>();
@ -107,6 +118,8 @@ public class ConversationItem extends LinearLayout
private @NonNull Stub<DocumentView> documentViewStub;
private @Nullable EventListener eventListener;
private int measureCalls;
private int incomingBubbleColor;
private int outgoingBubbleColor;
private int forwardedTitleColor;
@ -147,7 +160,9 @@ public class ConversationItem extends LinearLayout
this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub));
this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub));
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
this.reply = findViewById(R.id.reply_icon);
setOnClickListener(new ClickListener(null));
@ -189,6 +204,8 @@ public class ConversationItem extends LinearLayout
setAuthor(messageRecord, groupThread);
setMessageSpacing(context);
setFooter(messageRecord, locale);
setQuote(messageRecord);
}
@ -197,6 +214,14 @@ public class ConversationItem extends LinearLayout
this.eventListener = eventListener;
}
public boolean disallowSwipe(float downX, float downY) {
if (reply == null) return true;
if (!hasAudio(messageRecord)) return false;
audioViewStub.get().getSeekBarGlobalVisibleRect(SWIPE_RECT);
return SWIPE_RECT.contains((int) downX, (int) downY);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@ -204,6 +229,32 @@ public class ConversationItem extends LinearLayout
if (isInEditMode()) {
return;
}
boolean needsMeasure = false;
if (hasQuote(messageRecord)) {
if (quoteView == null) {
throw new AssertionError();
}
int quoteWidth = quoteView.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(quoteView);
if (quoteWidth != availableWidth) {
quoteView.getLayoutParams().width = availableWidth;
needsMeasure = true;
}
}
if (needsMeasure) {
if (measureCalls < MAX_MEASURE_CALLS) {
measureCalls++;
measure(widthMeasureSpec, heightMeasureSpec);
} else {
Log.w(TAG, "Hit measure() cap of " + MAX_MEASURE_CALLS);
}
} else {
measureCalls = 0;
}
}
private void initializeAttributes() {
@ -245,7 +296,7 @@ public class ConversationItem extends LinearLayout
} else if (pulseHighlight) {
setBackgroundResource(R.drawable.conversation_item_background_animated);
setSelected(true);
postDelayed(() -> setSelected(false), 1500);
postDelayed(() -> setSelected(false), PULSE_HIGHLIGHT_MILLIS);
} else {
setSelected(false);
}
@ -273,6 +324,10 @@ public class ConversationItem extends LinearLayout
return type==DcMsg.DC_MSG_AUDIO || type==DcMsg.DC_MSG_VOICE;
}
private boolean hasQuote(DcMsg messageRecord) {
return !"".equals(messageRecord.getQuotedText());
}
private boolean hasThumbnail(DcMsg messageRecord) {
int type = messageRecord.getType();
return type==DcMsg.DC_MSG_GIF || type==DcMsg.DC_MSG_IMAGE || type==DcMsg.DC_MSG_VIDEO;
@ -492,6 +547,54 @@ public class ConversationItem extends LinearLayout
return messageBody;
}
private void setQuote(@NonNull DcMsg current) {
if (quoteView == null) {
throw new AssertionError();
}
String quoteTxt = current.getQuotedText();
if (quoteTxt == null || quoteTxt.isEmpty()) {
quoteView.dismiss();
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
}
return;
}
DcMsg msg = current.getQuotedMsg();
// If you modify these lines you may also want to modify ConversationActivity.handleReplyMessage():
Recipient author = null;
SlideDeck slideDeck = new SlideDeck();
if (msg != null) {
author = dcContext.getRecipient(dcContext.getContact(msg.getFromId()));
if (msg.getType() != DcMsg.DC_MSG_TEXT) {
slideDeck.addSlide(MediaUtil.getSlideForMsg(context, msg));
}
}
quoteView.setQuote(GlideApp.with(this),
msg,
author,
quoteTxt,
slideDeck);
quoteView.setVisibility(View.VISIBLE);
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
quoteView.setOnClickListener(view -> {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onQuoteClicked(current);
} else {
passthroughClickListener.onClick(view);
}
});
quoteView.setOnLongClickListener(passthroughClickListener);
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding));
}
}
private void setGutterSizes(@NonNull DcMsg current, boolean isGroupThread) {
if (isGroupThread && current.isOutgoing()) {
ViewUtil.setLeftMargin(container, readDimen(R.dimen.conversation_group_left_gutter));
@ -535,9 +638,7 @@ public class ConversationItem extends LinearLayout
else if (groupThread && !messageRecord.isOutgoing() && dcContact !=null) {
this.groupSender.setText(dcContact.getDisplayName());
int rgb = dcContact.getColor();
int argb = Color.argb(0xFF, Color.red(rgb), Color.green(rgb), Color.blue(rgb));
this.groupSender.setTextColor(argb);
this.groupSender.setTextColor(dcContact.getArgbColor());
}
}
@ -589,6 +690,21 @@ public class ConversationItem extends LinearLayout
return context.getResources().getDimensionPixelOffset(dimenId);
}
private int getAvailableMessageBubbleWidth(@NonNull View forView) {
int availableWidth;
if (hasAudio(messageRecord)) {
availableWidth = audioViewStub.get().getMeasuredWidth() + ViewUtil.getLeftMargin(audioViewStub.get()) + ViewUtil.getRightMargin(audioViewStub.get());
} else if (hasThumbnail(messageRecord)) {
availableWidth = mediaThumbnailStub.get().getMeasuredWidth();
} else {
availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight();
}
availableWidth -= ViewUtil.getLeftMargin(forView) + ViewUtil.getRightMargin(forView);
return availableWidth;
}
/// Event handlers
private void handleDeadDropClick() {

View file

@ -0,0 +1,225 @@
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Vibrator;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.b44t.messenger.DcMsg;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX;
private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10;
private boolean swipeBack;
private boolean shouldTriggerSwipeFeedback;
private boolean canTriggerSwipe;
private float latestDownX;
private float latestDownY;
private final SwipeAvailabilityProvider swipeAvailabilityProvider;
private final ConversationItemTouchListener itemTouchListener;
private final OnSwipeListener onSwipeListener;
ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
@NonNull OnSwipeListener onSwipeListener)
{
super(0, ItemTouchHelper.END);
this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
this.swipeAvailabilityProvider = swipeAvailabilityProvider;
this.onSwipeListener = onSwipeListener;
this.shouldTriggerSwipeFeedback = true;
this.canTriggerSwipe = true;
}
void attachToRecyclerView(@NonNull RecyclerView recyclerView) {
recyclerView.addOnItemTouchListener(itemTouchListener);
new ItemTouchHelper(this).attachToRecyclerView(recyclerView);
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
@NonNull RecyclerView.ViewHolder target)
{
return false;
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
}
@Override
public int getSwipeDirs(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder)
{
if (cannotSwipeViewHolder(viewHolder)) return 0;
return super.getSwipeDirs(recyclerView, viewHolder);
}
@Override
public int convertToAbsoluteDirection(int flags, int layoutDirection) {
if (swipeBack) {
swipeBack = false;
return 0;
}
return super.convertToAbsoluteDirection(flags, layoutDirection);
}
@Override
public void onChildDraw(
@NonNull Canvas c,
@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
float dx, float dy, int actionState, boolean isCurrentlyActive)
{
if (cannotSwipeViewHolder(viewHolder)) return;
float sign = getSignFromDirection(viewHolder.itemView);
boolean isCorrectSwipeDir = sameSign(dx, sign);
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
if (canTriggerSwipe) {
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
}
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
}
if (dx == 0) {
shouldTriggerSwipeFeedback = true;
canTriggerSwipe = true;
}
}
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) {
vibrate(item.getContext());
ConversationSwipeAnimationHelper.trigger(item);
shouldTriggerSwipeFeedback = false;
}
}
private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) {
if (cannotSwipeViewHolder(viewHolder)) return;
ConversationItem item = ((ConversationItem) viewHolder.itemView);
DcMsg messageRecord = item.getMessageRecord();
onSwipeListener.onSwipe(messageRecord);
}
@SuppressLint("ClickableViewAccessibility")
private void setTouchListener(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
float dx)
{
recyclerView.setOnTouchListener(new View.OnTouchListener() {
// This variable is necessary to make sure that the handleTouchActionUp() and therefore onSwiped() is called only once.
// Otherwise, any subsequent little swipe would invoke onSwiped().
// We can't call recyclerView.setOnTouchListener(null) because another ConversationItem might have set its own
// on touch listener in the meantime and we don't want to cancel it
private boolean listenerCalled = false;
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
shouldTriggerSwipeFeedback = true;
break;
case MotionEvent.ACTION_UP:
if (!listenerCalled) {
listenerCalled = true;
ConversationItemSwipeCallback.this.handleTouchActionUp(recyclerView, viewHolder, dx);
}
//fallthrough
case MotionEvent.ACTION_CANCEL:
swipeBack = true;
shouldTriggerSwipeFeedback = false;
// Sometimes the view does not go back correctly, so make sure that after 2s the progress is reset:
viewHolder.itemView.postDelayed(() -> resetProgress(viewHolder), 2000);
if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) {
resetProgress(viewHolder);
}
break;
}
return false;
}
});
}
private void handleTouchActionUp(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
float dx)
{
if (dx > SWIPE_SUCCESS_DX) {
canTriggerSwipe = false;
onSwiped(viewHolder);
if (shouldTriggerSwipeFeedback) {
vibrate(viewHolder.itemView.getContext());
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
recyclerView.cancelPendingInputEvents();
}
}
private static void resetProgress(RecyclerView.ViewHolder viewHolder) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
0f,
getSignFromDirection(viewHolder.itemView));
}
private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
if (!(viewHolder.itemView instanceof ConversationItem)) return true;
ConversationItem item = ((ConversationItem) viewHolder.itemView);
return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) ||
item.disallowSwipe(latestDownX, latestDownY);
}
private void updateLatestDownCoordinate(float x, float y) {
latestDownX = x;
latestDownY = y;
}
private static float getSignFromDirection(@NonNull View view) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? -1f : 1f;
} else {
return 1f;
}
}
private static boolean sameSign(float dX, float sign) {
return dX * sign > 0;
}
private static void vibrate(@NonNull Context context) {
Vibrator vibrator = ServiceUtil.getVibrator(context);
if (vibrator != null) vibrator.vibrate(SWIPE_SUCCESS_VIBE_TIME_MS);
}
interface SwipeAvailabilityProvider {
boolean isSwipeAvailable(DcMsg conversationMessage);
}
interface OnSwipeListener {
void onSwipe(DcMsg conversationMessage);
}
}

View file

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
final class ConversationItemTouchListener extends RecyclerView.SimpleOnItemTouchListener {
private final Callback callback;
ConversationItemTouchListener(Callback callback) {
this.callback = callback;
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_DOWN) {
callback.onDownEvent(e.getRawX(), e.getRawY());
}
return false;
}
interface Callback {
void onDownEvent(float rawX, float rawY);
}
}

View file

@ -88,7 +88,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
ApplicationDcContext dcContext = DcHelper.getContext(this);
// add welcome message
dcContext.updateDeviceChats();
// dcContext.updateDeviceChats();
// update messages - for new messages, do not reuse or modify strings but create new ones.
// it is not needed to keep all past update messages, however, when deleted, also the strings should be deleted.

View file

@ -0,0 +1,142 @@
package org.thoughtcrime.securesms;
import android.animation.ValueAnimator;
import android.content.res.Resources;
import android.view.View;
import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.util.Util;
final class ConversationSwipeAnimationHelper {
static final float TRIGGER_DX = dpToPx(64);
static final float MAX_DX = dpToPx(96);
private static final float REPLY_SCALE_OVERSHOOT = 1.8f;
private static final float REPLY_SCALE_MAX = 1.2f;
private static final float REPLY_SCALE_MIN = 1f;
private static final long REPLY_SCALE_OVERSHOOT_DURATION = 200;
private static final Interpolator BUBBLE_INTERPOLATOR = new BubblePositionInterpolator(0f, TRIGGER_DX, MAX_DX);
private static final Interpolator REPLY_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(0f, 1f, 1f);
private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10));
private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8));
private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX);
private ConversationSwipeAnimationHelper() {
}
public static void update(@NonNull ConversationItem conversationItem, float dx, float sign) {
float progress = dx / TRIGGER_DX;
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
}
public static void trigger(@NonNull ConversationItem conversationItem) {
triggerReplyIcon(conversationItem.reply);
}
private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float dx, float sign) {
bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
}
private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) {
reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
}
private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) {
if (progress > 0.05f) {
replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress));
} else replyIcon.setAlpha(0f);
replyIcon.setTranslationX(REPLY_TRANSITION_INTERPOLATOR.getInterpolation(progress) * sign);
if (dx < TRIGGER_DX) {
float scale = REPLY_SCALE_INTERPOLATOR.getInterpolation(progress);
replyIcon.setScaleX(scale);
replyIcon.setScaleY(scale);
}
}
private static void updateContactPhotoHolderTransition(@Nullable View contactPhotoHolder,
float progress,
float sign)
{
if (contactPhotoHolder == null) return;
contactPhotoHolder.setTranslationX(AVATAR_INTERPOLATOR.getInterpolation(progress) * sign);
}
private static void triggerReplyIcon(@NonNull View replyIcon) {
ValueAnimator animator = ValueAnimator.ofFloat(REPLY_SCALE_MAX, REPLY_SCALE_OVERSHOOT, REPLY_SCALE_MAX);
animator.setDuration(REPLY_SCALE_OVERSHOOT_DURATION);
animator.addUpdateListener(animation -> {
replyIcon.setScaleX((float) animation.getAnimatedValue());
replyIcon.setScaleY((float) animation.getAnimatedValue());
});
animator.start();
}
private static int dpToPx(int dp) {
return (int) (dp * Resources.getSystem().getDisplayMetrics().density);
}
private static final class BubblePositionInterpolator implements Interpolator {
private final float start;
private final float middle;
private final float end;
private BubblePositionInterpolator(float start, float middle, float end) {
this.start = start;
this.middle = middle;
this.end = end;
}
@Override
public float getInterpolation(float input) {
if (input < start) {
return start;
} else if (input < middle) {
return input;
} else {
float segmentLength = end - middle;
float segmentTraveled = input - middle;
float segmentCompletion = segmentTraveled / segmentLength;
float scaleDownFactor = middle / (input * 2);
float output = middle + (segmentLength * segmentCompletion * scaleDownFactor);
return Math.min(output, end);
}
}
}
private static final class ClampingLinearInterpolator implements Interpolator {
private final float slope;
private final float yIntercept;
private final float max;
private final float min;
ClampingLinearInterpolator(float start, float end) {
this(start, end, 1.0f);
}
ClampingLinearInterpolator(float start, float end, float scale) {
slope = (end - start) * scale;
yIntercept = start;
max = Math.max(start, end);
min = Math.min(start, end);
}
@Override
public float getInterpolation(float input) {
return Util.clamp(slope * input + yIntercept, min, max);
}
}
}

View file

@ -4,6 +4,7 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.os.Build;
import androidx.annotation.NonNull;
@ -182,6 +183,10 @@ public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener
}
}
public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) {
seekBar.getGlobalVisibleRect(rect);
}
private double getProgress() {
if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
return 0;

View file

@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
@ -9,6 +11,7 @@ import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
@ -19,23 +22,32 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.view.ViewCompat;
import com.b44t.messenger.DcMsg;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Prefs;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.guava.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public class InputPanel extends LinearLayout
public class InputPanel extends ConstraintLayout
implements MicrophoneRecorderView.Listener,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiKeyboardProvider.EmojiEventListener
@ -45,6 +57,7 @@ public class InputPanel extends LinearLayout
private static final int FADE_TIME = 150;
private QuoteView quoteView;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private View quickCameraToggle;
@ -55,6 +68,7 @@ public class InputPanel extends LinearLayout
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private @Nullable Listener listener;
private boolean emojiVisible;
@ -76,7 +90,9 @@ public class InputPanel extends LinearLayout
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss);
this.quoteView = findViewById(R.id.quote_view);
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
@ -101,6 +117,8 @@ public class InputPanel extends LinearLayout
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
}
quoteDismiss.setOnClickListener(v -> clearQuote());
}
public void setListener(final @NonNull Listener listener) {
@ -113,6 +131,81 @@ public class InputPanel extends LinearLayout
composeText.setMediaListener(listener);
}
public void setQuote(@NonNull GlideRequests glideRequests,
@NonNull DcMsg msg,
long id,
@NonNull Recipient author,
@NonNull CharSequence body,
@NonNull SlideDeck attachments)
{
this.quoteView.setQuote(glideRequests, msg, author, body, attachments);
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
this.quoteView.setVisibility(VISIBLE);
this.quoteView.measure(0, 0);
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null);
quoteAnimator.start();
}
public void clearQuote() {
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
quoteAnimator = createHeightAnimator(quoteView, quoteView.getMeasuredHeight(), 0, new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
quoteView.dismiss();
}
});
quoteAnimator.start();
}
private static ValueAnimator createHeightAnimator(@NonNull View view,
int originalHeight,
int finalHeight,
@Nullable AnimationCompleteListener onAnimationComplete)
{
ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight)
.setDuration(300);
animator.addUpdateListener(animation -> {
ViewGroup.LayoutParams params = view.getLayoutParams();
params.height = Math.max(1, (int) animation.getAnimatedValue());
view.setLayoutParams(params);
});
if (onAnimationComplete != null) {
animator.addListener(onAnimationComplete);
}
return animator;
}
public Optional<QuoteModel> getQuote() {
if (quoteView.getVisibility() == View.VISIBLE && quoteView.getBody() != null) {
return Optional.of(new QuoteModel(
quoteView.getDcContact(), quoteView.getBody().toString(),
false, quoteView.getAttachments(), quoteView.getOriginalMsg()
));
} else {
return Optional.absent();
}
}
public void clickOnComposeInput() {
composeText.performClick();
}
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}

View file

@ -0,0 +1,260 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.annimon.stream.Stream;
import com.b44t.messenger.DcContact;
import com.b44t.messenger.DcMsg;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private static final String TAG = QuoteView.class.getSimpleName();
private static final int MESSAGE_TYPE_PREVIEW = 0;
private static final int MESSAGE_TYPE_OUTGOING = 1;
private static final int MESSAGE_TYPE_INCOMING = 2;
private ViewGroup mainView;
private TextView authorView;
private TextView bodyView;
private ImageView quoteBarView;
private ImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private ImageView dismissView;
private DcMsg quotedMsg;
private DcContact author;
private CharSequence body;
//private TextView missingLinkText;
private SlideDeck attachments;
private int messageType;
private int largeCornerRadius;
private int smallCornerRadius;
// private CornerMask cornerMask;
public QuoteView(Context context) {
super(context);
initialize(null);
}
public QuoteView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.quote_view, this);
this.mainView = findViewById(R.id.quote_main);
this.authorView = findViewById(R.id.quote_author);
this.bodyView = findViewById(R.id.quote_text);
this.quoteBarView = findViewById(R.id.quote_bar);
this.thumbnailView = findViewById(R.id.quote_thumbnail);
this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay);
this.attachmentContainerView = findViewById(R.id.quote_attachment_container);
this.dismissView = findViewById(R.id.quote_dismiss);
// this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
// this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
// cornerMask = new CornerMask(this);
// cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0);
int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK);
int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK);
messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0);
typedArray.recycle();
dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE);
// if (messageType == MESSAGE_TYPE_PREVIEW) {
// int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
// cornerMask.setTopLeftRadius(radius);
// cornerMask.setTopRightRadius(radius);
// }
}
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
//
// @Override
// protected void dispatchDraw(Canvas canvas) {
// super.dispatchDraw(canvas);
// cornerMask.mask(canvas);
// }
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
//if (author != null) author.removeForeverObserver(this);
}
public void setQuote(GlideRequests glideRequests,
DcMsg msg,
@Nullable Recipient author,
@Nullable CharSequence body,
@NonNull SlideDeck attachments)
{
// if (this.author != null) this.author.removeForeverObserver(this);
quotedMsg = msg;
this.author = author != null ? author.getDcContact() : null;
this.body = body;
this.attachments = attachments;
//this.author.observeForever(this);
setQuoteAuthor(author);
setQuoteText(body, attachments);
setQuoteAttachment(glideRequests, attachments);
//setQuoteMissingFooter(originalMissing);
}
// public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
// cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius);
// cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius);
// }
public void dismiss() {
//if (this.author != null) this.author.removeForeverObserver(this);
this.author = null;
this.body = null;
setVisibility(GONE);
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
setQuoteAuthor(recipient);
}
private void setQuoteAuthor(@Nullable Recipient author) {
if (author == null) {
authorView.setVisibility(GONE);
return;
}
DcContact contact = author.getDcContact();
if (contact != null) {
authorView.setVisibility(VISIBLE);
authorView.setText(contact.getDisplayName());
quoteBarView.setBackgroundColor(contact.getArgbColor());
authorView.setTextColor(contact.getArgbColor());
}
}
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body);
} else {
bodyView.setVisibility(GONE);
}
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
List<Slide> imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo()).limit(1).toList();
List<Slide> audioSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasAudio()).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
attachmentVideoOverlayView.setVisibility(GONE);
if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
Uri thumbnailUri = imageVideoSlides.get(0).getUri();
if (imageVideoSlides.get(0).hasVideo()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
MediaUtil.createVideoThumbnailIfNeeded(getContext(), imageVideoSlides.get(0).getUri(), imageVideoSlides.get(0).getThumbnailUri(), null);
thumbnailUri = imageVideoSlides.get(0).getThumbnailUri();
}
glideRequests.load(new DecryptableUri(thumbnailUri))
.centerCrop()
.override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if(!audioSlides.isEmpty()) {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
} else if (!documentSlides.isEmpty()) {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
}
if (ThemeUtil.isDarkTheme(getContext())) {
dismissView.setBackgroundResource(R.drawable.circle_alpha);
}
}
//
// private void setQuoteMissingFooter(boolean missing) {
// footerView.setVisibility(missing ? VISIBLE : GONE);
// footerView.setBackgroundColor(author.getColor());
// }
public CharSequence getBody() {
return body;
}
public List<Attachment> getAttachments() {
return attachments.asAttachments();
}
public DcContact getDcContact() {
return author;
}
public DcMsg getOriginalMsg() {
return quotedMsg;
}
}

View file

@ -112,7 +112,7 @@ public class ScaleStableImageView
large = storedSizes.get(landscapeWidth + "x" + landscapeHeight);
if (large == null) return; // no baseline. can't work.
Bitmap original = ((BitmapDrawable) large).getBitmap();
if (height <= original.getHeight() || width <= original.getWidth()) {
if (height <= original.getHeight() && width <= original.getWidth()) {
Bitmap cropped = Bitmap.createBitmap(original, 0, 0, width, height);
Drawable croppedDrawable = new BitmapDrawable(getResources(), cropped);
overrideDrawable(croppedDrawable);

View file

@ -11,6 +11,7 @@ import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewAnimationUtils;
@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
public class SearchToolbar extends LinearLayout {
private static final String TAG = SearchToolbar.class.getSimpleName();
private float x, y;
private MenuItem searchItem;
private SearchListener listener;
@ -47,6 +49,10 @@ public class SearchToolbar extends LinearLayout {
setOrientation(VERTICAL);
Toolbar toolbar = findViewById(R.id.toolbar);
if (toolbar == null) {
Log.e(TAG, "SearchToolbar: No toolbar");
return;
}
Drawable drawable = getContext().getResources().getDrawable(R.drawable.ic_arrow_back_white_24dp);
drawable.mutate();

View file

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.connect;
import android.content.Context;
import androidx.annotation.NonNull;
import com.b44t.messenger.DcContext;
import org.thoughtcrime.securesms.ApplicationContext;
@ -34,7 +36,7 @@ public class DcHelper {
public static final String CONFIG_MEDIA_QUALITY = "media_quality";
public static final String CONFIG_WEBRTC_INSTANCE = "webrtc_instance";
public static ApplicationDcContext getContext(Context context) {
public static ApplicationDcContext getContext(@NonNull Context context) {
return ApplicationContext.getInstance(context).dcContext;
}

View file

@ -59,7 +59,7 @@ public class AudioSlide extends Slide {
@Override
public boolean hasImage() {
return true;
return false;
}
@Override

View file

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.mms;
import androidx.annotation.Nullable;
import com.b44t.messenger.DcContact;
import com.b44t.messenger.DcMsg;
import org.thoughtcrime.securesms.attachments.Attachment;
import java.util.List;
public class QuoteModel {
private final DcContact author;
private final String text;
private final List<Attachment> attachments;
private DcMsg quotedMsg;
public QuoteModel(DcContact author, String text, boolean missing, @Nullable List<Attachment> attachments, DcMsg quotedMsg) {
this.author = author;
this.text = text;
this.attachments = attachments;
this.quotedMsg = quotedMsg;
}
public DcContact getAuthor() {
return author;
}
public String getText() {
return text;
}
public List<Attachment> getAttachments() {
return attachments;
}
public DcMsg getQuotedMsg() {
return quotedMsg;
}
}

View file

@ -50,6 +50,19 @@ public class SlideDeck {
slides.add(slide);
}
public List<Slide> getSlides() {
return slides;
}
public boolean containsMediaSlide() {
for (Slide slide : slides) {
if (slide.hasImage() || slide.hasVideo() || slide.hasAudio() || slide.hasDocument()) {
return true;
}
}
return false;
}
public @Nullable DocumentSlide getDocumentSlide() {
for (Slide slide: slides) {
if (slide.hasDocument()) {

View file

@ -137,6 +137,10 @@ public class Recipient {
return "";
}
public @Nullable DcContact getDcContact() {
return dcContact;
}
public @NonNull Address getAddress() {
return address;
}

View file

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.recipients;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
public interface RecipientForeverObserver {
@MainThread
void onRecipientChanged(@NonNull Recipient recipient);
}

View file

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
public final class AccessibilityUtil {
private AccessibilityUtil() {
}
public static boolean areAnimationsDisabled(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
return Settings.Global.getFloat(context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1) == 0f;
} else {
return false;
}
}
}

View file

@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.util;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
@ -240,4 +241,8 @@ public class ViewUtil {
public static void setPaddingBottom(@NonNull View view, int padding) {
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding);
}
public static int dpToPx(int dp) {
return Math.round(dp * Resources.getSystem().getDisplayMetrics().density);
}
}

View file

@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.util.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* AdaptiveActionsToolbar behaves like a normal {@link Toolbar} except in that it ignores the
* showAsAlways attributes of menu items added via menu inflation, opting for an adaptive algorithm
* instead. This algorithm will display as many icons as it can up to a specific percentage of the
* screen.
*
* Each ActionView icon is expected to occupy 48dp of space, including padding. Items are stacked one
* after the next with no margins.
*
* This view can be customized via attributes:
*
* aat_max_shown -- controls the max number of items to display.
* aat_percent_for_actions -- controls the max percent of screen width the buttons can occupy.
*/
public class AdaptiveActionsToolbar extends Toolbar {
private static final int NAVIGATION_DP = 56;
private static final int ACTION_VIEW_WIDTH_DP = 48;
private static final int OVERFLOW_VIEW_WIDTH_DP = 36;
private int maxShown;
public AdaptiveActionsToolbar(@NonNull Context context) {
this(context, null);
}
public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.toolbarStyle);
}
public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.AdaptiveActionsToolbar);
maxShown = array.getInteger(R.styleable.AdaptiveActionsToolbar_aat_max_shown, 100);
array.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
adjustMenuActions(getMenu(), maxShown, getMeasuredWidth());
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
public static void adjustMenuActions(@NonNull Menu menu, int maxToShow, int toolbarWidthPx) {
int menuSize = 0;
for (int i = 0; i < menu.size(); i++) {
if (menu.getItem(i).isVisible()) {
menuSize++;
}
}
int widthAllowed = toolbarWidthPx - ViewUtil.dpToPx(NAVIGATION_DP);
int nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP));
if (nItemsToShow < menuSize) {
widthAllowed -= ViewUtil.dpToPx(OVERFLOW_VIEW_WIDTH_DP);
}
nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP));
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
boolean neverShowAsAction = item.getItemId() == R.id.menu_context_reply_privately;
if (item.isVisible() && nItemsToShow > 0 && !neverShowAsAction) {
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
nItemsToShow--;
} else {
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
}
}
}
}