diff --git a/protobuf/Database.proto b/protobuf/Database.proto
new file mode 100644
index 0000000000..c50a258216
--- /dev/null
+++ b/protobuf/Database.proto
@@ -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;
+}
diff --git a/res/animator/reactions_scrubber_hide.xml b/res/animator/reactions_scrubber_hide.xml
new file mode 100644
index 0000000000..bfaf01ea4f
--- /dev/null
+++ b/res/animator/reactions_scrubber_hide.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/animator/reactions_scrubber_reveal.xml b/res/animator/reactions_scrubber_reveal.xml
new file mode 100644
index 0000000000..1b753fdaea
--- /dev/null
+++ b/res/animator/reactions_scrubber_reveal.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable-hdpi/ic_delete_white_24dp.webp b/res/drawable-hdpi/ic_delete_white_24dp.webp
deleted file mode 100644
index a8d54c978d..0000000000
Binary files a/res/drawable-hdpi/ic_delete_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-hdpi/ic_forward_white_24dp.webp b/res/drawable-hdpi/ic_forward_white_24dp.webp
deleted file mode 100644
index c01726477a..0000000000
Binary files a/res/drawable-hdpi/ic_forward_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-hdpi/ic_reply_white_24dp.webp b/res/drawable-hdpi/ic_reply_white_24dp.webp
deleted file mode 100644
index 7ee6e6aae6..0000000000
Binary files a/res/drawable-hdpi/ic_reply_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-mdpi/ic_delete_white_24dp.webp b/res/drawable-mdpi/ic_delete_white_24dp.webp
deleted file mode 100644
index 3328877c46..0000000000
Binary files a/res/drawable-mdpi/ic_delete_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-mdpi/ic_forward_white_24dp.webp b/res/drawable-mdpi/ic_forward_white_24dp.webp
deleted file mode 100644
index 9bb2ce0e14..0000000000
Binary files a/res/drawable-mdpi/ic_forward_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-mdpi/ic_reply_white_24dp.webp b/res/drawable-mdpi/ic_reply_white_24dp.webp
deleted file mode 100644
index ae81f91fd7..0000000000
Binary files a/res/drawable-mdpi/ic_reply_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xhdpi/ic_delete_white_24dp.webp b/res/drawable-xhdpi/ic_delete_white_24dp.webp
deleted file mode 100644
index 2ce7d2a685..0000000000
Binary files a/res/drawable-xhdpi/ic_delete_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xhdpi/ic_forward_white_24dp.webp b/res/drawable-xhdpi/ic_forward_white_24dp.webp
deleted file mode 100644
index 19adbb4cbb..0000000000
Binary files a/res/drawable-xhdpi/ic_forward_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xhdpi/ic_reply_white_24dp.webp b/res/drawable-xhdpi/ic_reply_white_24dp.webp
deleted file mode 100644
index 01e4f14168..0000000000
Binary files a/res/drawable-xhdpi/ic_reply_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xxhdpi/ic_delete_white_24dp.webp b/res/drawable-xxhdpi/ic_delete_white_24dp.webp
deleted file mode 100644
index d167093dd5..0000000000
Binary files a/res/drawable-xxhdpi/ic_delete_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xxhdpi/ic_forward_white_24dp.webp b/res/drawable-xxhdpi/ic_forward_white_24dp.webp
deleted file mode 100644
index 1fa198ef85..0000000000
Binary files a/res/drawable-xxhdpi/ic_forward_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xxhdpi/ic_reply_white_24dp.webp b/res/drawable-xxhdpi/ic_reply_white_24dp.webp
deleted file mode 100644
index 8b2db850f1..0000000000
Binary files a/res/drawable-xxhdpi/ic_reply_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xxxhdpi/ic_delete_white_24dp.webp b/res/drawable-xxxhdpi/ic_delete_white_24dp.webp
deleted file mode 100644
index f61ec1eb13..0000000000
Binary files a/res/drawable-xxxhdpi/ic_delete_white_24dp.webp and /dev/null differ
diff --git a/res/drawable-xxxhdpi/ic_reply_white_24dp.webp b/res/drawable-xxxhdpi/ic_reply_white_24dp.webp
deleted file mode 100644
index 4c92b9a7a1..0000000000
Binary files a/res/drawable-xxxhdpi/ic_reply_white_24dp.webp and /dev/null differ
diff --git a/res/drawable/conversation_reaction_overlay_background_dark.xml b/res/drawable/conversation_reaction_overlay_background_dark.xml
new file mode 100644
index 0000000000..0ff9bdd274
--- /dev/null
+++ b/res/drawable/conversation_reaction_overlay_background_dark.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/ic_copy_outline_24.xml b/res/drawable/ic_copy_outline_24.xml
new file mode 100644
index 0000000000..70a745ef6d
--- /dev/null
+++ b/res/drawable/ic_copy_outline_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_copy_solid_24.xml b/res/drawable/ic_copy_solid_24.xml
new file mode 100644
index 0000000000..e6edfc206e
--- /dev/null
+++ b/res/drawable/ic_copy_solid_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_forward_outline.xml b/res/drawable/ic_forward_outline_24.xml
similarity index 95%
rename from res/drawable/ic_forward_outline.xml
rename to res/drawable/ic_forward_outline_24.xml
index d1463dda12..f77ad2becb 100644
--- a/res/drawable/ic_forward_outline.xml
+++ b/res/drawable/ic_forward_outline_24.xml
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
diff --git a/res/drawable/ic_forward_solid_24.xml b/res/drawable/ic_forward_solid_24.xml
new file mode 100644
index 0000000000..10b92dd8e4
--- /dev/null
+++ b/res/drawable/ic_forward_solid_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_info_solid_24.xml b/res/drawable/ic_info_solid_24.xml
new file mode 100644
index 0000000000..0f6ba70ab5
--- /dev/null
+++ b/res/drawable/ic_info_solid_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_reply_outline_24.xml b/res/drawable/ic_reply_outline_24.xml
new file mode 100644
index 0000000000..535336fdd3
--- /dev/null
+++ b/res/drawable/ic_reply_outline_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_reply_solid_24.xml b/res/drawable/ic_reply_solid_24.xml
new file mode 100644
index 0000000000..5fd2caa43b
--- /dev/null
+++ b/res/drawable/ic_reply_solid_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_save_24.xml b/res/drawable/ic_save_24.xml
new file mode 100644
index 0000000000..32a936e3ce
--- /dev/null
+++ b/res/drawable/ic_save_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_select_24.xml b/res/drawable/ic_select_24.xml
new file mode 100644
index 0000000000..8520b0ac8c
--- /dev/null
+++ b/res/drawable/ic_select_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_trash_outline_24.xml b/res/drawable/ic_trash_outline_24.xml
new file mode 100644
index 0000000000..f297fee28a
--- /dev/null
+++ b/res/drawable/ic_trash_outline_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_trash_solid_24.xml b/res/drawable/ic_trash_solid_24.xml
new file mode 100644
index 0000000000..d531058056
--- /dev/null
+++ b/res/drawable/ic_trash_solid_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/res/drawable/ic_x_conversation.xml b/res/drawable/ic_x_conversation.xml
new file mode 100644
index 0000000000..2b4f81a4f0
--- /dev/null
+++ b/res/drawable/ic_x_conversation.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml
new file mode 100644
index 0000000000..5c42bb319e
--- /dev/null
+++ b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml
new file mode 100644
index 0000000000..e9f36f9be2
--- /dev/null
+++ b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_old_background_dark.xml b/res/drawable/reactions_old_background_dark.xml
new file mode 100644
index 0000000000..fe3c5b6ca4
--- /dev/null
+++ b/res/drawable/reactions_old_background_dark.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_recv_background_dark.xml b/res/drawable/reactions_recv_background_dark.xml
new file mode 100644
index 0000000000..2b6aa5b590
--- /dev/null
+++ b/res/drawable/reactions_recv_background_dark.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_recv_background_light.xml b/res/drawable/reactions_recv_background_light.xml
new file mode 100644
index 0000000000..bdab4b535c
--- /dev/null
+++ b/res/drawable/reactions_recv_background_light.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_send_background_dark.xml b/res/drawable/reactions_send_background_dark.xml
new file mode 100644
index 0000000000..fac02c4dca
--- /dev/null
+++ b/res/drawable/reactions_send_background_dark.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/reactions_send_background_light.xml b/res/drawable/reactions_send_background_light.xml
new file mode 100644
index 0000000000..8a6891ecb0
--- /dev/null
+++ b/res/drawable/reactions_send_background_light.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml
index a9064a4be6..db3c719e76 100644
--- a/res/layout/conversation_activity.xml
+++ b/res/layout/conversation_activity.xml
@@ -142,4 +142,5 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
+
diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml
index ee229b09f7..25fcbb6178 100644
--- a/res/layout/conversation_fragment.xml
+++ b/res/layout/conversation_fragment.xml
@@ -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"/>
-
diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml
index 1d5cb37021..25c207992b 100644
--- a/res/layout/conversation_item_received.xml
+++ b/res/layout/conversation_item_received.xml
@@ -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"/>
+
+
+
+
+
+
+
+
diff --git a/res/layout/conversation_item_sent.xml b/res/layout/conversation_item_sent.xml
index 3a7d161349..0467136a6e 100644
--- a/res/layout/conversation_item_sent.xml
+++ b/res/layout/conversation_item_sent.xml
@@ -1,71 +1,70 @@
-
+ android:orientation="horizontal">
+ android:clipChildren="false"
+ android:clipToPadding="false">
+ android:alpha="0"
+ app:srcCompat="?menu_reply_icon"
+ android:tint="?compose_icon_tint" />
+ tools:visibility="visible" />
+ android:layout="@layout/conversation_item_sent_shared_contact"
+ android:visibility="gone" />
+ 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" />
+ 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" />
+ 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" />
+ app:scaleEmojis="true"
+ tools:text="Mango pickle lorem ipsum" />
+ android:visibility="gone" />
+ android:gravity="end"
+ app:footer_icon_color="?attr/conversation_item_sent_icon_color"
+ app:footer_text_color="?attr/conversation_item_sent_text_secondary_color" />
+ app:footer_icon_color="?conversation_sticker_footer_icon_color"
+ app:footer_text_color="?conversation_sticker_footer_text_color" />
@@ -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" />
+
+
+
+
+
+
+
+
diff --git a/res/layout/conversation_reaction_long_press_toolbar.xml b/res/layout/conversation_reaction_long_press_toolbar.xml
new file mode 100644
index 0000000000..fdcfa492cc
--- /dev/null
+++ b/res/layout/conversation_reaction_long_press_toolbar.xml
@@ -0,0 +1,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/conversation_reaction_scrubber.xml b/res/layout/conversation_reaction_scrubber.xml
new file mode 100644
index 0000000000..c51790b39c
--- /dev/null
+++ b/res/layout/conversation_reaction_scrubber.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment.xml b/res/layout/reactions_bottom_sheet_dialog_fragment.xml
new file mode 100644
index 0000000000..b7d3343d8f
--- /dev/null
+++ b/res/layout/reactions_bottom_sheet_dialog_fragment.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml b/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml
new file mode 100644
index 0000000000..31f83dea5b
--- /dev/null
+++ b/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml b/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml
new file mode 100644
index 0000000000..dbe24e59f1
--- /dev/null
+++ b/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/sticker_management_sticker_item.xml b/res/layout/sticker_management_sticker_item.xml
index 0770de7783..53b4657688 100644
--- a/res/layout/sticker_management_sticker_item.xml
+++ b/res/layout/sticker_management_sticker_item.xml
@@ -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"
diff --git a/res/layout/sticker_preview_activity.xml b/res/layout/sticker_preview_activity.xml
index ea6768e960..fab1b5f403 100644
--- a/res/layout/sticker_preview_activity.xml
+++ b/res/layout/sticker_preview_activity.xml
@@ -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"
diff --git a/res/menu/conversation_context.xml b/res/menu/conversation_context.xml
index f0d23d6ee8..8803997884 100644
--- a/res/menu/conversation_context.xml
+++ b/res/menu/conversation_context.xml
@@ -15,10 +15,11 @@
android:icon="?menu_copy_icon"
app:showAsAction="always" />
-
+
-
+
diff --git a/res/menu/conversation_reactions_long_press_menu.xml b/res/menu/conversation_reactions_long_press_menu.xml
new file mode 100644
index 0000000000..5da3518829
--- /dev/null
+++ b/res/menu/conversation_reactions_long_press_menu.xml
@@ -0,0 +1,40 @@
+
+
\ No newline at end of file
diff --git a/res/menu/media_overview_context.xml b/res/menu/media_overview_context.xml
index 264af23279..464499861f 100644
--- a/res/menu/media_overview_context.xml
+++ b/res/menu/media_overview_context.xml
@@ -7,7 +7,7 @@
-
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index e3eaee6aa6..ab012af1ea 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -92,6 +92,12 @@
+
+
+
+
+
+
@@ -172,6 +178,7 @@
+
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 2dc4a227da..85651e8e2a 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -16,8 +16,10 @@
#33000000
#66000000
#99000000
+ #CC000000
#33ffffff
+ #4Dffffff
#99ffffff
#ccffffff
#e6ffffff
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 4b9bc7ae11..ab7013380d 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -46,6 +46,12 @@
320dp
128dp
+ -37dp
+ 32dp
+ 16dp
+ 28dp
+ 60dp
+
175dp
85dp
130dp
@@ -64,6 +70,11 @@
8dp
1dp
+ 28dp
+
+ 25dp
+ 0dp
+
20dp
28dp
@@ -129,4 +140,12 @@
@dimen/selection_item_header_height
+ 136dp
+ 40dp
+ 60dp
+ 136dp
+ 40dp
+ 20dp
+ 16dp
+
diff --git a/res/values/emoji.xml b/res/values/emoji.xml
index 045e125f3d..ac17853d0b 100644
--- a/res/values/emoji.xml
+++ b/res/values/emoji.xml
@@ -1,3 +1,10 @@
+ \u2764\ufe0f
+ \ud83d\udc4d
+ \ud83d\udc4e
+ \ud83d\ude02
+ \ud83d\ude2e
+ \ud83d\ude22
+ \ud83d\ude21
diff --git a/res/values/integers.xml b/res/values/integers.xml
index bb4c483c99..adb103844f 100644
--- a/res/values/integers.xml
+++ b/res/values/integers.xml
@@ -1,4 +1,7 @@
300
+ 400
+ 380
+ 10
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 306e474964..a049335284 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -894,6 +894,7 @@
Open Signal to check for recent notifications.
%1$s %2$s
Contact
+ Reacted to your message: %1$s
Default
@@ -1467,6 +1468,9 @@
Resend message
Reply to message
+
+ Select multiple
+
Save attachment
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 689464f33f..608d74ff3b 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -397,4 +397,8 @@
+
+
+
+
diff --git a/res/values/themes.xml b/res/values/themes.xml
index 4e0e00af87..7764035897 100644
--- a/res/values/themes.xml
+++ b/res/values/themes.xml
@@ -162,6 +162,8 @@
- @color/core_grey_90
- @color/core_grey_60
+ - @style/Theme.Design.Light.BottomSheetDialog
+
- @style/TextSecure.LightActionBar
- @style/TextSecure.LightActionBar.TabBar
- @color/core_grey_50
@@ -257,6 +259,12 @@
- @drawable/ic_emoji_emoticon_light_20
- @drawable/emoji_variation_selector_background_light
+ - @drawable/reactions_recv_background_light
+ - @drawable/reactions_send_background_light
+ - @drawable/reactions_old_background_dark
+ - @drawable/conversation_reaction_overlay_background_dark
+ - @drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light
+
- @color/core_grey_05
- @color/core_grey_90
- @color/core_grey_60
@@ -307,16 +315,17 @@
- @drawable/ic_unlocked_white_24dp
- @drawable/ic_lock_white_24dp
- @drawable/ic_lock_white_18dp
- - @drawable/ic_delete_white_24dp
+ - @drawable/ic_trash_outline_24
- @drawable/ic_select_all_white_24dp
- @drawable/ic_call_split_white_24dp
- @drawable/ic_check_24
- @drawable/ic_refresh_white_24dp
- - @drawable/ic_content_copy_white_24dp
+ - @drawable/ic_copy_outline_24
- @drawable/ic_info_outline_white_24dp
- - @drawable/ic_forward_white_24dp
- - @drawable/ic_download_filled_white_24
- - @drawable/ic_reply_white_24dp
+ - @drawable/ic_forward_outline_24
+ - @drawable/ic_save_24
+ - @drawable/ic_reply_outline_24
+ - @drawable/ic_select_24
- @drawable/ic_message_outline_tinted_24
- @drawable/ic_bell_outline_24
@@ -392,6 +401,8 @@
- @color/core_grey_05
- @color/core_grey_25
+ - @style/Theme.Design.BottomSheetDialog
+
- @style/TextSecure.DarkActionBar
- @style/TextSecure.DarkActionBar.TabBar
- @style/ThemeOverlay.AppCompat.Dark
@@ -500,6 +511,13 @@
- @color/transparent_white_90
- @color/transparent_white_80
+ - @drawable/reactions_recv_background_dark
+ - @drawable/reactions_send_background_dark
+ - @drawable/reactions_old_background_dark
+ - @drawable/conversation_reaction_overlay_background_dark
+ - @drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark
+
+
- @color/core_grey_85
- @color/core_grey_65
- @color/core_grey_75
@@ -537,16 +555,17 @@
- @drawable/ic_unlocked_white_24dp
- @drawable/ic_lock_white_24dp
- @drawable/ic_lock_white_18dp
- - @drawable/ic_delete_white_24dp
+ - @drawable/ic_trash_solid_24
- @drawable/ic_select_all_white_24dp
- @drawable/ic_call_split_white_24dp
- @drawable/ic_check_24
- @drawable/ic_refresh_white_24dp
- - @drawable/ic_content_copy_white_24dp
- - @drawable/ic_info_outline_white_24dp
- - @drawable/ic_forward_white_24dp
- - @drawable/ic_download_filled_white_24
- - @drawable/ic_reply_white_24dp
+ - @drawable/ic_copy_solid_24
+ - @drawable/ic_info_solid_24
+ - @drawable/ic_forward_solid_24
+ - @drawable/ic_save_24
+ - @drawable/ic_reply_solid_24
+ - @drawable/ic_select_24
- @drawable/ic_message_solid_tinted_24
- @drawable/ic_bell_solid_24
diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java
index c602e5427a..f820c652e8 100644
--- a/src/org/thoughtcrime/securesms/BindableConversationItem.java
+++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -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 choices);
void onInviteSharedContactClicked(@NonNull List choices);
+ void onReactionClicked(long messageId, boolean isMms);
}
}
diff --git a/src/org/thoughtcrime/securesms/components/MaskView.java b/src/org/thoughtcrime/securesms/components/MaskView.java
new file mode 100644
index 0000000000..a2b38040b9
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/components/MaskView.java
@@ -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();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java b/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java
new file mode 100644
index 0000000000..7928ad6667
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java
@@ -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));
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
index be0d6de73f..1a86c134ab 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
@@ -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;
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
index b3594b14ec..ddd095883e 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
@@ -139,7 +139,7 @@ public class ConversationAdapter
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
});
itemView.setOnLongClickListener(view -> {
if (clickListener != null) {
- clickListener.onItemLongClick(itemView.getMessageRecord());
+ clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
}
return true;
});
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
index 8ddf6cec1f..d7746202ef 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java
@@ -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 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;
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 6a73382506..7af24e4461 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -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 batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -169,8 +171,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private Stub sharedContactStub;
private Stub linkPreviewStub;
private Stub stickerStub;
- private Stub revealableStub;
+ private Stub 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 next, @NonNull Locale locale, boolean isGroupThread) {
ViewUtil.updateLayoutParams(footer, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java
new file mode 100644
index 0000000000..f5d7041144
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java
@@ -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 reactions) {
+ if (reactions.size() == 0) {
+ hideAllReactions();
+ return;
+ }
+
+ final Collection reactionInfos = getReactionInfos(reactions);
+
+ if (reactionInfos.size() == 1) {
+ displaySingleReaction(reactionInfos.iterator().next());
+ } else {
+ displayMultipleReactions(reactionInfos);
+ }
+ }
+
+ private static @NonNull Collection getReactionInfos(@NonNull List reactions) {
+ final Map 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 reactionInfos) {
+ reactionsContainer.setVisibility(View.VISIBLE);
+ primaryEmojiReaction.setVisibility(View.VISIBLE);
+ secondaryEmojiReaction.setVisibility(View.VISIBLE);
+
+ Pair 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 getPrimaryAndSecondaryReactions(@NonNull Collection 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;
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java
new file mode 100644
index 0000000000..504e7ceb6b
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java
@@ -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 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 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
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java
index 793331ba00..1ae897475b 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java
@@ -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));
diff --git a/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java
index b80e030f30..3ef66d23ca 100644
--- a/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java
+++ b/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java
@@ -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());
}
diff --git a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java
index c8adb01166..178b31eae6 100644
--- a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java
+++ b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java
@@ -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;
diff --git a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java
index 82dfd3317a..18c5f293e1 100644
--- a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java
@@ -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 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 , I> void removeFromDocument(long messageId, String column, I object, Class 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 pruned = Stream.of(reactionList.getReactionsList())
+ .filterNot(r -> r.getAuthor() == recipientId.toLong())
+ .toList();
+
+ return reactionList.toBuilder()
+ .clearReactions()
+ .addAllReactions(pruned)
+ .build();
+ }
+
+ private @NonNull Optional 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;
diff --git a/src/org/thoughtcrime/securesms/database/MmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsDatabase.java
index df29a57d69..f5fe7c0575 100644
--- a/src/org/thoughtcrime/securesms/database/MmsDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/MmsDatabase.java
@@ -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 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 getMismatchedIdentities(String document) {
diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java
index 74b01c5040..ca01455fa7 100644
--- a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java
+++ b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java
@@ -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;
diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
index 898e17c963..27cdbbdc2f 100644
--- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -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 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);
diff --git a/src/org/thoughtcrime/securesms/database/SmsDatabase.java b/src/org/thoughtcrime/securesms/database/SmsDatabase.java
index 6412b5d21d..25927ce3fe 100644
--- a/src/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -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(),
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 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 getMismatches(String document) {
diff --git a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
index 4fa925e41b..5b10037a61 100644
--- a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
@@ -278,6 +278,9 @@ public class ThreadDatabase extends Database {
final List smsRecords = DatabaseFactory.getSmsDatabase(context).setAllMessagesRead();
final List 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 smsRecords = DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId);
final List mmsRecords = DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId);
+ DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId);
+ DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId);
+
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, mmsRecords);
diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index fbb8c10c46..94b3a75d13 100644
--- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -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();
diff --git a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
index 6b3108eca9..81342846ff 100644
--- a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
@@ -57,12 +57,13 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
long expiresIn, long expireStarted,
boolean viewOnce, int readReceiptCount,
@Nullable Quote quote, @NonNull List contacts,
- @NonNull List linkPreviews, boolean unidentified)
+ @NonNull List linkPreviews, boolean unidentified,
+ @NonNull List 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;
}
diff --git a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java
index 025c792af9..c0b423f311 100644
--- a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java
@@ -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 reactions;
MessageRecord(long id, String body, Recipient conversationRecipient,
Recipient individualRecipient, int recipientDeviceId,
@@ -61,7 +62,8 @@ public abstract class MessageRecord extends DisplayRecord {
List mismatches,
List networkFailures,
int subscriptionId, long expiresIn, long expireStarted,
- int readReceiptCount, boolean unidentified)
+ int readReceiptCount, boolean unidentified,
+ @NonNull List 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 getReactions() {
+ return reactions;
+ }
}
diff --git a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
index 88950880af..f595753a85 100644
--- a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
@@ -32,9 +32,10 @@ public abstract class MmsMessageRecord extends MessageRecord {
long expireStarted, boolean viewOnce,
@NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List contacts,
- @NonNull List linkPreviews, boolean unidentified)
+ @NonNull List linkPreviews, boolean unidentified,
+ @NonNull List 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;
diff --git a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
index 7ce9a14803..d15e379569 100644
--- a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
@@ -57,7 +57,8 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new LinkedList(), new LinkedList(), 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;
diff --git a/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java b/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java
new file mode 100644
index 0000000000..c597190c17
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java
@@ -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;
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java
index 2a79ff7f86..a194be387e 100644
--- a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java
@@ -48,12 +48,13 @@ public class SmsMessageRecord extends MessageRecord {
long type, long threadId,
int status, List mismatches,
int subscriptionId, long expiresIn, long expireStarted,
- int readReceiptCount, boolean unidentified)
+ int readReceiptCount, boolean unidentified,
+ @NonNull List 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() {
diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 11b0508ec1..f28203300d 100644
--- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -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());
diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
index 2f076ef1e4..34153eef06 100644
--- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
@@ -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 {
diff --git a/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java
new file mode 100644
index 0000000000..a2baa39492
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java
@@ -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 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 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 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 destinations = Stream.of(recipients).map(Recipient::resolved).toList();
+ List 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 deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp)
+ throws IOException, UntrustedIdentityException
+ {
+ SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
+ List addresses = Stream.of(destinations).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList();
+ List> 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 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 {
+
+ @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 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);
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java
index 9c7dc3105b..f3c419d46a 100644
--- a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java
+++ b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java
@@ -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));
+ }
+ }
}
}
diff --git a/src/org/thoughtcrime/securesms/notifications/NotificationState.java b/src/org/thoughtcrime/securesms/notifications/NotificationState.java
index 48b3e3d584..a060db9f9d 100644
--- a/src/org/thoughtcrime/securesms/notifications/NotificationState.java
+++ b/src/org/thoughtcrime/securesms/notifications/NotificationState.java
@@ -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 notifications = new LinkedList<>();
- private final LinkedHashSet threads = new LinkedHashSet<>();
-
- private int notificationCount = 0;
+ private final Comparator notificationItemComparator = (a, b) -> -Long.compare(a.getTimestamp(), b.getTimestamp());
+ private final List notifications = new LinkedList<>();
+ private final LinkedHashSet 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 getThreads() {
+ public Collection getThreads() {
return threads;
}
@@ -83,7 +83,7 @@ public class NotificationState {
}
public int getMessageCount() {
- return notificationCount;
+ return notifications.size();
}
public List getNotifications() {
@@ -91,12 +91,13 @@ public class NotificationState {
}
public List getNotificationsForThread(long threadId) {
- LinkedList list = new LinkedList<>();
+ List 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;
}
diff --git a/src/org/thoughtcrime/securesms/reactions/EmojiCount.java b/src/org/thoughtcrime/securesms/reactions/EmojiCount.java
new file mode 100644
index 0000000000..c5dcbc6396
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/EmojiCount.java
@@ -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;
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java b/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java
new file mode 100644
index 0000000000..b8e5389abc
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java
@@ -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 {
+
+ private List emojiCountList = Collections.emptyList();
+ private int selectedPosition = -1;
+
+ private final OnEmojiCountSelectedListener onEmojiCountSelectedListener;
+
+ ReactionEmojiCountAdapter(@NonNull OnEmojiCountSelectedListener onEmojiCountSelectedListener) {
+ this.onEmojiCountSelectedListener = onEmojiCountSelectedListener;
+ }
+
+ void updateData(@NonNull List 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);
+ }
+
+}
diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java
new file mode 100644
index 0000000000..0011c41b03
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java
@@ -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 {
+
+ private List data = Collections.emptyList();
+
+ public void updateData(List 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);
+ }
+ }
+ }
+
+}
diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java b/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java
new file mode 100644
index 0000000000..61d8b1bdb9
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java
@@ -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);
+ });
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java b/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java
new file mode 100644
index 0000000000..148cbcc97c
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java
@@ -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 {
+
+ private final long messageId;
+ private final boolean isMms;
+ private final Context appContext;
+
+ private MutableLiveData> 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 onCreateLoader(int id, @Nullable Bundle args) {
+ return isMms ? new MmsMessageRecordCursorLoader(appContext, messageId)
+ : new SmsMessageRecordCursorLoader(appContext, messageId);
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader 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 loader) {
+ // Do nothing?
+ }
+
+ @Override
+ public LiveData> 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;
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java b/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java
new file mode 100644
index 0000000000..710f3f697a
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java
@@ -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 filterEmoji = new MutableLiveData<>();
+
+ public ReactionsViewModel(@NonNull Repository repository) {
+ this.repository = repository;
+ }
+
+ public @NonNull LiveData> 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> 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> lhs, @NonNull Map.Entry> 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 reactions) {
+ return Stream.of(reactions)
+ .max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()))
+ .map(Reaction::getTimestamp)
+ .orElse(-1L);
+ }
+
+ interface Repository {
+ LiveData> getReactions();
+ }
+
+ static final class Factory implements ViewModelProvider.Factory {
+
+ private final Repository repository;
+
+ Factory(@NonNull Repository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ return (T) new ReactionsViewModel(repository);
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/recipients/RecipientId.java b/src/org/thoughtcrime/securesms/recipients/RecipientId.java
index ffa72b1e73..092ee8f022 100644
--- a/src/org/thoughtcrime/securesms/recipients/RecipientId.java
+++ b/src/org/thoughtcrime/securesms/recipients/RecipientId.java
@@ -70,6 +70,10 @@ public class RecipientId implements Parcelable, Comparable {
return String.valueOf(id);
}
+ public long toLong() {
+ return id;
+ }
+
public @NonNull String toQueueKey() {
return "RecipientId::" + id;
}
diff --git a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java
index cf562a3b4c..5860741380 100644
--- a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java
+++ b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java
@@ -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;
diff --git a/src/org/thoughtcrime/securesms/sms/MessageSender.java b/src/org/thoughtcrime/securesms/sms/MessageSender.java
index e55c303c51..9ef692c70e 100644
--- a/src/org/thoughtcrime/securesms/sms/MessageSender.java
+++ b/src/org/thoughtcrime/securesms/sms/MessageSender.java
@@ -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);
diff --git a/src/org/thoughtcrime/securesms/util/AvatarUtil.java b/src/org/thoughtcrime/securesms/util/AvatarUtil.java
new file mode 100644
index 0000000000..f25ec3ad16
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/AvatarUtil.java
@@ -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);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/util/FeatureFlags.java b/src/org/thoughtcrime/securesms/util/FeatureFlags.java
index 4f6823cb6e..89253dcd18 100644
--- a/src/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/src/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -22,4 +22,7 @@ public class FeatureFlags {
/** Set or migrate PIN to KBS */
public static final boolean KBS = false;
-}
+
+ /** Send support for reactions. */
+ public static final boolean REACTION_SENDING = false;
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java b/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java
new file mode 100644
index 0000000000..e721de5fbd
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java
@@ -0,0 +1,36 @@
+package org.thoughtcrime.securesms.util;
+
+import androidx.annotation.NonNull;
+
+import com.annimon.stream.Stream;
+
+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.mms.Slide;
+
+public final class MessageRecordUtil {
+
+ private MessageRecordUtil() {
+ }
+
+ public static boolean isMediaMessage(@NonNull MessageRecord messageRecord) {
+ return messageRecord.isMms() &&
+ !messageRecord.isMmsNotification() &&
+ ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
+ ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null;
+ }
+
+ public static boolean hasSticker(@NonNull MessageRecord messageRecord) {
+ return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null;
+ }
+
+ public static boolean hasSharedContact(@NonNull MessageRecord messageRecord) {
+ return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty();
+ }
+
+ public static boolean hasLocation(@NonNull MessageRecord messageRecord) {
+ return messageRecord.isMms() && !Stream.of(((MmsMessageRecord) messageRecord).getSlideDeck().getSlides())
+ .anyMatch(Slide::hasLocation);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/util/SpanUtil.java b/src/org/thoughtcrime/securesms/util/SpanUtil.java
index 15a0b0c441..8890b6efb5 100644
--- a/src/org/thoughtcrime/securesms/util/SpanUtil.java
+++ b/src/org/thoughtcrime/securesms/util/SpanUtil.java
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util;
import android.graphics.Typeface;
import android.text.Spannable;
import android.text.SpannableString;
+import android.text.style.AbsoluteSizeSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
@@ -25,6 +26,12 @@ public class SpanUtil {
return spannable;
}
+ public static CharSequence ofSize(CharSequence sequence, int size) {
+ SpannableString spannable = new SpannableString(sequence);
+ spannable.setSpan(new AbsoluteSizeSpan(size, true), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return spannable;
+ }
+
public static CharSequence bold(CharSequence sequence) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
diff --git a/src/org/thoughtcrime/securesms/util/ThemeUtil.java b/src/org/thoughtcrime/securesms/util/ThemeUtil.java
index 247b4c56ed..2f88eaa7ea 100644
--- a/src/org/thoughtcrime/securesms/util/ThemeUtil.java
+++ b/src/org/thoughtcrime/securesms/util/ThemeUtil.java
@@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import androidx.annotation.AttrRes;
+import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
@@ -59,6 +60,17 @@ public class ThemeUtil {
return inflater.cloneInContext(contextThemeWrapper);
}
+ public static float getThemedDimen(@NonNull Context context, @AttrRes int attr) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+
+ if (theme.resolveAttribute(attr, typedValue, true)) {
+ return typedValue.getDimension(context.getResources().getDisplayMetrics());
+ }
+
+ return 0;
+ }
+
private static String getAttribute(Context context, int attribute, String defaultValue) {
TypedValue outValue = new TypedValue();
diff --git a/src/org/thoughtcrime/securesms/util/ViewUtil.java b/src/org/thoughtcrime/securesms/util/ViewUtil.java
index d119b05ec0..81f49c5104 100644
--- a/src/org/thoughtcrime/securesms/util/ViewUtil.java
+++ b/src/org/thoughtcrime/securesms/util/ViewUtil.java
@@ -252,4 +252,13 @@ public class ViewUtil {
return x > viewX && x < viewX + view.getWidth() &&
y > viewY && y < viewY + view.getHeight();
}
+
+ public static int getStatusBarHeight(@NonNull View view) {
+ int result = 0;
+ int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android");
+ if (resourceId > 0) {
+ result = view.getResources().getDimensionPixelSize(resourceId);
+ }
+ return result;
+ }
}