Add internal pre-alpha support for receiving reactions.

This commit is contained in:
Alex Hart 2019-12-03 17:57:21 -04:00 committed by Greyson Parrelli
parent a8d826020d
commit bceb69b284
105 changed files with 3202 additions and 255 deletions

24
protobuf/Database.proto Normal file
View File

@ -0,0 +1,24 @@
/**
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
syntax = "proto3";
package signal;
option java_package = "org.thoughtcrime.securesms.database.model";
option java_outer_classname = "DatabaseProtos";
message ReactionList {
message Reaction {
string emoji = 1;
uint64 author = 2;
uint64 sentTime = 3;
uint64 receivedTime = 4;
}
repeated Reaction reactions = 1;
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="@integer/reaction_scrubber_reveal_emoji_duration"
android:interpolator="@android:anim/decelerate_interpolator"
android:propertyName="translationY"
android:valueTo="@dimen/reaction_scrubber_anim_start_translation_y"
android:valueFrom="@dimen/reaction_scrubber_anim_end_translation_y" />
<objectAnimator
android:duration="@integer/reaction_scrubber_reveal_emoji_duration"
android:interpolator="@android:anim/decelerate_interpolator"
android:propertyName="alpha"
android:valueTo="0"
android:valueFrom="1" />
</set>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="@integer/reaction_scrubber_reveal_duration"
android:interpolator="@android:anim/linear_interpolator"
android:propertyName="translationY"
android:valueFrom="@dimen/reaction_scrubber_anim_start_translation_y"
android:valueTo="@dimen/reaction_scrubber_anim_end_translation_y" />
<objectAnimator
android:duration="@integer/reaction_scrubber_reveal_duration"
android:interpolator="@android:anim/linear_interpolator"
android:propertyName="alpha"
android:valueFrom="0"
android:valueTo="1" />
</set>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/transparent_black_80" />
<corners android:radius="30dp" />
</shape>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M15.95,19.5A3,3 0,0 1,13 22H6a3,3 0,0 1,-3 -3V9A3,3 0,0 1,6 6h0.5V7.5H6A1.5,1.5 0,0 0,4.5 9V19A1.5,1.5 0,0 0,6 20.5h7a1.5,1.5 0,0 0,1.408 -1ZM18,3.5H11A1.5,1.5 0,0 0,9.5 5V15A1.5,1.5 0,0 0,11 16.5h7A1.5,1.5 0,0 0,19.5 15V5A1.5,1.5 0,0 0,18 3.5M18,2a3,3 0,0 1,3 3V15a3,3 0,0 1,-3 3H11a3,3 0,0 1,-3 -3V5a3,3 0,0 1,3 -3Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M21,5V15a3,3 0,0 1,-3 3H11a3,3 0,0 1,-3 -3V5a3,3 0,0 1,3 -3h7A3,3 0,0 1,21 5ZM11,19.5A4.505,4.505 0,0 1,6.5 15V6H6A3,3 0,0 0,3 9V19a3,3 0,0 0,3 3h7a3,3 0,0 0,2.95 -2.5Z"/>
</vector>

View File

@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:fillColor="#FFFFFFFF"
android:pathData="M12.91,3.91h0m1.71,2.16V9.32l-1.31,0.17A11,11 0,0 0,3.84 17c-0.12,0.33 -0.8,1.59 -0.8,1.59s1,-1 1.3,-1.22A17.36,17.36 0,0 1,13 14.5l1.62,-0.12v3.48l-0.06,0.81L15,18 21,12 14.9,5.9l-0.34,-0.54ZM13.48,2.58a0.76,0.76 0,0 1,0.49 0.27l8.79,8.8a0.48,0.48 0,0 1,0 0.7L14,21.15a0.76,0.76 0,0 1,-0.49 0.27c-0.22,0 -0.36,-0.22 -0.36,-0.63V16c-5,0.39 -8.83,2.48 -11.37,6 -0.14,0.2 -0.27,0.29 -0.37,0.29s-0.2,-0.17 -0.16,-0.5C2,14.43 5.59,9 13.12,8V3.21c0,-0.41 0.14,-0.63 0.36,-0.63Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M2.116,21.426A14.706,14.706 0,0 1,13.021 16v4.793c0,0.664 0.384,0.823 0.853,0.353l8.793,-8.792a0.5,0.5 0,0 0,0 -0.708L13.874,2.854c-0.469,-0.47 -0.853,-0.311 -0.853,0.353V8C5.757,8.934 2.2,14.051 1.217,21.059 1.071,22.1 1.463,22.249 2.116,21.426Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,1A11,11 0,1 0,23 12,11 11,0 0,0 12,1ZM10.94,5.939A1.5,1.5 0,0 1,13.5 7a1.5,1.5 0,0 1,-2.56 1.06,1.5 1.5,0 0,1 0,-2.121ZM15,18H9V16.5h2.5v-5h-2V10H13v6.5h2Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M9.5,5.621v3.7l1.309,0.169a10.932,10.932 0,0 1,9.364 7.253c0.161,0.406 0.8,1.756 0.8,1.756A19.408,19.408 0,0 0,19.5 17.2,17.455 17.455,0 0,0 11.115,14.5L9.5,14.38v4L3.121,12 9.5,5.621m1.137,-3.037a0.758,0.758 0,0 0,-0.491 0.27L1.354,11.646a0.5,0.5 0,0 0,0 0.708l8.792,8.792a0.758,0.758 0,0 0,0.491 0.27c0.219,0 0.363,-0.217 0.363,-0.623V16a14.706,14.706 0,0 1,10.905 5.426c0.282,0.355 0.514,0.529 0.677,0.529 0.214,0 0.3,-0.3 0.222,-0.9C21.822,14.051 18.264,8.934 11,8V3.207c0,-0.406 -0.144,-0.623 -0.363,-0.623Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M21.905,21.426A14.706,14.706 0,0 0,11 16v4.793c0,0.664 -0.384,0.823 -0.854,0.353L1.354,12.354a0.5,0.5 0,0 1,0 -0.708l8.792,-8.792c0.47,-0.47 0.854,-0.311 0.854,0.353V8c7.264,0.934 10.822,6.051 11.8,13.059C22.95,22.1 22.558,22.249 21.905,21.426Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M22,10v9a3,3 0,0 1,-3 3L5,22a3,3 0,0 1,-3 -3L2,10A3,3 0,0 1,5 7L9.75,7L9.75,8.5L5,8.5A1.5,1.5 0,0 0,3.5 10v9A1.5,1.5 0,0 0,5 20.5L19,20.5A1.5,1.5 0,0 0,20.5 19L20.5,10A1.5,1.5 0,0 0,19 8.5L14.25,8.5L14.25,7L19,7A3,3 0,0 1,22 10ZM15.419,11.47L13.586,13.3l-0.82,1.148L12.766,2h-1.5L11.266,14.45l-0.742,-1.039L8.581,11.485 7.525,12.55 12.012,17l4.467,-4.468Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M8.5,4.75L22,4.75v1.5L8.5,6.25ZM8.5,12.75L22,12.75v-1.5L8.5,11.25ZM8.5,19.25L22,19.25v-1.5L8.5,17.75ZM4.5,4.5a1,1 0,1 0,1 1,1 1,0 0,0 -1,-1M4.5,3A2.5,2.5 0,1 1,2 5.5,2.5 2.5,0 0,1 4.5,3ZM4.5,11a1,1 0,1 0,1 1,1 1,0 0,0 -1,-1m0,-1.5A2.5,2.5 0,1 1,2 12,2.5 2.5,0 0,1 4.5,9.5ZM4.5,17.5a1,1 0,1 0,1 1,1 1,0 0,0 -1,-1m0,-1.5A2.5,2.5 0,1 1,2 18.5,2.5 2.5,0 0,1 4.5,16Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M12.75,18h-1.5L11.25,8h1.5ZM16.5,8L15,8L15,18h1.5ZM9,8L7.5,8L7.5,18L9,18ZM22,5.5L20,5.5L20,19a3,3 0,0 1,-3 3L7,22a3,3 0,0 1,-3 -3L4,5.5L2,5.5L2,4L7.571,4l1.05,-1.837h0A1.5,1.5 0,0 1,9.866 1.5h4.269a1.5,1.5 0,0 1,1.235 0.651L16.43,4L22,4ZM9.3,4h5.4l-0.573,-1L9.871,3ZM18.5,5.5L5.5,5.5L5.5,19A1.5,1.5 0,0 0,7 20.5L17,20.5A1.5,1.5 0,0 0,18.5 19Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M16.43,4 L15.37,2.151A1.5,1.5 0,0 0,14.135 1.5L9.866,1.5a1.5,1.5 0,0 0,-1.244 0.663h0L7.571,4L2,4L2,5.5L4,5.5L4,19a3,3 0,0 0,3 3L17,22a3,3 0,0 0,3 -3L20,5.5h2L22,4ZM9,18L7.5,18L7.5,8L9,8ZM12.75,18h-1.5L11.25,8h1.5ZM9.3,4l0.572,-1h4.257L14.7,4ZM16.5,18L15,18L15,8h1.5Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?conversation_subtitle_color"
android:pathData="M20.5,4.5l-1,-1l-7.5,7.4l-7.5,-7.4l-1,1l7.4,7.5l-7.4,7.5l1,1l7.5,-7.4l7.5,7.4l1,-1l-7.4,-7.5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="18dp" />
<solid android:color="@color/core_grey_60" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="18dp" />
<solid android:color="@color/core_grey_05" />
</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="@color/transparent_white_30" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/core_grey_75" />
<stroke
android:width="1dp"
android:color="@color/core_grey_95" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/core_grey_05" />
<stroke
android:width="1dp"
android:color="@color/white" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/core_grey_45" />
<stroke
android:width="1dp"
android:color="@color/core_grey_95" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/core_grey_25" />
<stroke
android:width="1dp"
android:color="@color/white" />
</shape>

View File

@ -142,4 +142,5 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include layout="@layout/conversation_reaction_scrubber" />
</FrameLayout>

View File

@ -55,5 +55,4 @@
android:alpha="0.9"
android:contentDescription="@string/conversation_fragment__scroll_to_the_bottom_content_description"
android:src="@drawable/ic_scroll_down"/>
</FrameLayout>

View File

@ -27,7 +27,7 @@
android:id="@+id/reply_icon"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:src="@drawable/ic_reply_white_24dp"
app:srcCompat="?menu_reply_icon"
android:tint="?compose_icon_tint"
android:alpha="0"
android:layout_alignTop="@id/body_bubble"
@ -231,5 +231,41 @@
android:orientation="vertical"
android:gravity="center_vertical"/>
<org.thoughtcrime.securesms.components.MaxHeightFrameLayout
android:id="@+id/reactions_bubbles_container"
android:layout_width="wrap_content"
android:layout_height="0dip"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:layout_marginStart="@dimen/reactions_bubble_margin"
android:layout_toEndOf="@id/body_bubble"
android:visibility="gone"
app:mhfl_maxHeight="@dimen/reactions_bubble_container_max_height"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bubbles_secondary"
android:layout_width="@dimen/reactions_bubble_size"
android:layout_height="@dimen/reactions_bubble_size"
android:layout_gravity="bottom"
android:background="?attr/reactions_recv_background"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="@dimen/reactions_bubble_text_size"
tools:ignore="SpUsage" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bubbles_primary"
android:layout_width="@dimen/reactions_bubble_size"
android:layout_height="@dimen/reactions_bubble_size"
android:layout_gravity="top"
android:background="?attr/reactions_recv_background"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="@dimen/reactions_bubble_text_size"
tools:ignore="SpUsage" />
</org.thoughtcrime.securesms.components.MaxHeightFrameLayout>
</RelativeLayout>
</org.thoughtcrime.securesms.conversation.ConversationItem>

View File

@ -1,71 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.ConversationItem
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<org.thoughtcrime.securesms.conversation.ConversationItem xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_item"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/conversation_item_background"
android:clipChildren="false"
android:clipToPadding="false"
android:focusable="true"
android:nextFocusLeft="@id/container"
android:nextFocusRight="@id/embedded_text_editor"
android:background="@drawable/conversation_item_background"
android:clipToPadding="false"
android:clipChildren="false">
android:orientation="horizontal">
<RelativeLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/conversation_individual_right_gutter"
android:clipToPadding="false"
android:clipChildren="false">
android:clipChildren="false"
android:clipToPadding="false">
<ImageView
android:id="@+id/reply_icon"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:alpha="0"
android:src="@drawable/ic_reply_white_24dp"
android:tint="?compose_icon_tint"
android:layout_alignStart="@id/body_bubble"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:layout_alignStart="@id/body_bubble" />
android:alpha="0"
app:srcCompat="?menu_reply_icon"
android:tint="?compose_icon_tint" />
<org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble
android:id="@+id/body_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_toStartOf="@+id/indicators_parent"
android:layout_alignWithParentIfMissing="true"
android:layout_marginStart="@dimen/message_bubble_edge_margin"
android:clipToPadding="false"
android:clipChildren="false"
android:layout_toStartOf="@+id/indicators_parent"
android:background="@color/white"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
tools:backgroundTint="@color/core_grey_05">
<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_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginEnd="6dp"
android:visibility="gone"
app:message_type="outgoing"
app:quote_colorPrimary="?attr/conversation_item_quote_text_color"
app:quote_colorSecondary="?attr/conversation_item_quote_text_color"
tools:visibility="visible"/>
tools:visibility="visible" />
<ViewStub
android:id="@+id/shared_contact_view_stub"
android:layout="@layout/conversation_item_sent_shared_contact"
android:layout_width="@dimen/media_bubble_default_dimens"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:visibility="gone"/>
android:layout="@layout/conversation_item_sent_shared_contact"
android:visibility="gone" />
<ViewStub
android:id="@+id/image_view_stub"
@ -87,55 +86,55 @@
<ViewStub
android:id="@+id/audio_view_stub"
android:layout="@layout/conversation_item_sent_audio"
android:layout_width="210dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" />
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout="@layout/conversation_item_sent_audio" />
<ViewStub
android:id="@+id/document_view_stub"
android:layout="@layout/conversation_item_sent_document"
android:layout_width="210dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" />
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout="@layout/conversation_item_sent_document" />
<ViewStub
android:id="@+id/revealable_view_stub"
android:layout="@layout/conversation_item_sent_revealable"
android:layout_width="148dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" />
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
android:layout="@layout/conversation_item_sent_revealable" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/conversation_item_body"
style="@style/Signal.Text.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginTop="@dimen/message_bubble_top_padding"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding"
style="@style/Signal.Text.Body"
android:ellipsize="end"
android:textColor="?conversation_item_sent_text_primary_color"
android:textColorLink="?conversation_item_sent_text_primary_color"
android:ellipsize="end"
app:scaleEmojis="true"
app:emoji_maxLength="1000"
tools:text="Mango pickle lorem ipsum"/>
app:scaleEmojis="true"
tools:text="Mango pickle lorem ipsum" />
<View
android:id="@+id/group_sender_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"/>
android:visibility="gone" />
<TextView
android:id="@+id/group_message_sender"
@ -153,29 +152,29 @@
android:id="@+id/conversation_item_footer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-4dp"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginTop="-4dp"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:layout_marginBottom="@dimen/message_bubble_bottom_padding"
android:gravity="end"
android:clipChildren="false"
android:clipToPadding="false"
app:footer_text_color="?attr/conversation_item_sent_text_secondary_color"
app:footer_icon_color="?attr/conversation_item_sent_icon_color"/>
android:gravity="end"
app:footer_icon_color="?attr/conversation_item_sent_icon_color"
app:footer_text_color="?attr/conversation_item_sent_text_secondary_color" />
<org.thoughtcrime.securesms.components.ConversationItemFooter
android:id="@+id/conversation_item_sticker_footer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginStart="@dimen/message_bubble_horizontal_padding"
android:layout_marginTop="6dp"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:gravity="end"
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="end"
android:visibility="gone"
app:footer_text_color="?conversation_sticker_footer_text_color"
app:footer_icon_color="?conversation_sticker_footer_icon_color"/>
app:footer_icon_color="?conversation_sticker_footer_icon_color"
app:footer_text_color="?conversation_sticker_footer_text_color" />
</org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble>
@ -183,11 +182,47 @@
android:id="@+id/indicators_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="4dp"
android:layout_marginStart="8dp"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true" />
android:layout_alignParentBottom="true"
android:layout_marginStart="8dp"
android:orientation="vertical"
android:paddingBottom="4dp" />
<org.thoughtcrime.securesms.components.MaxHeightFrameLayout
android:id="@+id/reactions_bubbles_container"
android:layout_width="wrap_content"
android:layout_height="0dip"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:layout_marginEnd="@dimen/reactions_bubble_margin"
android:layout_toStartOf="@id/body_bubble"
android:visibility="gone"
app:mhfl_maxHeight="@dimen/reactions_bubble_container_max_height"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bubbles_secondary"
android:layout_width="@dimen/reactions_bubble_size"
android:layout_height="@dimen/reactions_bubble_size"
android:layout_gravity="bottom"
android:background="?attr/reactions_recv_background"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="@dimen/reactions_bubble_text_size"
tools:ignore="SpUsage" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bubbles_primary"
android:layout_width="@dimen/reactions_bubble_size"
android:layout_height="@dimen/reactions_bubble_size"
android:layout_gravity="top"
android:background="?attr/reactions_recv_background"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="@dimen/reactions_bubble_text_size"
tools:ignore="SpUsage" />
</org.thoughtcrime.securesms.components.MaxHeightFrameLayout>
</RelativeLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/action_mode_status_bar"
android:theme="@style/TextSecure.DarkActionBar.Conversation"
app:navigationIcon="@drawable/ic_x_conversation"
app:menu="@menu/conversation_reactions_long_press_menu">
</androidx.appcompat.widget.Toolbar>

View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.ConversationReactionOverlay
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_reaction_scrubber"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="1000dp"
android:visibility="gone"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.MaskView
android:id="@+id/conversation_reaction_mask"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="@color/transparent_black_20" />
<include
android:id="@+id/conversation_reaction_toolbar"
layout="@layout/conversation_reaction_long_press_toolbar" />
<View
android:id="@+id/conversation_reaction_scrubber_background"
android:layout_width="320dp"
android:layout_height="?attr/actionBarSize"
android:layout_marginTop="40dp"
android:layout_marginBottom="40dp"
android:alpha="0"
android:background="?reactions_overlay_scrubber_background"
android:elevation="4dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/conversation_reaction_scrubber_foreground"
android:layout_width="320dp"
android:layout_height="@dimen/conversation_reaction_scrubber_height"
android:clipToPadding="false"
android:elevation="4dp">
<View
android:id="@+id/conversation_reaction_current_selection_indicator"
android:layout_width="52dp"
android:layout_height="52dp"
android:alpha="0"
android:background="?attr/reactions_overlay_old_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="@id/reaction_3"
app:layout_constraintRight_toRightOf="@id/reaction_3"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_1"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_2"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_3"
app:layout_constraintStart_toEndOf="@id/reaction_1"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_3"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_4"
app:layout_constraintStart_toEndOf="@id/reaction_2"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_4"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_5"
app:layout_constraintStart_toEndOf="@id/reaction_3"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_5"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_6"
app:layout_constraintStart_toEndOf="@id/reaction_4"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_6"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_7"
app:layout_constraintStart_toEndOf="@id/reaction_5"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reaction_7"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:gravity="center"
android:singleLine="true"
android:textSize="@dimen/conversation_reaction_picker_emoji_text_size"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reaction_6"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</org.thoughtcrime.securesms.conversation.ConversationReactionOverlay>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:minHeight="340dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/reactions_bottom_view_emoji_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="6dp"
android:paddingEnd="6dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/reactions_bottom_sheet_dialog_fragment_emoji_item" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/reactions_bottom_view_recipient_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/reactions_bottom_sheet_dialog_fragment_recipient_item" />
</LinearLayout>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="62dp"
android:layout_height="36dp"
android:layout_marginStart="6dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="6dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:orientation="horizontal">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bottom_view_emoji_item_emoji"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="22dp"
android:textStyle="bold"
tools:ignore="SpUsage"
tools:text=":-)" />
<TextView
android:id="@+id/reactions_bottom_view_emoji_item_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="center"
android:includeFontPadding="true"
android:textColor="?title_text_color_primary"
android:textSize="14dp"
android:textStyle="bold"
tools:ignore="SpUsage"
tools:text="24" />
</LinearLayout>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/reactions_bottom_view_recipient_avatar"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/reactions_bottom_view_recipient_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:gravity="center_vertical"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?title_text_color_primary"
android:textSize="17sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/reactions_bottom_view_recipient_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -88,7 +88,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tint="?sticker_management_action_button_color"
app:srcCompat="@drawable/ic_forward_outline"
app:srcCompat="@drawable/ic_forward_outline_24"
app:layout_constraintTop_toTopOf="@id/sticker_management_share_button"
app:layout_constraintBottom_toBottomOf="@id/sticker_management_share_button"
app:layout_constraintStart_toStartOf="@id/sticker_management_share_button"

View File

@ -71,7 +71,7 @@
android:layout_height="wrap_content"
android:tint="@color/core_grey_90"
android:visibility="gone"
app:srcCompat="@drawable/ic_forward_outline"
app:srcCompat="@drawable/ic_forward_outline_24"
app:layout_constraintTop_toTopOf="@id/sticker_install_share_button"
app:layout_constraintBottom_toBottomOf="@id/sticker_install_share_button"
app:layout_constraintStart_toStartOf="@id/sticker_install_share_button"

View File

@ -15,10 +15,11 @@
android:icon="?menu_copy_icon"
app:showAsAction="always" />
<item android:title="@string/conversation_context__menu_forward_message"
android:id="@+id/menu_context_forward"
android:icon="?menu_forward_icon"
app:showAsAction="always" />
<item android:title="@string/conversation_context__menu_reply_to_message"
android:id="@+id/menu_context_reply"
android:visible="true"
android:icon="?menu_reply_icon"
app:showAsAction="always" />
<item android:title="@string/conversation_context__menu_resend_message"
android:id="@+id/menu_context_resend"
@ -31,10 +32,9 @@
android:icon="?menu_save_icon"
app:showAsAction="always" />
<item android:title="@string/conversation_context__menu_reply_to_message"
android:id="@+id/menu_context_reply"
android:visible="true"
android:icon="?menu_reply_icon"
app:showAsAction="always" />
<item android:title="@string/conversation_context__menu_forward_message"
android:id="@+id/menu_context_forward"
android:icon="?menu_forward_icon"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_info"
android:icon="?menu_info_icon"
android:title="@string/conversation_context__menu_message_details"
app:showAsAction="always" />
<item
android:id="@+id/action_delete"
android:icon="?menu_trash_icon"
android:title="@string/conversation_context__menu_delete_message"
app:showAsAction="always" />
<item
android:id="@+id/action_copy"
android:icon="?menu_copy_icon"
android:title="@string/conversation_context__menu_copy_text"
app:showAsAction="always" />
<item
android:id="@+id/action_reply"
android:icon="?menu_reply_icon"
android:title="@string/conversation_context__menu_reply_to_message"
app:showAsAction="always" />
<item
android:visible="false"
android:id="@+id/action_download"
android:icon="?menu_save_icon"
android:title="@string/conversation_context_image__save_attachment"
app:showAsAction="always" />
<item
android:id="@+id/action_multiselect"
android:icon="?menu_multi_select_icon"
android:title="@string/conversation_context__reaction_multi_select"
app:showAsAction="always" />
<item
android:id="@+id/action_forward"
android:icon="?menu_forward_icon"
android:title="@string/conversation_context__menu_forward_message"
app:showAsAction="ifRoom" />
</menu>

View File

@ -7,7 +7,7 @@
<item android:id="@+id/delete"
android:title="@string/delete"
android:icon="@drawable/ic_delete_white_24dp"
android:icon="?menu_trash_icon"
app:showAsAction="always"/>
<item android:id="@+id/select_all"

View File

@ -3,7 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/media_preview__forward"
android:title="@string/media_preview__forward_title"
android:icon="@drawable/ic_forward_white_24dp"
android:icon="?menu_forward_icon"
app:showAsAction="always"/>
<item android:id="@+id/save"
android:title="@string/media_preview__save_title"
@ -15,6 +15,6 @@
app:showAsAction="ifRoom"/>
<item android:id="@+id/delete"
android:title="@string/delete"
android:icon="@drawable/ic_delete_white_24dp"
android:icon="?menu_trash_icon"
app:showAsAction="ifRoom"/>
</menu>

View File

@ -92,6 +92,12 @@
<attr name="emoji_category_emoticons" format="reference"/>
<attr name="emoji_variation_selector_background" format="reference|color" />
<attr name="reactions_sent_background" format="reference" />
<attr name="reactions_recv_background" format="reference" />
<attr name="reactions_overlay_old_background" format="reference" />
<attr name="reactions_overlay_scrubber_background" format="reference" />
<attr name="reactions_bottom_dialog_fragment_emoji_selected" format="reference" />
<attr name="camera_button_style" />
<attr name="quick_camera_icon" format="reference"/>
@ -172,6 +178,7 @@
<attr name="menu_forward_icon" format="reference" />
<attr name="menu_save_icon" format="reference" />
<attr name="menu_reply_icon" format="reference" />
<attr name="menu_multi_select_icon" format="reference" />
<attr name="message_icon" format="reference" />
<attr name="notifications_icon" format="reference" />

View File

@ -16,8 +16,10 @@
<color name="transparent_black_20">#33000000</color>
<color name="transparent_black_40">#66000000</color>
<color name="transparent_black_60">#99000000</color>
<color name="transparent_black_80">#CC000000</color>
<color name="transparent_white_20">#33ffffff</color>
<color name="transparent_white_30">#4Dffffff</color>
<color name="transparent_white_60">#99ffffff</color>
<color name="transparent_white_80">#ccffffff</color>
<color name="transparent_white_90">#e6ffffff</color>

View File

@ -46,6 +46,12 @@
<dimen name="media_bubble_max_height">320dp</dimen>
<dimen name="media_bubble_sticker_dimens">128dp</dimen>
<dimen name="reactions_bubble_margin">-37dp</dimen>
<dimen name="reactions_bubble_size">32dp</dimen>
<dimen name="reactions_bubble_text_size">16dp</dimen>
<dimen name="reactions_bubble_margin_max">28dp</dimen>
<dimen name="reactions_bubble_container_max_height">60dp</dimen>
<dimen name="media_picker_folder_width">175dp</dimen>
<dimen name="media_picker_item_width">85dp</dimen>
<dimen name="media_picker_rail_padding_affordance">130dp</dimen>
@ -64,6 +70,11 @@
<dimen name="conversation_vertical_message_spacing_default">8dp</dimen>
<dimen name="conversation_vertical_message_spacing_collapse">1dp</dimen>
<dimen name="conversation_reaction_picker_emoji_text_size">28dp</dimen>
<dimen name="reaction_scrubber_anim_start_translation_y">25dp</dimen>
<dimen name="reaction_scrubber_anim_end_translation_y">0dp</dimen>
<dimen name="conversation_item_reply_size">20dp</dimen>
<dimen name="conversation_item_avatar_size">28dp</dimen>
@ -129,4 +140,12 @@
<dimen name="contact_selection_item_height">@dimen/selection_item_header_height</dimen>
<dimen name="conversation_reaction_scrubber_height">136dp</dimen>
<dimen name="conversation_reaction_scrubber_distance">40dp</dimen>
<dimen name="conversation_reaction_touch_deadzone_size">60dp</dimen>
<dimen name="conversation_reaction_scrub_deadzone_distance_from_touch_top">136dp</dimen>
<dimen name="conversation_reaction_scrub_deadzone_distance_from_touch_bottom">40dp</dimen>
<dimen name="conversation_reaction_scrub_vertical_translation">20dp</dimen>
<dimen name="conversation_reaction_scrub_horizontal_margin">16dp</dimen>
</resources>

View File

@ -1,3 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="emoji_heart" translatable="false">\u2764\ufe0f</string>
<string name="emoji_thumbs_up" translatable="false">\ud83d\udc4d</string>
<string name="emoji_thumbs_down" translatable="false">\ud83d\udc4e</string>
<string name="emoji_laugh" translatable="false">\ud83d\ude02</string>
<string name="emoji_surprise" translatable="false">\ud83d\ude2e</string>
<string name="emoji_sad" translatable="false">\ud83d\ude22</string>
<string name="emoji_angry" translatable="false">\ud83d\ude21</string>
</resources>

View File

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="play_button_animation_duration">300</integer>
<integer name="reaction_scrubber_reveal_duration">400</integer>
<integer name="reaction_scrubber_reveal_emoji_duration">380</integer>
<integer name="reaction_scrubber_emoji_reveal_duration_start_delay_factor">10</integer>
</resources>

View File

@ -894,6 +894,7 @@
<string name="MessageNotifier_open_signal_to_check_for_recent_notifications">Open Signal to check for recent notifications.</string>
<string name="MessageNotifier_contact_message">%1$s %2$s</string>
<string name="MessageNotifier_unknown_contact_message">Contact</string>
<string name="MessageNotifier_reacted_to_your_message">Reacted to your message: %1$s</string>
<!-- Notification Channels -->
<string name="NotificationChannel_messages">Default</string>
@ -1467,6 +1468,9 @@
<string name="conversation_context__menu_resend_message">Resend message</string>
<string name="conversation_context__menu_reply_to_message">Reply to message</string>
<!-- conversation_context_reacction -->
<string name="conversation_context__reaction_multi_select">Select multiple</string>
<!-- conversation_context_image -->
<string name="conversation_context_image__save_attachment">Save attachment</string>

View File

@ -397,4 +397,8 @@
<style name="Signal.MessageRequest.Button.Accept">
<item name="android:textColor">@color/core_blue</item>
</style>
<declare-styleable name="MaxHeightFrameLayout">
<attr name="mhfl_maxHeight" format="dimension" />
</declare-styleable>
</resources>

View File

@ -162,6 +162,8 @@
<item name="title_text_color_primary">@color/core_grey_90</item>
<item name="title_text_color_secondary">@color/core_grey_60</item>
<item name="bottomSheetDialogTheme">@style/Theme.Design.Light.BottomSheetDialog</item>
<item name="actionBarStyle">@style/TextSecure.LightActionBar</item>
<item name="actionBarTabBarStyle">@style/TextSecure.LightActionBar.TabBar</item>
<item name="actionModeBackground">@color/core_grey_50</item>
@ -257,6 +259,12 @@
<item name="emoji_category_emoticons">@drawable/ic_emoji_emoticon_light_20</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_light</item>
<item name="reactions_recv_background">@drawable/reactions_recv_background_light</item>
<item name="reactions_sent_background">@drawable/reactions_send_background_light</item>
<item name="reactions_overlay_old_background">@drawable/reactions_old_background_dark</item>
<item name="reactions_overlay_scrubber_background">@drawable/conversation_reaction_overlay_background_dark</item>
<item name="reactions_bottom_dialog_fragment_emoji_selected">@drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light</item>
<item name="conversation_item_bubble_background">@color/core_grey_05</item>
<item name="conversation_item_sent_text_primary_color">@color/core_grey_90</item>
<item name="conversation_item_sent_text_secondary_color">@color/core_grey_60</item>
@ -307,16 +315,17 @@
<item name="menu_unlock_icon">@drawable/ic_unlocked_white_24dp</item>
<item name="menu_lock_icon">@drawable/ic_lock_white_24dp</item>
<item name="menu_lock_icon_small">@drawable/ic_lock_white_18dp</item>
<item name="menu_trash_icon">@drawable/ic_delete_white_24dp</item>
<item name="menu_trash_icon">@drawable/ic_trash_outline_24</item>
<item name="menu_selectall_icon">@drawable/ic_select_all_white_24dp</item>
<item name="menu_split_icon">@drawable/ic_call_split_white_24dp</item>
<item name="menu_accept_icon">@drawable/ic_check_24</item>
<item name="menu_refresh_directory">@drawable/ic_refresh_white_24dp</item>
<item name="menu_copy_icon">@drawable/ic_content_copy_white_24dp</item>
<item name="menu_copy_icon">@drawable/ic_copy_outline_24</item>
<item name="menu_info_icon">@drawable/ic_info_outline_white_24dp</item>
<item name="menu_forward_icon">@drawable/ic_forward_white_24dp</item>
<item name="menu_save_icon">@drawable/ic_download_filled_white_24</item>
<item name="menu_reply_icon">@drawable/ic_reply_white_24dp</item>
<item name="menu_forward_icon">@drawable/ic_forward_outline_24</item>
<item name="menu_save_icon">@drawable/ic_save_24</item>
<item name="menu_reply_icon">@drawable/ic_reply_outline_24</item>
<item name="menu_multi_select_icon">@drawable/ic_select_24</item>
<item name="message_icon">@drawable/ic_message_outline_tinted_24</item>
<item name="notifications_icon">@drawable/ic_bell_outline_24</item>
@ -392,6 +401,8 @@
<item name="title_text_color_primary">@color/core_grey_05</item>
<item name="title_text_color_secondary">@color/core_grey_25</item>
<item name="bottomSheetDialogTheme">@style/Theme.Design.BottomSheetDialog</item>
<item name="actionBarStyle">@style/TextSecure.DarkActionBar</item>
<item name="actionBarTabBarStyle">@style/TextSecure.DarkActionBar.TabBar</item>
<item name="actionBarPopupTheme">@style/ThemeOverlay.AppCompat.Dark</item>
@ -500,6 +511,13 @@
<item name="conversation_title_color">@color/transparent_white_90</item>
<item name="conversation_subtitle_color">@color/transparent_white_80</item>
<item name="reactions_recv_background">@drawable/reactions_recv_background_dark</item>
<item name="reactions_sent_background">@drawable/reactions_send_background_dark</item>
<item name="reactions_overlay_old_background">@drawable/reactions_old_background_dark</item>
<item name="reactions_overlay_scrubber_background">@drawable/conversation_reaction_overlay_background_dark</item>
<item name="reactions_bottom_dialog_fragment_emoji_selected">@drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark</item>
<item name="emoji_tab_strip_background">@color/core_grey_85</item>
<item name="emoji_tab_indicator">@color/core_grey_65</item>
<item name="emoji_tab_underline">@color/core_grey_75</item>
@ -537,16 +555,17 @@
<item name="menu_unlock_icon">@drawable/ic_unlocked_white_24dp</item>
<item name="menu_lock_icon">@drawable/ic_lock_white_24dp</item>
<item name="menu_lock_icon_small">@drawable/ic_lock_white_18dp</item>
<item name="menu_trash_icon">@drawable/ic_delete_white_24dp</item>
<item name="menu_trash_icon">@drawable/ic_trash_solid_24</item>
<item name="menu_selectall_icon">@drawable/ic_select_all_white_24dp</item>
<item name="menu_split_icon">@drawable/ic_call_split_white_24dp</item>
<item name="menu_accept_icon">@drawable/ic_check_24</item>
<item name="menu_refresh_directory">@drawable/ic_refresh_white_24dp</item>
<item name="menu_copy_icon">@drawable/ic_content_copy_white_24dp</item>
<item name="menu_info_icon">@drawable/ic_info_outline_white_24dp</item>
<item name="menu_forward_icon">@drawable/ic_forward_white_24dp</item>
<item name="menu_save_icon">@drawable/ic_download_filled_white_24</item>
<item name="menu_reply_icon">@drawable/ic_reply_white_24dp</item>
<item name="menu_copy_icon">@drawable/ic_copy_solid_24</item>
<item name="menu_info_icon">@drawable/ic_info_solid_24</item>
<item name="menu_forward_icon">@drawable/ic_forward_solid_24</item>
<item name="menu_save_icon">@drawable/ic_save_24</item>
<item name="menu_reply_icon">@drawable/ic_reply_solid_24</item>
<item name="menu_multi_select_icon">@drawable/ic_select_24</item>
<item name="message_icon">@drawable/ic_message_solid_tinted_24</item>
<item name="notifications_icon">@drawable/ic_bell_solid_24</item>

View File

@ -7,6 +7,7 @@ import android.view.View;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -43,5 +44,6 @@ public interface BindableConversationItem extends Unbindable {
void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
void onReactionClicked(long messageId, boolean isMms);
}
}

View File

@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.util.ViewUtil;
public class MaskView extends View {
private View target;
private int[] targetLocation = new int[2];
private int statusBarHeight;
private Paint maskPaint;
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
public MaskView(@NonNull Context context) {
super(context);
}
public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) {
super(context, attributeSet);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
setLayerType(LAYER_TYPE_HARDWARE, maskPaint);
maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
statusBarHeight = ViewUtil.getStatusBarHeight(this);
}
public void setTarget(@Nullable View target) {
if (this.target != null) {
this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener);
}
this.target = target;
if (this.target != null) {
this.target.getViewTreeObserver().addOnDrawListener(onDrawListener);
}
invalidate();
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
if (target == null) {
return;
}
target.getLocationInWindow(targetLocation);
Bitmap mask = Bitmap.createBitmap(target.getWidth(), target.getHeight(), Bitmap.Config.ARGB_8888);
Canvas maskCanvas = new Canvas(mask);
target.draw(maskCanvas);
canvas.drawBitmap(mask, 0, targetLocation[1] - statusBarHeight, maskPaint);
mask.recycle();
}
}

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.thoughtcrime.securesms.R;
public class MaxHeightFrameLayout extends FrameLayout {
private final int maxHeight;
public MaxHeightFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0);
a.recycle();
} else {
maxHeight = 0;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight));
}
}

View File

@ -49,6 +49,7 @@ import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnFocusChangeListener;
@ -145,6 +146,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
@ -311,6 +313,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private ConversationSearchBottomBar searchNav;
private MenuItem searchViewItem;
private FrameLayout messageRequestOverlay;
private ConversationReactionOverlay reactionOverlay;
private AttachmentTypeSelector attachmentTypeSelector;
private AttachmentManager attachmentManager;
@ -524,6 +527,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
super.onDestroy();
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
return reactionOverlay.applyTouchEvent(ev) || super.dispatchTouchEvent(ev);
}
@Override
public void onActivityResult(final int reqCode, int resultCode, Intent data) {
Log.i(TAG, "onActivityResult called: " + reqCode + ", " + resultCode + " , " + data);
@ -1581,6 +1589,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestOverlay = ViewUtil.findById(this, R.id.fragment_overlay_container);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@ -1636,6 +1645,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
searchNav.setEventListener(this);
inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment());
reactionOverlay.setOnReactionSelectedListener(this::onReactionSelected);
}
protected void initializeActionBar() {
@ -1748,6 +1759,24 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
.show(TooltipPopup.POSITION_ABOVE);
}
private void onReactionSelected(MessageRecord messageRecord, String emoji) {
final Context context = getApplicationContext();
SignalExecutors.BOUNDED.execute(() -> {
ReactionRecord oldRecord = Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
.findFirst()
.orElse(null);
if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) {
MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord);
} else {
MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji);
}
});
}
@Override
public void onSearchMoveUpPressed() {
searchViewModel.onMoveUp();
@ -2753,6 +2782,33 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
public void handleReaction(@NonNull View maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener)
{
reactionOverlay.setOnToolbarItemClickedListener(toolbarListener);
reactionOverlay.setOnHideListener(onHideListener);
reactionOverlay.show(this, maskTarget, messageRecord);
}
@Override
public void onCursorChanged() {
if (!reactionOverlay.isShowing()) {
return;
}
SimpleTask.run(() -> {
//noinspection CodeBlock2Expr
return DatabaseFactory.getMmsSmsDatabase(this)
.checkMessageExists(reactionOverlay.getMessageRecord());
}, messageExists -> {
if (!messageExists) {
reactionOverlay.hide();
}
});
}
@Override
public void setThreadId(long threadId) {
this.threadId = threadId;

View File

@ -139,7 +139,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(MessageRecord item);
void onItemLongClick(MessageRecord item);
void onItemLongClick(View maskTarget, MessageRecord item);
}
@SuppressWarnings("ConstantConditions")
@ -224,7 +224,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
});
itemView.setOnLongClickListener(view -> {
if (clickListener != null) {
clickListener.onItemLongClick(itemView.getMessageRecord());
clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
}
return true;
});

View File

@ -26,19 +26,6 @@ import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ActivityOptionsCompat;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import android.text.ClipboardManager;
import android.text.TextUtils;
import android.view.LayoutInflater;
@ -54,7 +41,23 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ViewSwitcher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ActivityOptionsCompat;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import com.annimon.stream.Stream;
import com.google.android.collect.Sets;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.MessageDetailsActivity;
@ -91,10 +94,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.profiles.UnknownSenderView;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity;
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
import org.thoughtcrime.securesms.sms.MessageSender;
@ -146,6 +149,7 @@ public class ConversationFragment extends Fragment
private int previousOffset;
private int activeOffset;
private boolean firstLoad;
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private RecyclerView list;
@ -326,7 +330,9 @@ public class ConversationFragment extends Fragment
if (!isTypingIndicatorShowing() && isAtBottom()) {
Context context = requireContext();
list.setVerticalScrollBarEnabled(false);
list.post(() -> getListLayoutManager().smoothScrollToPosition(context, 0, 250));
list.post(() -> { if (!isReacting) {
getListLayoutManager().smoothScrollToPosition(context, 0, 250);
}});
list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300);
adapter.setHeaderView(typingView);
adapter.notifyItemInserted(0);
@ -341,7 +347,7 @@ public class ConversationFragment extends Fragment
}
} else {
if (getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0 && getListLayoutManager().getItemCount() > 1 && !replacedByIncomingMessage) {
getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250);
if (!isReacting) getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250);
list.setVerticalScrollBarEnabled(false);
list.postDelayed(() -> {
adapter.setHeaderView(null);
@ -707,8 +713,8 @@ public class ConversationFragment extends Fragment
}
activeOffset = loader.getOffset();
adapter.changeCursor(cursor);
listener.onCursorChanged();
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
@ -749,6 +755,7 @@ public class ConversationFragment extends Fragment
public void onLoaderReset(@NonNull Loader<Cursor> arg0) {
if (list.getAdapter() != null) {
getListAdapter().changeCursor(null);
listener.onCursorChanged();
}
}
@ -866,6 +873,11 @@ public class ConversationFragment extends Fragment
void onMessageActionToolbarOpened();
void onForwardClicked();
void onMessageRequest();
void handleReaction(@NonNull View maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
}
private class ConversationScrollListener extends OnScrollListener {
@ -955,8 +967,21 @@ public class ConversationFragment extends Fragment
}
@Override
public void onItemLongClick(MessageRecord messageRecord) {
if (actionMode == null) {
public void onItemLongClick(View maskTarget, MessageRecord messageRecord) {
if (actionMode != null) return;
if (FeatureFlags.REACTION_SENDING &&
messageRecord.isSecure() &&
((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty())
{
isReacting = true;
list.setLayoutFrozen(true);
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(messageRecord), () -> {
isReacting = false;
list.setLayoutFrozen(false);
});
} else {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
list.getAdapter().notifyDataSetChanged();
@ -1094,6 +1119,13 @@ public class ConversationFragment extends Fragment
CommunicationActions.composeSmsThroughDefaultApp(getContext(), recipient, getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url)));
});
}
@Override
public void onReactionClicked(long messageId, boolean isMms) {
if (getContext() == null) return;
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
}
}
@Override
@ -1105,6 +1137,36 @@ public class ConversationFragment extends Fragment
}
}
private void handleEnterMultiSelect(@NonNull MessageRecord messageRecord) {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
list.getAdapter().notifyDataSetChanged();
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
}
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
private final MessageRecord messageRecord;
private ReactionsToolbarListener(@NonNull MessageRecord messageRecord) {
this.messageRecord = messageRecord;
}
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_info: handleDisplayDetails(messageRecord); return true;
case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(messageRecord)); return true;
case R.id.action_copy: handleCopyMessage(Sets.newHashSet(messageRecord)); return true;
case R.id.action_reply: handleReplyMessage(messageRecord); return true;
case R.id.action_multiselect: handleEnterMultiSelect(messageRecord); return true;
case R.id.action_forward: handleForwardMessage(messageRecord); return true;
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) messageRecord); return true;
default: return false;
}
}
}
private class ActionModeCallback implements ActionMode.Callback {
private int statusBarColor;

View File

@ -26,11 +26,6 @@ import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.net.Uri;
import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@ -45,15 +40,21 @@ import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.view.ViewCompat;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.ConfirmIdentityDialog;
import org.thoughtcrime.securesms.MediaPreviewActivity;
@ -159,6 +160,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private AvatarImageView contactPhoto;
private AlertView alertView;
private ViewGroup container;
protected ViewGroup reactionsContainer;
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@ -169,8 +171,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<StickerView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private Stub<ViewOnceMessageView> revealableStub;
private @Nullable EventListener eventListener;
private ConversationItemReactionBubbles conversationItemReactionBubbles;
private int defaultBubbleColor;
private int measureCalls;
@ -225,6 +228,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
this.reply = findViewById(R.id.reply_icon);
this.reactionsContainer = findViewById(R.id.reactions_bubbles_container);
this.conversationItemReactionBubbles = new ConversationItemReactionBubbles(this.reactionsContainer);
setOnClickListener(new ClickListener(null));
@ -274,6 +280,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setReactions(messageRecord);
setFooter(messageRecord, nextMessageRecord, locale, groupThread);
}
@ -369,7 +376,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
@Override
public void unbind() {
if (recipient != null) {
recipient.removeForeverObserver(this);;
recipient.removeForeverObserver(this);
}
}
@ -900,6 +907,15 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
private void setReactions(@NonNull MessageRecord current) {
conversationItemReactionBubbles.setReactions(current.getReactions());
reactionsContainer.setOnClickListener(v -> {
if (eventListener == null) return;
eventListener.onReactionClicked(current.getId(), current.isMms());
});
}
private void setFooter(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, @NonNull Locale locale, boolean isGroupThread) {
ViewUtil.updateLayoutParams(footer, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

View File

@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.conversation;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pair;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
final class ConversationItemReactionBubbles {
private final ViewGroup reactionsContainer;
private final EmojiTextView primaryEmojiReaction;
private final EmojiTextView secondaryEmojiReaction;
ConversationItemReactionBubbles(@NonNull ViewGroup reactionsContainer) {
this.reactionsContainer = reactionsContainer;
this.primaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_primary);
this.secondaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_secondary);
}
void setReactions(@NonNull List<ReactionRecord> reactions) {
if (reactions.size() == 0) {
hideAllReactions();
return;
}
final Collection<ReactionInfo> reactionInfos = getReactionInfos(reactions);
if (reactionInfos.size() == 1) {
displaySingleReaction(reactionInfos.iterator().next());
} else {
displayMultipleReactions(reactionInfos);
}
}
private static @NonNull Collection<ReactionInfo> getReactionInfos(@NonNull List<ReactionRecord> reactions) {
final Map<String, ReactionInfo> counters = new HashMap<>();
for (ReactionRecord reaction : reactions) {
ReactionInfo info = counters.get(reaction.getEmoji());
if (info == null) {
info = new ReactionInfo(reaction.getEmoji(),
1,
reaction.getDateReceived(),
Recipient.self().getId().equals(reaction.getAuthor()));
} else {
info = new ReactionInfo(reaction.getEmoji(),
info.count + 1,
Math.max(info.lastSeen, reaction.getDateReceived()),
info.userWasSender || Recipient.self().getId().equals(reaction.getAuthor()));
}
counters.put(reaction.getEmoji(), info);
}
return counters.values();
}
private void hideAllReactions() {
reactionsContainer.setVisibility(View.GONE);
}
private void displaySingleReaction(@NonNull ReactionInfo reactionInfo) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.GONE);
primaryEmojiReaction.setText(reactionInfo.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(reactionInfo));
}
private void displayMultipleReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.VISIBLE);
Pair<ReactionInfo, ReactionInfo> primaryAndSecondaryReactions = getPrimaryAndSecondaryReactions(reactionInfos);
primaryEmojiReaction.setText(primaryAndSecondaryReactions.first.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.first));
secondaryEmojiReaction.setText(primaryAndSecondaryReactions.second.emoji);
secondaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.second));
}
private Drawable getBackgroundDrawableForReactionBubble(@NonNull ReactionInfo reactionInfo) {
return ThemeUtil.getThemedDrawable(reactionsContainer.getContext(),
reactionInfo.userWasSender ? R.attr.reactions_sent_background : R.attr.reactions_recv_background);
}
private Pair<ReactionInfo, ReactionInfo> getPrimaryAndSecondaryReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
ReactionInfo mostPopular = null;
ReactionInfo latestReaction = null;
ReactionInfo secondLatestReaction = null;
ReactionInfo ourReaction = null;
for (ReactionInfo current : reactionInfos) {
if (current.userWasSender) {
ourReaction = current;
}
if (mostPopular == null) {
mostPopular = current;
} else if (mostPopular.count < current.count) {
mostPopular = current;
}
if (latestReaction == null) {
latestReaction = current;
} else if (latestReaction.lastSeen < current.lastSeen) {
if (current.count == mostPopular.count) {
mostPopular = current;
}
secondLatestReaction = latestReaction;
latestReaction = current;
} else if (secondLatestReaction == null) {
secondLatestReaction = current;
}
}
if (mostPopular == null) {
throw new AssertionError("getPrimaryAndSecondaryReactions was called with an empty list.");
}
if (ourReaction != null && !mostPopular.equals(ourReaction)) {
return Pair.create(mostPopular, ourReaction);
} else {
return Pair.create(mostPopular, mostPopular.equals(latestReaction) ? secondLatestReaction : latestReaction);
}
}
private static class ReactionInfo {
private final String emoji;
private final int count;
private final long lastSeen;
private final boolean userWasSender;
private ReactionInfo(@NonNull String emoji, int count, long lastSeen, boolean userWasSender) {
this.emoji = emoji;
this.count = count;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
@Override
public int hashCode() {
return Objects.hash(emoji, count, lastSeen, userWasSender);
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof ReactionInfo)) return false;
ReactionInfo other = (ReactionInfo) obj;
return other.emoji.equals(emoji) &&
other.count == count &&
other.lastSeen == lastSeen &&
other.userWasSender == userWasSender;
}
}
}

View File

@ -0,0 +1,551 @@
package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.app.Activity;
import android.content.Context;
import android.graphics.PointF;
import android.graphics.Rect;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.RelativeLayout;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
import androidx.core.util.Preconditions;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
public final class ConversationReactionOverlay extends RelativeLayout {
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiStripViewBounds = new Rect();
private float segmentSize;
private final Boundary horizontalEmojiBoundary = new Boundary();
private final Boundary verticalScrubBoundary = new Boundary();
private final PointF deadzoneTouchPoint = new PointF();
private final PointF lastSeenDownPoint = new PointF();
private Activity activity;
private MessageRecord messageRecord;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean downIsOurs;
private boolean isToolbarTouch;
private int selected = -1;
private int originalStatusBarColor;
private View backgroundView;
private ConstraintLayout foregroundView;
private View selectedView;
private View[] emojiViews;
private MaskView maskView;
private Toolbar toolbar;
private int statusBarHeight;
private float touchDownDeadZoneSize;
private float distanceFromTouchDownPointToTopOfScrubberDeadZone;
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
private int scrubberDistanceFromTouchDown;
private int scrubberHeight;
private int halfActionBarHeight;
private int selectedVerticalTranslation;
private int scrubberHorizontalMargin;
private int animationEmojiStartDelayFactor;
private OnReactionSelectedListener onReactionSelectedListener;
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
}
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
maskView = findViewById(R.id.conversation_reaction_mask);
toolbar = findViewById(R.id.conversation_reaction_toolbar);
toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked);
toolbar.setNavigationOnClickListener(view -> hide());
emojiViews = Stream.of(ReactionEmoji.values())
.map(e -> {
EmojiTextView view = findViewById(e.viewId);
view.setText(e.emoji);
return view;
}).toArray(View[]::new);
distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top);
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
statusBarHeight = ViewUtil.getStatusBarHeight(this);
scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance);
scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height);
halfActionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize) / 2;
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
initAnimators();
}
public void show(@NonNull Activity activity, @NonNull View maskTarget, @NonNull MessageRecord messageRecord) {
if (overlayState != OverlayState.HIDDEN) {
return;
}
this.messageRecord = messageRecord;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
setupToolbarMenuItems();
setupSelectedEmojiBackground();
final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + halfActionBarHeight,
lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown);
final float halfWidth = foregroundView.getWidth() / 2f + scrubberHorizontalMargin;
final float screenWidth = getResources().getDisplayMetrics().widthPixels;
final float scrubberTranslationX = Util.clamp(lastSeenDownPoint.x - halfWidth,
scrubberHorizontalMargin,
screenWidth + scrubberHorizontalMargin - halfWidth * 2);
backgroundView.setTranslationX(scrubberTranslationX);
backgroundView.setTranslationY(scrubberTranslationY);
foregroundView.setTranslationX(scrubberTranslationX);
foregroundView.setTranslationY(scrubberTranslationY);
verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
maskView.setTarget(maskTarget);
hideAnimatorSet.end();
setVisibility(View.VISIBLE);
revealAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21) {
this.activity = activity;
originalStatusBarColor = activity.getWindow().getStatusBarColor();
activity.getWindow().setStatusBarColor(ContextCompat.getColor(activity, R.color.core_grey_45));
}
}
public void hide() {
maskView.setTarget(null);
overlayState = OverlayState.HIDDEN;
revealAnimatorSet.end();
hideAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
activity.getWindow().setStatusBarColor(originalStatusBarColor);
activity = null;
}
if (onHideListener != null) {
onHideListener.onHide();
}
}
public boolean isShowing() {
return overlayState != OverlayState.HIDDEN;
}
public @NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
if (!isShowing()) {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
lastSeenDownPoint.set(motionEvent.getRawX(), motionEvent.getRawY());
}
return false;
}
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
return true;
}
if (overlayState == OverlayState.UNINITAILIZED) {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.left = emojiViewGlobalRect.left;
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.right = emojiViewGlobalRect.right;
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
downIsOurs = false;
deadzoneTouchPoint.set(motionEvent.getRawX(), motionEvent.getRawY());
overlayState = OverlayState.DEADZONE;
}
if (overlayState == OverlayState.DEADZONE) {
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getRawX());
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getRawY());
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
overlayState = OverlayState.SCRUB;
} else {
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
overlayState = OverlayState.TAP;
if (downIsOurs) {
handleUpEvent();
return true;
}
}
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
}
}
if (isToolbarTouch) {
if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) {
isToolbarTouch = false;
}
return false;
}
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
selected = getSelectedIndexViaDownEvent(motionEvent);
if (selected == -1) {
if (motionEvent.getRawY() < toolbar.getHeight() + statusBarHeight) {
isToolbarTouch = true;
return false;
}
}
deadzoneTouchPoint.set(motionEvent.getRawX(), motionEvent.getRawY());
overlayState = OverlayState.DEADZONE;
downIsOurs = true;
return true;
case MotionEvent.ACTION_MOVE:
selected = getSelectedIndexViaMoveEvent(motionEvent);
return true;
case MotionEvent.ACTION_UP:
handleUpEvent();
return downIsOurs;
case MotionEvent.ACTION_CANCEL:
hide();
return downIsOurs;
default:
return false;
}
}
private void setupSelectedEmojiBackground() {
final String oldEmoji = getOldEmoji(messageRecord);
if (oldEmoji == null) {
selectedView.setVisibility(View.GONE);
}
for (int i = 0; i < emojiViews.length; i++) {
final View view = emojiViews[i];
view.setScaleX(1.0f);
view.setScaleY(1.0f);
view.setTranslationY(0);
if (ReactionEmoji.values()[i].emoji.equals(oldEmoji)) {
selectedView.setVisibility(View.VISIBLE);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(foregroundView);
constraintSet.clear(selectedView.getId(), ConstraintSet.LEFT);
constraintSet.clear(selectedView.getId(), ConstraintSet.RIGHT);
constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT);
constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT);
constraintSet.applyTo(foregroundView);
}
}
}
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
}
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
}
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
int selected = -1;
for (int i = 0; i < emojiViews.length; i++) {
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
if (horizontalEmojiBoundary.contains(motionEvent.getRawX()) && boundary.contains(motionEvent.getRawY())) {
selected = i;
}
}
if (this.selected != -1 && this.selected != selected) {
shrinkView(emojiViews[this.selected]);
}
if (this.selected != selected && selected != -1) {
growView(emojiViews[selected]);
}
return selected;
}
private void growView(@NonNull View view) {
view.animate()
.scaleY(1.5f)
.scaleX(1.5f)
.translationY(-selectedVerticalTranslation)
.setDuration(400)
.setInterpolator(INTERPOLATOR)
.start();
}
private void shrinkView(@NonNull View view) {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationY(0)
.setDuration(400)
.setInterpolator(INTERPOLATOR)
.start();
}
private void handleUpEvent() {
hide();
if (selected != -1 && onReactionSelectedListener != null) {
onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji);
}
}
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener;
}
public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
}
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
this.onHideListener = onHideListener;
}
private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
return Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor()
.serialize()
.equals(Recipient.self()
.getId()
.serialize()))
.findFirst()
.map(ReactionRecord::getEmoji)
.orElse(null);
}
private void setupToolbarMenuItems() {
toolbar.getMenu().findItem(R.id.action_copy).setVisible(shouldShowCopy());
toolbar.getMenu().findItem(R.id.action_download).setVisible(shouldShowDownload());
toolbar.getMenu().findItem(R.id.action_forward).setVisible(shouldShowForward());
}
private boolean shouldShowCopy() {
return !MessageRecordUtil.hasSticker(messageRecord) &&
!MessageRecordUtil.isMediaMessage(messageRecord) &&
!MessageRecordUtil.hasSharedContact(messageRecord);
}
private boolean shouldShowDownload() {
return MessageRecordUtil.isMediaMessage(messageRecord) ||
MessageRecordUtil.hasLocation(messageRecord);
}
private boolean shouldShowForward() {
return !MessageRecordUtil.hasSharedContact(messageRecord);
}
private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) {
hide();
if (onToolbarItemClickedListener == null) {
return false;
}
return onToolbarItemClickedListener.onMenuItemClick(menuItem);
}
private void initAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
List<Animator> reveals = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator overlayRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
overlayRevealAnim.setTarget(maskView);
overlayRevealAnim.setDuration(duration);
reveals.add(overlayRevealAnim);
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
backgroundRevealAnim.setTarget(backgroundView);
backgroundRevealAnim.setDuration(duration);
reveals.add(backgroundRevealAnim);
Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
selectedRevealAnim.setTarget(selectedView);
selectedRevealAnim.setDuration(duration);
reveals.add(selectedRevealAnim);
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
List<Animator> hides = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
overlayHideAnim.setTarget(maskView);
overlayHideAnim.setDuration(duration);
hides.add(overlayHideAnim);
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
hides.add(backgroundHideAnim);
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
selectedHideAnim.setTarget(selectedView);
selectedHideAnim.setDuration(duration);
hides.add(selectedHideAnim);
hideAnimatorSet.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.INVISIBLE);
}
});
hideAnimatorSet.setInterpolator(INTERPOLATOR);
hideAnimatorSet.playTogether(hides);
}
public interface OnHideListener {
void onHide();
}
public interface OnReactionSelectedListener {
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
}
private static class Boundary {
private float min;
private float max;
Boundary() {}
Boundary(float min, float max) {
update(min, max);
}
private void update(float min, float max) {
Preconditions.checkArgument(min < max, "Min must be less than max");
this.min = min;
this.max = max;
}
public boolean contains(float value) {
return this.min < value && this.max > value;
}
}
private enum ReactionEmoji {
HEART(R.id.reaction_1, "\u2764\ufe0f"),
THUMBS_UP(R.id.reaction_2, "\ud83d\udc4d"),
THUMBS_DOWN(R.id.reaction_3, "\ud83d\udc4e"),
LAUGH(R.id.reaction_4, "\ud83d\ude02"),
SURPRISE(R.id.reaction_5, "\ud83d\ude2e"),
SAD(R.id.reaction_6, "\ud83d\ude22"),
ANGRY(R.id.reaction_7, "\ud83d\ude21");
final @IdRes int viewId;
final String emoji;
ReactionEmoji(int viewId, String emoji) {
this.viewId = viewId;
this.emoji = emoji;
}
}
private enum OverlayState {
HIDDEN,
UNINITAILIZED,
DEADZONE,
SCRUB,
TAP
}
}

View File

@ -33,6 +33,7 @@ final class ConversationSwipeAnimationHelper {
float progress = dx / TRIGGER_DX;
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
updateReactionsTransition(conversationItem.reactionsContainer, dx, sign);
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
}
@ -45,6 +46,10 @@ final class ConversationSwipeAnimationHelper {
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));

View File

@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@ -339,23 +340,9 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
}
private void initializeProfileIcon(@NonNull Recipient recipient) {
ImageView icon = requireView().findViewById(R.id.toolbar_icon);
String name = Optional.fromNullable(recipient.getDisplayName(requireContext())).or(Optional.fromNullable(TextSecurePreferences.getProfileName(requireContext()))).or("");
MaterialColor fallbackColor = recipient.getColor();
if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) {
fallbackColor = ContactColors.generateFor(name);
}
Drawable fallback = new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(requireContext(), fallbackColor.toAvatarColor(requireContext()));
GlideApp.with(this)
.load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(requireContext()))))
.error(fallback)
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(icon);
ImageView icon = requireView().findViewById(R.id.toolbar_icon);
AvatarUtil.loadIconIntoImageView(recipient, icon);
icon.setOnClickListener(v -> getNavigator().goToAppSettings());
}

View File

@ -22,6 +22,7 @@ import android.database.DataSetObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import android.view.View;

View File

@ -7,23 +7,29 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import com.google.protobuf.InvalidProtocolBufferException;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.documents.Document;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.DatabaseProtos.ReactionList;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.insights.InsightsConstants;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
public abstract class MessagingDatabase extends Database implements MmsSmsColumns {
@ -90,6 +96,91 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
}
public void setReactionsSeen(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
String whereClause = THREAD_ID + " = ?";
String[] whereArgs = new String[]{String.valueOf(threadId)};
values.put(REACTIONS_UNREAD, 0);
values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis());
db.update(getTableName(), values, whereClause, whereArgs);
}
public void setAllReactionsSeen() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(REACTIONS_UNREAD, 0);
values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis());
db.update(getTableName(), values, null, null);
}
public void addReaction(long messageId, @NonNull ReactionRecord reaction) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList.Reaction newReaction = ReactionList.Reaction.newBuilder()
.setEmoji(reaction.getEmoji())
.setAuthor(reaction.getAuthor().toLong())
.setSentTime(reaction.getDateSent())
.setReceivedTime(reaction.getDateReceived())
.build();
ReactionList updatedList = pruneByAuthor(reactions, reaction.getAuthor()).toBuilder()
.addReactions(newReaction)
.build();
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public void deleteReaction(long messageId, @NonNull RecipientId author) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList updatedList = pruneByAuthor(reactions, author);
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public boolean hasReaction(long messageId, @NonNull ReactionRecord reactionRecord) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
for (ReactionList.Reaction reaction : reactions.getReactionsList()) {
if (reactionRecord.getAuthor().toLong() == reaction.getAuthor() &&
reactionRecord.getEmoji().equals(reaction.getEmoji()))
{
return true;
}
}
return false;
}
public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,
@ -110,6 +201,28 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
}
protected List<ReactionRecord> parseReactions(@NonNull Cursor cursor) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
if (raw != null) {
try {
return Stream.of(ReactionList.parseFrom(raw).getReactionsList())
.map(r -> {
return new ReactionRecord(r.getEmoji(),
RecipientId.from(r.getAuthor()),
r.getSentTime(),
r.getReceivedTime());
})
.toList();
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[parseReactions] Failed to parse reaction list!", e);
return Collections.emptyList();
}
} else {
return Collections.emptyList();
}
}
protected <D extends Document<I>, I> void removeFromDocument(long messageId, String column, I object, Class<D> clazz) throws IOException {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction();
@ -205,6 +318,62 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
}
private static @NonNull ReactionList pruneByAuthor(@NonNull ReactionList reactionList, @NonNull RecipientId recipientId) {
List<ReactionList.Reaction> pruned = Stream.of(reactionList.getReactionsList())
.filterNot(r -> r.getAuthor() == recipientId.toLong())
.toList();
return reactionList.toBuilder()
.clearReactions()
.addAllReactions(pruned)
.build();
}
private @NonNull Optional<ReactionList> getReactions(SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ REACTIONS };
String query = ID + " = ?";
String[] args = new String[]{String.valueOf(messageId)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
if (raw != null) {
return Optional.of(ReactionList.parseFrom(raw));
}
}
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[getRecipients] Failed to parse reaction list!", e);
}
return Optional.absent();
}
private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) {
ContentValues values = new ContentValues(1);
values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray());
values.put(REACTIONS_UNREAD, reactionList.getReactionsCount() != 0 ? 1 : 0);
String query = ID + " = ?";
String[] args = new String[] { String.valueOf(messageId) };
db.update(getTableName(), values, query, args);
}
private long getThreadId(@NonNull SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ THREAD_ID };
String query = ID + " = ?";
String[] args = new String[]{ String.valueOf(messageId) };
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID));
}
}
return -1;
}
public static class SyncMessageId {
private final RecipientId recipientId;

View File

@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
@ -112,26 +113,60 @@ public class MmsDatabase extends MessagingDatabase {
public static final String VIEW_ONCE = "reveal_duration";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " +
READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " +
"sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " +
"ct_t" + " TEXT, " + CONTENT_LOCATION + " TEXT, " + RECIPIENT_ID + " INTEGER, " +
ADDRESS_DEVICE_ID + " INTEGER, " +
EXPIRY + " INTEGER, " + "m_cls" + " TEXT, " + MESSAGE_TYPE + " INTEGER, " +
"v" + " INTEGER, " + MESSAGE_SIZE + " INTEGER, " + "pri" + " INTEGER, " +
"rr" + " INTEGER, " + "rpt_a" + " INTEGER, " + "resp_st" + " INTEGER, " +
STATUS + " INTEGER, " + TRANSACTION_ID + " TEXT, " + "retr_st" + " INTEGER, " +
"retr_txt" + " TEXT, " + "retr_txt_cs" + " INTEGER, " + "read_status" + " INTEGER, " +
"ct_cls" + " INTEGER, " + "resp_txt" + " TEXT, " + "d_tm" + " INTEGER, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " +
NETWORK_FAILURE + " TEXT DEFAULT NULL," + "d_rpt" + " INTEGER, " +
SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " +
QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " +
QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " +
LINK_PREVIEWS + " TEXT, " + VIEW_ONCE + " INTEGER DEFAULT 0);";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
THREAD_ID + " INTEGER, " +
DATE_SENT + " INTEGER, " +
DATE_RECEIVED + " INTEGER, " +
MESSAGE_BOX + " INTEGER, " +
READ + " INTEGER DEFAULT 0, " +
"m_id" + " TEXT, " +
"sub" + " TEXT, " +
"sub_cs" + " INTEGER, " +
BODY + " TEXT, " +
PART_COUNT + " INTEGER, " +
"ct_t" + " TEXT, " +
CONTENT_LOCATION + " TEXT, " +
RECIPIENT_ID + " INTEGER, " +
ADDRESS_DEVICE_ID + " INTEGER, " +
EXPIRY + " INTEGER, " +
"m_cls" + " TEXT, " +
MESSAGE_TYPE + " INTEGER, " +
"v" + " INTEGER, " +
MESSAGE_SIZE + " INTEGER, " +
"pri" + " INTEGER, " +
"rr" + " INTEGER, " +
"rpt_a" + " INTEGER, " +
"resp_st" + " INTEGER, " +
STATUS + " INTEGER, " +
TRANSACTION_ID + " TEXT, " +
"retr_st" + " INTEGER, " +
"retr_txt" + " TEXT, " +
"retr_txt_cs" + " INTEGER, " +
"read_status" + " INTEGER, " +
"ct_cls" + " INTEGER, " +
"resp_txt" + " TEXT, " +
"d_tm" + " INTEGER, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " +
NETWORK_FAILURE + " TEXT DEFAULT NULL," +
"d_rpt" + " INTEGER, " +
SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
EXPIRES_IN + " INTEGER DEFAULT 0, " +
EXPIRE_STARTED + " INTEGER DEFAULT 0, " +
NOTIFIED + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
QUOTE_ID + " INTEGER DEFAULT 0, " +
QUOTE_AUTHOR + " TEXT, " +
QUOTE_BODY + " TEXT, " +
QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " +
QUOTE_MISSING + " INTEGER DEFAULT 0, " +
SHARED_CONTACTS + " TEXT, " +
UNIDENTIFIED + " INTEGER DEFAULT 0, " +
LINK_PREVIEWS + " TEXT, " +
VIEW_ONCE + " INTEGER DEFAULT 0, " +
REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
@ -152,7 +187,7 @@ public class MmsDatabase extends MessagingDatabase {
BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID,
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID,
EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING,
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE,
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
"json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
@ -334,6 +369,18 @@ public class MmsDatabase extends MessagingDatabase {
return cursor;
}
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) {
MessageRecord record = new Reader(cursor).getNext();
if (record == null) {
throw new NoSuchMessageException("No message for ID: " + messageId);
}
return record;
}
}
public Reader getExpireStartedMessages() {
String where = EXPIRE_STARTED + " > 0";
return readerFor(rawQuery(where, null));
@ -1414,7 +1461,7 @@ public class MmsDatabase extends MessagingDatabase {
message.getOutgoingQuote().isOriginalMissing(),
new SlideDeck(context, message.getOutgoingQuote().getAttachments())) :
null,
message.getSharedContacts(), message.getLinkPreviews(), false);
message.getSharedContacts(), message.getLinkPreviews(), false, Collections.emptyList());
}
}
@ -1486,24 +1533,25 @@ public class MmsDatabase extends MessagingDatabase {
}
private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT));
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED));
long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID));
int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT));
String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY));
int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT));
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES));
String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE));
int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN));
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED));
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1;
boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1;
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID ));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT ));
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED));
long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX ));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID ));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID ));
int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID ));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT ));
String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY ));
int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT ));
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES));
String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE ));
int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID ));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN ));
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED ));
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1;
boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1;
List<ReactionRecord> reactions = parseReactions(cursor);
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@ -1524,7 +1572,7 @@ public class MmsDatabase extends MessagingDatabase {
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified);
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions);
}
private List<IdentityKeyMismatch> getMismatchedIdentities(String document) {

View File

@ -20,6 +20,9 @@ public interface MmsSmsColumns {
public static final String EXPIRE_STARTED = "expire_started";
public static final String NOTIFIED = "notified";
public static final String UNIDENTIFIED = "unidentified";
public static final String REACTIONS = "reactions";
public static final String REACTIONS_UNREAD = "reactions_unread";
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
public static class Types {
protected static final long TOTAL_MASK = 0xFFFFFFFF;

View File

@ -52,6 +52,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX,
SmsDatabase.STATUS,
MmsSmsColumns.UNIDENTIFIED,
MmsSmsColumns.REACTIONS,
MmsDatabase.PART_COUNT,
MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID,
MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY,
@ -73,7 +74,11 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE};
MmsDatabase.VIEW_ONCE,
MmsSmsColumns.READ,
MmsSmsColumns.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
@ -133,7 +138,7 @@ public class MmsSmsDatabase extends Database {
public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0";
String selection = MmsSmsColumns.NOTIFIED + " = 0 AND (" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + ")";
return queryTables(PROJECTION, selection, order, null);
}
@ -149,6 +154,18 @@ public class MmsSmsDatabase extends Database {
}
}
public boolean checkMessageExists(@NonNull MessageRecord messageRecord) {
if (messageRecord.isMms()) {
try (Cursor mms = DatabaseFactory.getMmsDatabase(context).getMessage(messageRecord.getId())) {
return mms != null && mms.getCount() > 0;
}
} else {
try (Cursor sms = DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageRecord.getId())) {
return sms != null && sms.getCount() > 0;
}
}
}
public int getConversationCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId);
@ -299,7 +316,10 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE};
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@ -326,7 +346,10 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE};
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@ -398,6 +421,9 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE);
mmsColumnsPresent.add(MmsDatabase.REACTIONS);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN);
Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);
@ -419,6 +445,9 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
smsColumnsPresent.add(SmsDatabase.STATUS);
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
smsColumnsPresent.add(SmsDatabase.REACTIONS);
smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD);
smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN);
@SuppressWarnings("deprecation")
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);

View File

@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
@ -49,6 +50,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -75,14 +77,32 @@ public class SmsDatabase extends MessagingDatabase {
public static final String SUBJECT = "subject";
public static final String SERVICE_CENTER = "service_center";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " integer PRIMARY KEY, " +
THREAD_ID + " INTEGER, " + RECIPIENT_ID + " INTEGER, " + ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " + PERSON + " INTEGER, " +
DATE_RECEIVED + " INTEGER, " + DATE_SENT + " INTEGER, " + PROTOCOL + " INTEGER, " + READ + " INTEGER DEFAULT 0, " +
STATUS + " INTEGER DEFAULT -1," + TYPE + " INTEGER, " + REPLY_PATH_PRESENT + " INTEGER, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," + SUBJECT + " TEXT, " + BODY + " TEXT, " +
MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + SERVICE_CENTER + " TEXT, " + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
THREAD_ID + " INTEGER, " +
RECIPIENT_ID + " INTEGER, " +
ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " +
PERSON + " INTEGER, " +
DATE_RECEIVED + " INTEGER, " +
DATE_SENT + " INTEGER, " +
PROTOCOL + " INTEGER, " +
READ + " INTEGER DEFAULT 0, " +
STATUS + " INTEGER DEFAULT -1," +
TYPE + " INTEGER, " +
REPLY_PATH_PRESENT + " INTEGER, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," +
SUBJECT + " TEXT, " +
BODY + " TEXT, " +
MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " +
SERVICE_CENTER + " TEXT, " +
SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
EXPIRES_IN + " INTEGER DEFAULT 0, " +
EXPIRE_STARTED + " INTEGER DEFAULT 0, " +
NOTIFIED + " DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
UNIDENTIFIED + " INTEGER DEFAULT 0, " +
REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
@ -100,7 +120,7 @@ public class SmsDatabase extends MessagingDatabase {
PROTOCOL, READ, STATUS, TYPE,
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN
};
private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")";
@ -868,7 +888,7 @@ public class SmsDatabase extends MessagingDatabase {
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
threadId, 0, new LinkedList<IdentityKeyMismatch>(),
message.getSubscriptionId(), message.getExpiresIn(),
System.currentTimeMillis(), 0, false);
System.currentTimeMillis(), 0, false, Collections.emptyList());
}
}
@ -893,22 +913,23 @@ public class SmsDatabase extends MessagingDatabase {
}
public SmsMessageRecord getCurrent() {
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID));
int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID));
long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE));
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID));
int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT));
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES));
int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN));
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED));
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY));
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1;
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID ));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID ));
int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID ));
long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE ));
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT ));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID ));
int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS ));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT ));
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES));
int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID ));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN ));
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED ));
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY ));
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1;
List<ReactionRecord> reactions = parseReactions(cursor);
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@ -923,7 +944,7 @@ public class SmsDatabase extends MessagingDatabase {
dateSent, dateReceived, deliveryReceiptCount, type,
threadId, status, mismatches, subscriptionId,
expiresIn, expireStarted,
readReceiptCount, unidentified);
readReceiptCount, unidentified, reactions);
}
private List<IdentityKeyMismatch> getMismatches(String document) {

View File

@ -278,6 +278,9 @@ public class ThreadDatabase extends Database {
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setAllMessagesRead();
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setAllMessagesRead();
DatabaseFactory.getSmsDatabase(context).setAllReactionsSeen();
DatabaseFactory.getMmsDatabase(context).setAllReactionsSeen();
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, mmsRecords);
@ -312,6 +315,9 @@ public class ThreadDatabase extends Database {
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId);
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId);
DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId);
DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId);
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, mmsRecords);

View File

@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.File;
import java.util.List;
import java.util.UUID;
public class SQLCipherOpenHelper extends SQLiteOpenHelper {
@ -93,8 +92,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int ATTACHMENT_CLEAR_HASHES_2 = 34;
private static final int UUIDS = 35;
private static final int USERNAMES = 36;
private static final int REACTIONS = 37;
private static final int DATABASE_VERSION = 37;
private static final int DATABASE_VERSION = 36;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -626,6 +627,17 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_username_index ON recipient (username)");
}
if (oldVersion < REACTIONS) {
db.execSQL("ALTER TABLE sms ADD COLUMN reactions BLOB DEFAULT NULL");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions BLOB DEFAULT NULL");
db.execSQL("ALTER TABLE sms ADD COLUMN reactions_unread INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions_unread INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE sms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -57,12 +57,13 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
long expiresIn, long expireStarted,
boolean viewOnce, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified);
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions);
this.partCount = partCount;
}

View File

@ -53,6 +53,7 @@ public abstract class MessageRecord extends DisplayRecord {
private final long expiresIn;
private final long expireStarted;
private final boolean unidentified;
private final List<ReactionRecord> reactions;
MessageRecord(long id, String body, Recipient conversationRecipient,
Recipient individualRecipient, int recipientDeviceId,
@ -61,7 +62,8 @@ public abstract class MessageRecord extends DisplayRecord {
List<IdentityKeyMismatch> mismatches,
List<NetworkFailure> networkFailures,
int subscriptionId, long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified)
int readReceiptCount, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(body, conversationRecipient, dateSent, dateReceived,
threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount);
@ -74,6 +76,7 @@ public abstract class MessageRecord extends DisplayRecord {
this.expiresIn = expiresIn;
this.expireStarted = expireStarted;
this.unidentified = unidentified;
this.reactions = reactions;
}
public abstract boolean isMms();
@ -249,4 +252,8 @@ public abstract class MessageRecord extends DisplayRecord {
public boolean isViewOnce() {
return false;
}
public @NonNull List<ReactionRecord> getReactions() {
return reactions;
}
}

View File

@ -32,9 +32,10 @@ public abstract class MmsMessageRecord extends MessageRecord {
long expireStarted, boolean viewOnce,
@NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified);
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
this.slideDeck = slideDeck;
this.quote = quote;

View File

@ -57,7 +57,8 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new LinkedList<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(), subscriptionId,
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false);
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
Collections.emptyList());
this.contentLocation = contentLocation;
this.messageSize = messageSize;

View File

@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.RecipientId;
public class ReactionRecord {
private final String emoji;
private final RecipientId author;
private final long dateSent;
private final long dateReceived;
public ReactionRecord(@NonNull String emoji,
@NonNull RecipientId author,
long dateSent,
long dateReceived)
{
this.emoji = emoji;
this.author = author;
this.dateSent = dateSent;
this.dateReceived = dateReceived;
}
public @NonNull String getEmoji() {
return emoji;
}
public @NonNull RecipientId getAuthor() {
return author;
}
public long getDateSent() {
return dateSent;
}
public long getDateReceived() {
return dateReceived;
}
}

View File

@ -48,12 +48,13 @@ public class SmsMessageRecord extends MessageRecord {
long type, long threadId,
int status, List<IdentityKeyMismatch> mismatches,
int subscriptionId, long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified)
int readReceiptCount, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, recipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
mismatches, new LinkedList<>(), subscriptionId,
expiresIn, expireStarted, readReceiptCount, unidentified);
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
}
public long getType() {

View File

@ -66,6 +66,7 @@ public final class JobManagerFactories {
put(PushMediaSendJob.KEY, new PushMediaSendJob.Factory());
put(PushNotificationReceiveJob.KEY, new PushNotificationReceiveJob.Factory());
put(PushTextSendJob.KEY, new PushTextSendJob.Factory());
put(ReactionSendJob.KEY, new ReactionSendJob.Factory());
put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory());
put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory());
put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory());

View File

@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupMessageProcessor;
@ -241,12 +242,13 @@ public class PushDecryptJob extends BaseJob {
SignalServiceDataMessage message = content.getDataMessage().get();
boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent();
if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), message.getGroupInfo(), content.getTimestamp(), smsMessageId);
else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId);
else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId);
else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId);
else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId);
if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), message.getGroupInfo(), content.getTimestamp(), smsMessageId);
else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId);
else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId);
else if (message.getReaction().isPresent()) handleReaction(content, message);
else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId);
else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId);
if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false))) {
handleUnknownGroupMessage(content, message.getGroupInfo().get());
@ -525,6 +527,30 @@ public class PushDecryptJob extends BaseJob {
}
}
private void handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) {
SignalServiceDataMessage.Reaction reaction = message.getReaction().get();
Recipient targetAuthor = Recipient.externalPush(context, reaction.getTargetAuthor());
MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId());
if (targetMessage != null) {
Recipient reactionAuthor = Recipient.externalPush(context, content.getSender());
MessagingDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
if (reaction.isRemove()) {
db.deleteReaction(targetMessage.getId(), reactionAuthor.getId());
MessageNotifier.updateNotification(context);
} else {
ReactionRecord reactionRecord = new ReactionRecord(reaction.getEmoji(), reactionAuthor.getId(), message.getTimestamp(), System.currentTimeMillis());
db.addReaction(targetMessage.getId(), reactionRecord);
MessageNotifier.updateNotification(context, targetMessage.getThreadId(), false);
}
} else {
Log.w(TAG, "[handleReaction] Could not find matching message! timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId());
}
}
private void handleSynchronizeVerifiedMessage(@NonNull VerifiedMessage verifiedMessage) {
IdentityUtil.processVerifiedMessage(context, verifiedMessage);
}
@ -599,6 +625,10 @@ public class PushDecryptJob extends BaseJob {
threadId = GroupMessageProcessor.process(context, content, message.getMessage(), true);
} else if (message.getMessage().isExpirationUpdate()) {
threadId = handleSynchronizeSentExpirationUpdate(message);
} else if (message.getMessage().getReaction().isPresent()) {
handleReaction(content, message.getMessage());
threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(getSyncMessageDestination(message));
threadId = threadId != -1 ? threadId : null;
} else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent()) {
threadId = handleSynchronizeSentMediaMessage(message);
} else {

View File

@ -0,0 +1,283 @@
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class ReactionSendJob extends BaseJob {
public static final String KEY = "ReactionSendJob";
private static final String TAG = Log.tag(ReactionSendJob.class);
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_IS_MMS = "is_mms";
private static final String KEY_REACTION_EMOJI = "reaction_emoji";
private static final String KEY_REACTION_AUTHOR = "reaction_author";
private static final String KEY_REACTION_DATE_SENT = "reaction_date_sent";
private static final String KEY_REACTION_DATE_RECEIVED = "reaction_date_received";
private static final String KEY_REMOVE = "remove";
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count";
private final long messageId;
private final boolean isMms;
private final List<RecipientId> recipients;
private final int initialRecipientCount;
private final ReactionRecord reaction;
private final boolean remove;
@WorkerThread
public static @NonNull ReactionSendJob create(@NonNull Context context,
long messageId,
boolean isMms,
@NonNull ReactionRecord reaction,
boolean remove)
throws NoSuchMessageException
{
MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId)
: DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId());
if (conversationRecipient == null) {
throw new AssertionError("We have a message, but couldn't find the thread!");
}
List<RecipientId> recipients = conversationRecipient.isGroup() ? Stream.of(conversationRecipient.getParticipants()).map(Recipient::getId).toList()
: Arrays.asList(conversationRecipient.getId());
recipients.remove(Recipient.self().getId());
return new ReactionSendJob(messageId,
isMms,
recipients,
recipients.size(),
reaction,
remove,
new Parameters.Builder()
.setQueue(conversationRecipient.getId().toQueueKey())
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build());
}
private ReactionSendJob(long messageId,
boolean isMms,
@NonNull List<RecipientId> recipients,
int initialRecipientCount,
@NonNull ReactionRecord reaction,
boolean remove,
@NonNull Parameters parameters)
{
super(parameters);
this.messageId = messageId;
this.isMms = isMms;
this.recipients = recipients;
this.initialRecipientCount = initialRecipientCount;
this.reaction = reaction;
this.remove = remove;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId)
.putBoolean(KEY_IS_MMS, isMms)
.putString(KEY_REACTION_EMOJI, reaction.getEmoji())
.putString(KEY_REACTION_AUTHOR, reaction.getAuthor().serialize())
.putLong(KEY_REACTION_DATE_SENT, reaction.getDateSent())
.putLong(KEY_REACTION_DATE_RECEIVED, reaction.getDateReceived())
.putBoolean(KEY_REMOVE, remove)
.putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients))
.putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount)
.build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
protected void onRun() throws Exception {
MessagingDatabase db;
MessageRecord message;
if (isMms) {
db = DatabaseFactory.getMmsDatabase(context);
message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
} else {
db = DatabaseFactory.getSmsDatabase(context);
message = DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
}
Recipient targetAuthor = message.isOutgoing() ? Recipient.self() : message.getIndividualRecipient();
long targetSentTimestamp = message.getDateSent();
if (!remove && !db.hasReaction(messageId, reaction)) {
Log.w(TAG, "Went to add a reaction, but it's no longer present on the message!");
return;
}
if (remove && db.hasReaction(messageId, reaction)) {
Log.w(TAG, "Went to remove a reaction, but it's still there!");
return;
}
Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId());
if (conversationRecipient == null) {
throw new AssertionError("We have a message, but couldn't find the thread!");
}
List<Recipient> destinations = Stream.of(recipients).map(Recipient::resolved).toList();
List<Recipient> completions = deliver(conversationRecipient, destinations, targetAuthor, targetSentTimestamp);
for (Recipient completion : completions) {
recipients.remove(completion.getId());
}
Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size());
if (!recipients.isEmpty()) {
Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying.");
throw new RetryLaterException();
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException ||
e instanceof RetryLaterException;
}
@Override
public void onCanceled() {
if (recipients.size() < initialRecipientCount) {
Log.w(TAG, "Only sent a reaction to " + recipients.size() + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays.");
return;
}
Log.w(TAG, "Failed to send the reaction to all recipients!");
MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
if (remove && !db.hasReaction(messageId, reaction)) {
Log.w(TAG, "Reaction removal failed, so adding the reaction back.");
db.addReaction(messageId, reaction);
} else if (!remove && db.hasReaction(messageId, reaction)){
Log.w(TAG, "Reaction addition failed, so removing the reaction.");
db.deleteReaction(messageId, reaction.getAuthor());
} else {
Log.w(TAG, "Reaction state didn't match what we'd expect to revert it, so we're just leaving it alone.");
}
}
private @NonNull List<Recipient> deliver(@NonNull Recipient conversationRecipient, @NonNull List<Recipient> destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp)
throws IOException, UntrustedIdentityException
{
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
List<SignalServiceAddress> addresses = Stream.of(destinations).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList();
List<Optional<UnidentifiedAccessPair>> unidentifiedAccess = Stream.of(destinations).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList();
SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder()
.withTimestamp(System.currentTimeMillis())
.withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp));
if (conversationRecipient.isGroup()) {
dataMessage.asGroupMessage(new SignalServiceGroup(GroupUtil.getDecodedId(conversationRecipient.requireGroupId())));
}
List<SendMessageResult> results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build());
Stream.of(results)
.filter(r -> r.getIdentityFailure() != null)
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.forEach(r -> Log.w(TAG, "Identity failure for " + r.getId()));
Stream.of(results)
.filter(SendMessageResult::isUnregisteredFailure)
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId()));
return Stream.of(results)
.filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure())
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.toList();
}
private static SignalServiceDataMessage.Reaction buildReaction(@NonNull Context context,
@NonNull ReactionRecord reaction,
boolean remove,
@NonNull Recipient targetAuthor,
long targetSentTimestamp)
{
return new SignalServiceDataMessage.Reaction(reaction.getEmoji(),
remove,
RecipientUtil.toSignalServiceAddress(context, targetAuthor),
targetSentTimestamp);
}
public static class Factory implements Job.Factory<ReactionSendJob> {
@Override
public @NonNull
ReactionSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
long messageId = data.getLong(KEY_MESSAGE_ID);
boolean isMms = data.getBoolean(KEY_IS_MMS);
List<RecipientId> recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS));
int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT);
ReactionRecord reaction = new ReactionRecord(data.getString(KEY_REACTION_EMOJI),
RecipientId.from(data.getString(KEY_REACTION_AUTHOR)),
data.getLong(KEY_REACTION_DATE_SENT),
data.getLong(KEY_REACTION_DATE_RECEIVED));
boolean remove = data.getBoolean(KEY_REMOVE);
return new ReactionSendJob(messageId, isMms, recipients, initialRecipientCount, reaction, remove, parameters);
}
}
}

View File

@ -37,6 +37,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.text.HtmlCompat;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import org.thoughtcrime.securesms.ApplicationContext;
@ -46,11 +49,13 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
@ -66,9 +71,11 @@ import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@ -90,6 +97,7 @@ public class MessageNotifier {
public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply";
private static final String EMOJI_REPLACEMENT_STRING = "__EMOJI__";
private static final int SUMMARY_NOTIFICATION_ID = 1338;
private static final int PENDING_MESSAGES_ID = 1111;
private static final String NOTIFICATION_GROUP = "messages";
@ -455,34 +463,73 @@ public class MessageNotifier {
Recipient threadRecipients = null;
SlideDeck slideDeck = null;
long timestamp = record.getTimestamp();
boolean isUnreadMessage = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.READ)) == 0;
boolean hasUnreadReactions = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_UNREAD)) == 1;
long lastReactionRead = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_LAST_SEEN));
if (threadId != -1) {
threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
}
if (KeyCachingService.isLocked(context)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
} else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) {
Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0);
body = ContactUtil.getStringSummary(context, contact);
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker));
slideDeck = ((MmsMessageRecord) record).getSlideDeck();
} else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) {
body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record)));
} else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message));
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
} else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
String message = context.getString(R.string.MessageNotifier_media_message_with_text, body);
int italicLength = message.length() - body.length();
body = SpanUtil.italic(message, italicLength);
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
if (isUnreadMessage) {
if (KeyCachingService.isLocked(context)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
} else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) {
Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0);
body = ContactUtil.getStringSummary(context, contact);
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker));
slideDeck = ((MmsMessageRecord) record).getSlideDeck();
} else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) {
body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record)));
} else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message));
slideDeck = ((MediaMmsMessageRecord) record).getSlideDeck();
} else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
String message = context.getString(R.string.MessageNotifier_media_message_with_text, body);
int italicLength = message.length() - body.length();
body = SpanUtil.italic(message, italicLength);
slideDeck = ((MediaMmsMessageRecord) record).getSlideDeck();
}
if (threadRecipients == null || !threadRecipients.isMuted()) {
notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck));
}
}
if (threadRecipients == null || !threadRecipients.isMuted()) {
notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck));
if (hasUnreadReactions) {
for (ReactionRecord reaction : record.getReactions()) {
Recipient reactionSender = Recipient.resolved(reaction.getAuthor());
if (reactionSender.equals(Recipient.self()) || !record.isOutgoing() || reaction.getDateReceived() <= lastReactionRead) {
continue;
}
if (KeyCachingService.isLocked(context)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
} else {
String text = SpanUtil.italic(context.getString(R.string.MessageNotifier_reacted_to_your_message, EMOJI_REPLACEMENT_STRING)).toString();
String[] parts = text.split(EMOJI_REPLACEMENT_STRING);
SpannableStringBuilder builder = new SpannableStringBuilder();
for (int i = 0; i < parts.length; i++) {
builder.append(SpanUtil.italic(parts[i]));
if (i != parts.length -1) {
builder.append(reaction.getEmoji());
}
}
if (text.endsWith(EMOJI_REPLACEMENT_STRING)) {
builder.append(reaction.getEmoji());
}
body = builder;
}
if (threadRecipients == null || !threadRecipients.isMuted()) {
notificationState.addNotification(new NotificationItem(id, mms, reactionSender, conversationRecipient, threadRecipients, threadId, body, reaction.getDateReceived(), null));
}
}
}
}

View File

@ -4,6 +4,7 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -13,6 +14,9 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
@ -21,10 +25,9 @@ public class NotificationState {
private static final String TAG = NotificationState.class.getSimpleName();
private final LinkedList<NotificationItem> notifications = new LinkedList<>();
private final LinkedHashSet<Long> threads = new LinkedHashSet<>();
private int notificationCount = 0;
private final Comparator<NotificationItem> notificationItemComparator = (a, b) -> -Long.compare(a.getTimestamp(), b.getTimestamp());
private final List<NotificationItem> notifications = new LinkedList<>();
private final LinkedHashSet<Long> threads = new LinkedHashSet<>();
public NotificationState() {}
@ -35,19 +38,16 @@ public class NotificationState {
}
public void addNotification(NotificationItem item) {
notifications.addFirst(item);
if (threads.contains(item.getThreadId())) {
threads.remove(item.getThreadId());
}
notifications.add(item);
Collections.sort(notifications, notificationItemComparator);
threads.remove(item.getThreadId());
threads.add(item.getThreadId());
notificationCount++;
}
public @Nullable Uri getRingtone(@NonNull Context context) {
if (!notifications.isEmpty()) {
Recipient recipient = notifications.getFirst().getRecipient();
Recipient recipient = notifications.get(0).getRecipient();
if (recipient != null) {
return NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context, recipient)
@ -60,7 +60,7 @@ public class NotificationState {
public VibrateState getVibrate() {
if (!notifications.isEmpty()) {
Recipient recipient = notifications.getFirst().getRecipient();
Recipient recipient = notifications.get(0).getRecipient();
if (recipient != null) {
return recipient.resolve().getMessageVibrate();
@ -74,7 +74,7 @@ public class NotificationState {
return threads.size() > 1;
}
public LinkedHashSet<Long> getThreads() {
public Collection<Long> getThreads() {
return threads;
}
@ -83,7 +83,7 @@ public class NotificationState {
}
public int getMessageCount() {
return notificationCount;
return notifications.size();
}
public List<NotificationItem> getNotifications() {
@ -91,12 +91,13 @@ public class NotificationState {
}
public List<NotificationItem> getNotificationsForThread(long threadId) {
LinkedList<NotificationItem> list = new LinkedList<>();
List<NotificationItem> list = new LinkedList<>();
for (NotificationItem item : notifications) {
if (item.getThreadId() == threadId) list.addFirst(item);
if (item.getThreadId() == threadId) list.add(item);
}
Collections.sort(list, notificationItemComparator);
return list;
}

View File

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
final class EmojiCount {
private final String emoji;
private final int count;
EmojiCount(@NonNull String emoji, int count) {
this.emoji = emoji;
this.count = count;
}
public @NonNull String getEmoji() {
return emoji;
}
public int getCount() {
return count;
}
}

View File

@ -0,0 +1,113 @@
package org.thoughtcrime.securesms.reactions;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Collections;
import java.util.List;
final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmojiCountAdapter.ViewHolder> {
private List<EmojiCount> emojiCountList = Collections.emptyList();
private int selectedPosition = -1;
private final OnEmojiCountSelectedListener onEmojiCountSelectedListener;
ReactionEmojiCountAdapter(@NonNull OnEmojiCountSelectedListener onEmojiCountSelectedListener) {
this.onEmojiCountSelectedListener = onEmojiCountSelectedListener;
}
void updateData(@NonNull List<EmojiCount> newEmojiCount) {
if (selectedPosition != -1) {
EmojiCount oldSelection = emojiCountList.get(selectedPosition);
int newPosition = -1;
for (int i = 0; i < newEmojiCount.size(); i++) {
if (newEmojiCount.get(i).getEmoji().equals(oldSelection.getEmoji())) {
newPosition = i;
break;
}
}
if (newPosition == -1 && !newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0));
} else {
selectedPosition = newPosition;
}
} else if (!newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0));
}
this.emojiCountList = newEmojiCount;
notifyDataSetChanged();
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item, parent, false), position -> {
if (position != -1 && position != selectedPosition) {
onEmojiCountSelectedListener.onSelected(emojiCountList.get(position));
int oldPosition = selectedPosition;
selectedPosition = position;
notifyItemChanged(oldPosition);
notifyItemChanged(position);
}
});
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(emojiCountList.get(position), selectedPosition);
}
@Override
public int getItemCount() {
return emojiCountList.size();
}
static final class ViewHolder extends RecyclerView.ViewHolder {
private final Drawable selected;
private final EmojiTextView emojiView;
private final TextView countView;
public ViewHolder(@NonNull View itemView, @NonNull OnViewHolderClickListener onClickListener) {
super(itemView);
emojiView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji);
countView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_text);
selected = ThemeUtil.getThemedDrawable(itemView.getContext(), R.attr.reactions_bottom_dialog_fragment_emoji_selected);
itemView.setOnClickListener(v -> onClickListener.onClick(getAdapterPosition()));
}
void bind(@NonNull EmojiCount emojiCount, int selectedPosition) {
emojiView.setText(emojiCount.getEmoji());
countView.setText(String.valueOf(emojiCount.getCount()));
itemView.setBackground(getAdapterPosition() == selectedPosition ? selected : null);
}
}
interface OnViewHolderClickListener {
void onClick(int position);
}
interface OnEmojiCountSelectedListener {
void onSelected(@NonNull EmojiCount emojiCount);
}
}

View File

@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.reactions;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AvatarUtil;
import java.util.Collections;
import java.util.List;
final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecipientsAdapter.ViewHolder> {
private List<Recipient> data = Collections.emptyList();
public void updateData(List<Recipient> newData) {
data = newData;
notifyDataSetChanged();
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.reactions_bottom_sheet_dialog_fragment_recipient_item,
parent,
false));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
final class ViewHolder extends RecyclerView.ViewHolder {
private final AvatarImageView avatar;
private final TextView recipient;
public ViewHolder(@NonNull View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar);
recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name);
}
void bind(Recipient recipient) {
this.recipient.setText(recipient.getDisplayName(itemView.getContext()));
if (recipient.equals(Recipient.self())) {
AvatarUtil.loadIconIntoImageView(recipient, avatar);
} else {
this.avatar.setAvatar(GlideApp.with(avatar), recipient, false);
}
}
}
}

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.reactions;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.thoughtcrime.securesms.R;
public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogFragment {
private static final String ARGS_MESSAGE_ID = "reactions.args.message.id";
private static final String ARGS_IS_MMS = "reactions.args.is.mms";
private long messageId;
private RecyclerView recipientRecyclerView;
private RecyclerView emojiRecyclerView;
private ReactionsLoader reactionsLoader;
private ReactionRecipientsAdapter recipientsAdapter;
private ReactionEmojiCountAdapter emojiCountAdapter;
private ReactionsViewModel viewModel;
public static DialogFragment create(long messageId, boolean isMms) {
Bundle args = new Bundle();
DialogFragment fragment = new ReactionsBottomSheetDialogFragment();
args.putLong(ARGS_MESSAGE_ID, messageId);
args.putBoolean(ARGS_IS_MMS, isMms);
fragment.setArguments(args);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
return inflater.inflate(R.layout.reactions_bottom_sheet_dialog_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
recipientRecyclerView = view.findViewById(R.id.reactions_bottom_view_recipient_recycler);
emojiRecyclerView = view.findViewById(R.id.reactions_bottom_view_emoji_recycler);
messageId = getArguments().getLong(ARGS_MESSAGE_ID);
setUpRecipientsRecyclerView();
setUpEmojiRecyclerView();
setUpViewModel();
LoaderManager.getInstance(requireActivity()).initLoader((int) messageId, null, reactionsLoader);
}
@Override
public void onDestroyView() {
LoaderManager.getInstance(requireActivity()).destroyLoader((int) messageId);
super.onDestroyView();
}
private void setUpRecipientsRecyclerView() {
recipientsAdapter = new ReactionRecipientsAdapter();
recipientRecyclerView.setAdapter(recipientsAdapter);
}
private void setUpEmojiRecyclerView() {
emojiCountAdapter = new ReactionEmojiCountAdapter((emojiCount -> viewModel.setFilterEmoji(emojiCount.getEmoji())));
emojiRecyclerView.setAdapter(emojiCountAdapter);
}
private void setUpViewModel() {
reactionsLoader = new ReactionsLoader(requireContext(),
getArguments().getLong(ARGS_MESSAGE_ID),
getArguments().getBoolean(ARGS_IS_MMS));
ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(reactionsLoader);
viewModel = ViewModelProviders.of(this, factory).get(ReactionsViewModel.class);
viewModel.getRecipients().observe(getViewLifecycleOwner(), reactions -> {
if (reactions.size() == 0) dismiss();
recipientsAdapter.updateData(reactions);
});
viewModel.getEmojiCounts().observe(getViewLifecycleOwner(), emojiCounts -> {
if (emojiCounts.size() == 0) dismiss();
emojiCountAdapter.updateData(emojiCounts);
});
}
}

View File

@ -0,0 +1,130 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pair;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.Collections;
import java.util.List;
public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderManager.LoaderCallbacks<Cursor> {
private final long messageId;
private final boolean isMms;
private final Context appContext;
private MutableLiveData<List<Reaction>> internalLiveData = new MutableLiveData<>();
public ReactionsLoader(@NonNull Context context, long messageId, boolean isMms)
{
this.messageId = messageId;
this.isMms = isMms;
this.appContext = context.getApplicationContext();
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) {
return isMms ? new MmsMessageRecordCursorLoader(appContext, messageId)
: new SmsMessageRecordCursorLoader(appContext, messageId);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
SignalExecutors.BOUNDED.execute(() -> {
MessageRecord record = isMms ? DatabaseFactory.getMmsDatabase(appContext).readerFor(data).getNext()
: DatabaseFactory.getSmsDatabase(appContext).readerFor(data).getNext();
if (record == null) {
internalLiveData.postValue(Collections.emptyList());
} else {
internalLiveData.postValue(Stream.of(record.getReactions())
.map(reactionRecord -> new Reaction(Recipient.resolved(reactionRecord.getAuthor()),
reactionRecord.getEmoji(),
reactionRecord.getDateReceived()))
.toList());
}
});
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
// Do nothing?
}
@Override
public LiveData<List<Reaction>> getReactions() {
return internalLiveData;
}
private static final class MmsMessageRecordCursorLoader extends AbstractCursorLoader {
private final long messageId;
public MmsMessageRecordCursorLoader(@NonNull Context context, long messageId) {
super(context);
this.messageId = messageId;
}
@Override
public Cursor getCursor() {
return DatabaseFactory.getMmsDatabase(context).getMessage(messageId);
}
}
private static final class SmsMessageRecordCursorLoader extends AbstractCursorLoader {
private final long messageId;
public SmsMessageRecordCursorLoader(@NonNull Context context, long messageId) {
super(context);
this.messageId = messageId;
}
@Override
public Cursor getCursor() {
return DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageId);
}
}
static class Reaction {
private final Recipient sender;
private final String emoji;
private final long timestamp;
private Reaction(@NonNull Recipient sender, @NonNull String emoji, long timestamp) {
this.sender = sender;
this.emoji = emoji;
this.timestamp = timestamp;
}
public @NonNull Recipient getSender() {
return sender;
}
public @NonNull String getEmoji() {
return emoji;
}
public long getTimestamp() {
return timestamp;
}
}
}

View File

@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import static org.thoughtcrime.securesms.reactions.ReactionsLoader.*;
public class ReactionsViewModel extends ViewModel {
private final Repository repository;
private final MutableLiveData<String> filterEmoji = new MutableLiveData<>();
public ReactionsViewModel(@NonNull Repository repository) {
this.repository = repository;
}
public @NonNull LiveData<List<Recipient>> getRecipients() {
return Transformations.switchMap(filterEmoji,
emoji -> Transformations.map(repository.getReactions(),
reactions -> Stream.of(reactions)
.filter(reaction -> reaction.getEmoji().equals(emoji))
.map(Reaction::getSender).toList()));
}
public @NonNull LiveData<List<EmojiCount>> getEmojiCounts() {
return Transformations.map(repository.getReactions(),
reactionList -> Stream.of(reactionList)
.groupBy(Reaction::getEmoji)
.sorted(this::compareReactions)
.map(entry -> new EmojiCount(entry.getKey(), entry.getValue().size()))
.toList());
}
public void setFilterEmoji(String filterEmoji) {
this.filterEmoji.setValue(filterEmoji);
}
private int compareReactions(@NonNull Map.Entry<String, List<Reaction>> lhs, @NonNull Map.Entry<String, List<Reaction>> rhs) {
int lengthComparison = -Integer.compare(lhs.getValue().size(), rhs.getValue().size());
if (lengthComparison != 0) return lengthComparison;
long latestTimestampLhs = getLatestTimestamp(lhs.getValue());
long latestTimestampRhs = getLatestTimestamp(rhs.getValue());
return -Long.compare(latestTimestampLhs, latestTimestampRhs);
}
private long getLatestTimestamp(List<Reaction> reactions) {
return Stream.of(reactions)
.max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()))
.map(Reaction::getTimestamp)
.orElse(-1L);
}
interface Repository {
LiveData<List<Reaction>> getReactions();
}
static final class Factory implements ViewModelProvider.Factory {
private final Repository repository;
Factory(@NonNull Repository repository) {
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new ReactionsViewModel(repository);
}
}
}

View File

@ -70,6 +70,10 @@ public class RecipientId implements Parcelable, Comparable<RecipientId> {
return String.valueOf(id);
}
public long toLong() {
return id;
}
public @NonNull String toQueueKey() {
return "RecipientId::" + id;
}

View File

@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.telephony.SmsMessage;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;

View File

@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
@ -48,22 +50,21 @@ import org.thoughtcrime.securesms.jobs.MmsSendJob;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
import org.thoughtcrime.securesms.jobs.PushTextSendJob;
import org.thoughtcrime.securesms.jobs.ReactionSendJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.push.AccountManagerFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
@ -229,6 +230,32 @@ public class MessageSender {
}
}
public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) {
MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis());
db.addReaction(messageId, reaction);
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, false));
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring.");
}
}
public static void sendReactionRemoval(@NonNull Context context, long messageId, boolean isMms, @NonNull ReactionRecord reaction) {
MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
db.deleteReaction(messageId, reaction.getAuthor());
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, true));
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendReactionRemoval] Could not find message! Ignoring.");
}
}
public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) {
if (!messageRecord.isMms()) throw new AssertionError("Not Group");
sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId);

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.libsignal.util.guava.Optional;
public final class AvatarUtil {
private AvatarUtil() {
}
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
Context context = target.getContext();
String name = Optional.fromNullable(recipient.getDisplayName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or("");
MaterialColor fallbackColor = recipient.getColor();
if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) {
fallbackColor = ContactColors.generateFor(name);
}
Drawable fallback = new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor.toAvatarColor(context));
GlideApp.with(context)
.load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context))))
.error(fallback)
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(target);
}
}

Some files were not shown because too many files have changed in this diff Show More