diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index 88b4b79dcc..6aa8d41fc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; @@ -50,7 +51,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { PREVIEW(0), OUTGOING(1), INCOMING(2), - STORY_REPLY(3); + STORY_REPLY(3), + STORY_REPLY_PREVIEW(4); private final int code; @@ -178,7 +180,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview); cornerMask.setTopLeftRadius(radius); cornerMask.setTopRightRadius(radius); - } else if (messageType == MessageType.STORY_REPLY) { + } else if (isStoryReply()) { thumbWidth = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_width); thumbHeight = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_height); } @@ -250,9 +252,9 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { private void setQuoteAuthor(@NonNull Recipient author) { boolean outgoing = messageType != MessageType.INCOMING; - boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY; + boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW; - if (messageType == MessageType.STORY_REPLY) { + if (isStoryReply()) { authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_your_story) : getContext().getString(R.string.QuoteView_s_story, author.getDisplayName(getContext()))); } else { @@ -264,12 +266,26 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background)); } - private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { - boolean isTextStory = !attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY; + private boolean isStoryReply() { + return messageType == MessageType.STORY_REPLY || messageType == MessageType.STORY_REPLY_PREVIEW; + } + + private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { + boolean isTextStory = !attachments.containsMediaSlide() && isStoryReply(); + + if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) { + if (isTextStory && body != null) { + try { + bodyView.setText(StoryTextPostModel.parseFrom(body.toString()).getText()); + } catch (Exception e) { + Log.w(TAG, "Could not parse body of text post.", e); + bodyView.setText(""); + } + } else { + bodyView.setText(body == null ? "" : body); + } - if (!isTextStory && (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide())) { bodyView.setVisibility(VISIBLE); - bodyView.setText(body == null ? "" : body); mediaDescriptionText.setVisibility(GONE); return; } @@ -277,11 +293,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { bodyView.setVisibility(GONE); mediaDescriptionText.setVisibility(VISIBLE); - if (isTextStory) { - // TODO [alex] -- Media description. - return; - } - Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null); Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null); Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null); @@ -314,7 +325,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { } private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck) { - if (!attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY) { + if (!attachments.containsMediaSlide() && isStoryReply()) { StoryTextPostModel model = StoryTextPostModel.parseFrom(body.toString()); attachmentVideoOverlayView.setVisibility(GONE); attachmentContainerView.setVisibility(GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 18c62630ee..703eb27842 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.components.Outliner; import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView; import org.thoughtcrime.securesms.components.QuoteView; import org.thoughtcrime.securesms.components.SharedContactView; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.contactshare.Contact; @@ -190,6 +191,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private AlertView alertView; protected ReactionsConversationView reactionsView; protected BadgeImageView badgeImageView; + private View storyReactionLabelWrapper; + private TextView storyReactionLabel; + private EmojiImageView storyReactionEmoji; private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); @@ -271,28 +275,31 @@ public final class ConversationItem extends RelativeLayout implements BindableCo initializeAttributes(); - this.bodyText = findViewById(R.id.conversation_item_body); - this.footer = findViewById(R.id.conversation_item_footer); - this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer); - this.groupSender = findViewById(R.id.group_message_sender); - this.alertView = findViewById(R.id.indicators_parent); - this.contactPhoto = findViewById(R.id.contact_photo); - this.contactPhotoHolder = findViewById(R.id.contact_photo_container); - this.bodyBubble = findViewById(R.id.body_bubble); - this.mediaThumbnailStub = new NullableStub<>(findViewById(R.id.image_view_stub)); - this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); - this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); - this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub)); - this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub)); - this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); - this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub)); - this.callToActionStub = ViewUtil.findStubById(this, R.id.conversation_item_call_to_action_stub); - this.groupSenderHolder = findViewById(R.id.group_sender_holder); - this.quoteView = findViewById(R.id.quote_view); - this.reply = findViewById(R.id.reply_icon_wrapper); - this.replyIcon = findViewById(R.id.reply_icon); - this.reactionsView = findViewById(R.id.reactions_view); - this.badgeImageView = findViewById(R.id.badge); + this.bodyText = findViewById(R.id.conversation_item_body); + this.footer = findViewById(R.id.conversation_item_footer); + this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer); + this.groupSender = findViewById(R.id.group_message_sender); + this.alertView = findViewById(R.id.indicators_parent); + this.contactPhoto = findViewById(R.id.contact_photo); + this.contactPhotoHolder = findViewById(R.id.contact_photo_container); + this.bodyBubble = findViewById(R.id.body_bubble); + this.mediaThumbnailStub = new NullableStub<>(findViewById(R.id.image_view_stub)); + this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); + this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); + this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub)); + this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub)); + this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); + this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub)); + this.callToActionStub = ViewUtil.findStubById(this, R.id.conversation_item_call_to_action_stub); + this.groupSenderHolder = findViewById(R.id.group_sender_holder); + this.quoteView = findViewById(R.id.quote_view); + this.reply = findViewById(R.id.reply_icon_wrapper); + this.replyIcon = findViewById(R.id.reply_icon); + this.reactionsView = findViewById(R.id.reactions_view); + this.badgeImageView = findViewById(R.id.badge); + this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder); + this.storyReactionLabel = findViewById(R.id.story_reacted_label); + this.storyReactionEmoji = findViewById(R.id.story_reaction_emoji); setOnClickListener(new ClickListener(null)); @@ -355,6 +362,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread); setReactions(messageRecord); setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper); + setStoryReactionLabel(messageRecord); if (audioViewStub.resolved()) { audioViewStub.get().setOnLongClickListener(passthroughClickListener); @@ -454,6 +462,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (!updatingFooter && getActiveFooter(messageRecord) == footer && !hasAudio(messageRecord) && + !isStoryReaction(messageRecord) && isFooterVisible(messageRecord, nextMessageRecord, groupThread) && !bodyText.isJumbomoji() && conversationMessage.getBottomButton() == null && @@ -493,8 +502,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } - if (!updatingFooter && ViewUtil.getTopMargin(footer) != defaultTopMargin) { - ViewUtil.setTopMargin(footer, defaultTopMargin); + int defaultTopMarginForRecord = getDefaultTopMarginForRecord(messageRecord, defaultTopMargin, defaultBottomMargin); + if (!updatingFooter && ViewUtil.getTopMargin(footer) != defaultTopMarginForRecord) { + ViewUtil.setTopMargin(footer, defaultTopMarginForRecord); ViewUtil.setBottomMargin(footer, defaultBottomMargin); needsMeasure = true; } @@ -538,6 +548,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private int getDefaultTopMarginForRecord(@NonNull MessageRecord messageRecord, int defaultTopMargin, int defaultBottomMargin) { + if (isStoryReaction(messageRecord)) { + return defaultBottomMargin; + } else { + return defaultTopMargin; + } + } + @Override public void onRecipientChanged(@NonNull Recipient modified) { if (conversationRecipient.getId().equals(modified.getId())) { @@ -837,6 +855,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private boolean isStoryReaction(MessageRecord messageRecord) { + return MessageRecordUtil.isStoryReaction(messageRecord); + } + private boolean isCaptionlessMms(MessageRecord messageRecord) { return MessageRecordUtil.isCaptionlessMms(messageRecord, context); } @@ -914,7 +936,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setText(italics); bodyText.setVisibility(View.VISIBLE); bodyText.setOverflowText(null); - } else if (isCaptionlessMms(messageRecord)) { + } else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord)) { bodyText.setVisibility(View.GONE); } else { Spannable styledText = conversationMessage.getDisplayBody(getContext()); @@ -1524,6 +1546,34 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private void setStoryReactionLabel(@NonNull MessageRecord record) { + if (isStoryReaction(record)) { + storyReactionLabelWrapper.setVisibility(View.VISIBLE); + storyReactionLabel.setTextColor(record.isOutgoing() ? colorizer.getOutgoingBodyTextColor(context) : ContextCompat.getColor(context, R.color.signal_text_primary)); + storyReactionLabel.setText(getStoryReactionLabelText(messageRecord)); + storyReactionEmoji.setImageEmoji(record.getBody()); + storyReactionEmoji.setVisibility(View.VISIBLE); + } else if (storyReactionLabelWrapper != null) { + storyReactionLabelWrapper.setVisibility(View.GONE); + storyReactionEmoji.setVisibility(View.GONE); + } + } + + private @NonNull String getStoryReactionLabelText(@NonNull MessageRecord messageRecord) { + if (hasQuote(messageRecord)) { + MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord; + RecipientId author = mmsMessageRecord.getQuote().getAuthor(); + + if (author.equals(Recipient.self().getId())) { + return context.getString(R.string.ConversationItem__s_dot_story, context.getString(R.string.QuoteView_you)); + } else { + return context.getString(R.string.ConversationItem__s_dot_story, Recipient.resolved(author).getDisplayName(context)); + } + } else { + return context.getString(R.string.ConversationItem__reacted_to_a_story); + } + } + private boolean forceFooter(@NonNull MessageRecord messageRecord) { return hasAudio(messageRecord); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index d65716cf10..eb12e0af62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -2970,7 +2970,7 @@ public class ConversationParentFragment extends Fragment long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null); List mentions = new ArrayList<>(result.getMentions()); - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, quote, Collections.emptyList(), Collections.emptyList(), mentions); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, false, quote, Collections.emptyList(), Collections.emptyList(), mentions); OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message); final Context context = requireContext().getApplicationContext(); @@ -3046,7 +3046,7 @@ public class ConversationParentFragment extends Fragment } } - OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, quote, contacts, previews, mentions); + OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, false, quote, contacts, previews, mentions); final SettableFuture future = new SettableFuture<>(); final Context context = requireContext().getApplicationContext(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 37e90006f0..447cd978ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -1479,7 +1479,23 @@ public class MmsDatabase extends MessageDatabase { return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); } - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, storyType, parentStoryId, quote, contacts, previews, mentions, networkFailures, mismatches); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, + body, + attachments, + timestamp, + subscriptionId, + expiresIn, + viewOnce, + distributionType, + storyType, + parentStoryId, + Types.isStoryReaction(outboxType), + quote, + contacts, + previews, + mentions, + networkFailures, + mismatches); if (Types.isSecureType(outboxType)) { return new OutgoingSecureMediaMessage(message); @@ -1675,6 +1691,10 @@ public class MmsDatabase extends MessageDatabase { type |= Types.EXPIRATION_TIMER_UPDATE_BIT; } + if (retrieved.isStoryReaction()) { + type |= Types.SPECIAL_TYPE_STORY_REACTION; + } + return insertMessageInbox(retrieved, "", threadId, type); } @@ -1779,6 +1799,10 @@ public class MmsDatabase extends MessageDatabase { type |= Types.EXPIRATION_TIMER_UPDATE_BIT; } + if (message.isStoryReaction()) { + type |= Types.SPECIAL_TYPE_STORY_REACTION; + } + Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis()); ContentValues contentValues = new ContentValues(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 17c50e568e..4c304952ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -45,20 +45,21 @@ public interface MmsSmsColumns { * {@link #TOTAL_MASK}. * *
-   *      _____________________________________ ENCRYPTION ({@link #ENCRYPTION_MASK})
-   *     |        _____________________________ SECURE MESSAGE INFORMATION (no mask, but look at {@link #SECURE_MESSAGE_BIT})
-   *     |       |     ________________________ GROUPS (no mask, but look at {@link #GROUP_UPDATE_BIT})
-   *     |       |    |       _________________ KEY_EXCHANGE ({@link #KEY_EXCHANGE_MASK})
-   *     |       |    |      |       _________  MESSAGE_ATTRIBUTES ({@link #MESSAGE_ATTRIBUTE_MASK})
-   *     |       |    |      |      |     ____  BASE_TYPE ({@link #BASE_TYPE_MASK})
-   *  ___|___   _|   _|   ___|__    |  __|_
-   * |       | |  | |  | |       | | ||    |
-   * 0000 0000 0000 0000 0000 0000 0000 0000
+   *   _____________________________________________ SPECIAL TYPES (Story reactions) ({@link #SPECIAL_TYPES_MASK}
+   *   |       _____________________________________ ENCRYPTION ({@link #ENCRYPTION_MASK})
+   *   |      |        _____________________________ SECURE MESSAGE INFORMATION (no mask, but look at {@link #SECURE_MESSAGE_BIT})
+   *   |      |       |     ________________________ GROUPS (no mask, but look at {@link #GROUP_UPDATE_BIT})
+   *   |      |       |    |       _________________ KEY_EXCHANGE ({@link #KEY_EXCHANGE_MASK})
+   *   |      |       |    |      |       _________  MESSAGE_ATTRIBUTES ({@link #MESSAGE_ATTRIBUTE_MASK})
+   *   |      |       |    |      |      |     ____  BASE_TYPE ({@link #BASE_TYPE_MASK})
+   *  _|   ___|___   _|   _|   ___|__    |  __|_
+   * |  | |       | |  | |  | |       | | ||    |
+   * 0000 0000 0000 0000 0000 0000 0000 0000 0000
    * 
*/ public static class Types { - protected static final long TOTAL_MASK = 0xFFFFFFFF; + protected static final long TOTAL_MASK = 0xFFFFFFFFFL; // Base Types protected static final long BASE_TYPE_MASK = 0x1F; @@ -134,6 +135,18 @@ public interface MmsSmsColumns { protected static final long ENCRYPTION_REMOTE_DUPLICATE_BIT = 0x04000000; protected static final long ENCRYPTION_REMOTE_LEGACY_BIT = 0x02000000; + // Special message types + public static final long SPECIAL_TYPES_MASK = 0xF00000000L; + public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L; + + public static boolean isSpecialType(long type) { + return (type & SPECIAL_TYPES_MASK) != 0L; + } + + public static boolean isStoryReaction(long type) { + return (type & SPECIAL_TYPE_STORY_REACTION) == SPECIAL_TYPE_STORY_REACTION; + } + public static boolean isDraftMessageType(long type) { return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 0da2a1d827..efad1fecfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -317,7 +317,14 @@ public final class PushGroupSendJob extends PushSendJob { .filter(r -> r.getStoriesCapability() == Recipient.Capability.SUPPORTED) .collect(java.util.stream.Collectors.toList()); - groupMessageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(recipient.requireServiceId(), storyRecord.getDateSent())); + SignalServiceDataMessage.StoryContext storyContext = new SignalServiceDataMessage.StoryContext(recipient.requireServiceId(), storyRecord.getDateSent()); + groupMessageBuilder.withStoryContext(storyContext); + + Optional reaction = getStoryReactionFor(message, storyContext); + if (reaction.isPresent()) { + groupMessageBuilder.withReaction(reaction.get()); + groupMessageBuilder.withBody(null); + } } catch (NoSuchMessageException e) { // The story has probably expired // TODO [stories] check what should happen in this case diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 9fb7d79716..5112f4f008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -206,9 +206,9 @@ public class PushMediaSendJob extends PushSendJob { SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, messageRecipient); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); - List serviceAttachments = getAttachmentPointersFor(attachments); - Optional profileKey = getProfileKey(messageRecipient); - Optional sticker = getStickerFor(message); + List serviceAttachments = getAttachmentPointersFor(attachments); + Optional profileKey = getProfileKey(messageRecipient); + Optional sticker = getStickerFor(message); List sharedContacts = getSharedContactsFor(message); List previews = getPreviewsFor(message); SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder() @@ -226,7 +226,15 @@ public class PushMediaSendJob extends PushSendJob { if (message.getParentStoryId() != null) { try { MessageRecord storyRecord = SignalDatabase.mms().getMessageRecord(message.getParentStoryId().asMessageId().getId()); - mediaMessageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(address.getServiceId(), storyRecord.getDateSent())); + + SignalServiceDataMessage.StoryContext storyContext = new SignalServiceDataMessage.StoryContext(address.getServiceId(), storyRecord.getDateSent()); + mediaMessageBuilder.withStoryContext(storyContext); + + Optional reaction = getStoryReactionFor(message, storyContext); + if (reaction.isPresent()) { + mediaMessageBuilder.withReaction(reaction.get()); + mediaMessageBuilder.withBody(null); + } } catch (NoSuchMessageException e) { // The story has probably expired // TODO [stories] check what should happen in this case diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 9ce098feea..c033a14bb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -375,6 +375,17 @@ public abstract class PushSendJob extends SendJob { } } + protected Optional getStoryReactionFor(@NonNull OutgoingMediaMessage message, @NonNull SignalServiceDataMessage.StoryContext storyContext) { + if (message.isStoryReaction()) { + return Optional.of(new SignalServiceDataMessage.Reaction( + message.getBody(), + false, + new SignalServiceAddress(storyContext.getAuthorServiceId()), storyContext.getSentTimestamp())); + } else { + return Optional.empty(); + } + } + List getSharedContactsFor(OutgoingMediaMessage mediaMessage) { List sharedContacts = new LinkedList<>(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index fca6712587..16b53bab36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -225,6 +225,7 @@ class MediaSelectionRepository(context: Context) { ThreadDatabase.DistributionTypes.DEFAULT, storyType, null, + false, null, emptyList(), emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt index e92ad6e006..051ed3c0a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt @@ -82,6 +82,7 @@ class TextStoryPostSendRepository(context: Context) { ThreadDatabase.DistributionTypes.DEFAULT, storyType.toTextStoryType(), null, + false, null, emptyList(), listOfNotNull(linkPreview), diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 469350ec7e..fca8297c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -840,6 +840,7 @@ public final class MessageContentProcessor { receivedTime, StoryType.NONE, null, + false, -1, expiresInSeconds * 1000L, true, @@ -873,9 +874,15 @@ public final class MessageContentProcessor { return null; } - private @Nullable MessageId handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) { + private @Nullable MessageId handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { log(content.getTimestamp(), "Handle reaction for message " + message.getReaction().get().getTargetSentTimestamp()); + if (content.getStoryMessage().isPresent()) { + log(content.getTimestamp(), "Reaction has a story context. Treating as a story reaction."); + handleStoryReaction(content, message, senderRecipient); + return null; + } + SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); if (!EmojiUtil.isEmoji(reaction.getEmoji())) { @@ -1350,6 +1357,7 @@ public final class MessageContentProcessor { System.currentTimeMillis(), storyType, null, + false, -1, 0, false, @@ -1448,6 +1456,85 @@ public final class MessageContentProcessor { return Base64.encodeBytes(builder.build().toByteArray()); } + private void handleStoryReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { + log(content.getTimestamp(), "Story reaction."); + + if (!Stories.isFeatureAvailable()) { + warn(content.getTimestamp(), "Dropping unsupported story reaction."); + return; + } + + SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); + + if (!EmojiUtil.isEmoji(reaction.getEmoji())) { + warn(content.getTimestamp(), "Story reaction text is not a valid emoji! Ignoring the message."); + return; + } + + SignalServiceDataMessage.StoryContext storyContext = message.getStoryContext().get(); + + MessageDatabase database = SignalDatabase.mms(); + database.beginTransaction(); + + try { + RecipientId storyAuthorRecipient = RecipientId.from(storyContext.getAuthorServiceId(), null); + ParentStoryId parentStoryId; + QuoteModel quoteModel = null; + try { + MessageId storyMessageId = database.getStoryId(storyAuthorRecipient, storyContext.getSentTimestamp()); + + if (message.getGroupContext().isPresent()) { + parentStoryId = new ParentStoryId.GroupReply(storyMessageId.getId()); + } else { + MmsMessageRecord story = (MmsMessageRecord) database.getMessageRecord(storyMessageId.getId()); + + if (!story.getStoryType().isStoryWithReplies()) { + warn(content.getTimestamp(), "Story has reactions disabled. Dropping reaction."); + return; + } + + parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, "", false, story.getSlideDeck().asAttachments(), Collections.emptyList()); + } + } catch (NoSuchMessageException e) { + warn(content.getTimestamp(), "Couldn't find story for reaction.", e); + return; + } + + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + System.currentTimeMillis(), + StoryType.NONE, + parentStoryId, + true, + -1, + 0, + false, + false, + content.isNeedsReceipt(), + Optional.of(reaction.getEmoji()), + Optional.ofNullable(GroupUtil.getGroupContextIfPresent(content)), + Optional.empty(), + Optional.ofNullable(quoteModel), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + content.getServerUuid()); + + Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + + if (insertResult.isPresent()) { + database.setTransactionSuccessful(); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } finally { + database.endTransaction(); + } + } + private void handleStoryReply(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { log(content.getTimestamp(), "Story reply."); @@ -1492,6 +1579,7 @@ public final class MessageContentProcessor { System.currentTimeMillis(), StoryType.NONE, parentStoryId, + false, -1, 0, false, @@ -1549,6 +1637,7 @@ public final class MessageContentProcessor { receivedTime, StoryType.NONE, null, + false, -1, TimeUnit.SECONDS.toMillis(message.getExpiresInSeconds()), false, @@ -1655,6 +1744,7 @@ public final class MessageContentProcessor { ThreadDatabase.DistributionTypes.DEFAULT, StoryType.NONE, null, + false, quote.orElse(null), sharedContacts.orElse(Collections.emptyList()), previews.orElse(Collections.emptyList()), @@ -1849,6 +1939,7 @@ public final class MessageContentProcessor { ThreadDatabase.DistributionTypes.DEFAULT, StoryType.NONE, null, + false, null, Collections.emptyList(), Collections.emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt index f36eeebbe4..6dd119779e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt @@ -22,6 +22,7 @@ class IncomingMediaMessage( val isPushMessage: Boolean = false, val storyType: StoryType = StoryType.NONE, val parentStoryId: ParentStoryId? = null, + val isStoryReaction: Boolean = false, val sentTimeMillis: Long, val serverTimeMillis: Long, val receivedTimeMillis: Long, @@ -86,6 +87,7 @@ class IncomingMediaMessage( receivedTimeMillis: Long, storyType: StoryType, parentStoryId: ParentStoryId?, + isStoryReaction: Boolean, subscriptionId: Int, expiresIn: Long, expirationUpdate: Boolean, @@ -107,6 +109,7 @@ class IncomingMediaMessage( isPushMessage = true, storyType = storyType, parentStoryId = parentStoryId, + isStoryReaction = isStoryReaction, sentTimeMillis = sentTimeMillis, serverTimeMillis = serverTimeMillis, receivedTimeMillis = receivedTimeMillis, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java index 49b2b0b2c0..404e281dd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java @@ -19,6 +19,7 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage false, StoryType.NONE, null, + false, null, Collections.emptyList(), Collections.emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java index 29560abc1e..e775033697 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java @@ -41,6 +41,7 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage viewOnce, StoryType.NONE, null, + false, quote, contacts, previews, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index 275fe12faa..a6dd30e5ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -33,6 +33,7 @@ public class OutgoingMediaMessage { private final QuoteModel outgoingQuote; private final StoryType storyType; private final ParentStoryId parentStoryId; + private final boolean isStoryReaction; private final Set networkFailures = new HashSet<>(); private final Set identityKeyMismatches = new HashSet<>(); @@ -50,6 +51,7 @@ public class OutgoingMediaMessage { int distributionType, @NonNull StoryType storyType, @Nullable ParentStoryId parentStoryId, + boolean isStoryReaction, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews, @@ -68,6 +70,7 @@ public class OutgoingMediaMessage { this.outgoingQuote = outgoingQuote; this.storyType = storyType; this.parentStoryId = parentStoryId; + this.isStoryReaction = isStoryReaction; this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); @@ -86,6 +89,7 @@ public class OutgoingMediaMessage { int distributionType, @NonNull StoryType storyType, @Nullable ParentStoryId parentStoryId, + boolean isStoryReaction, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews, @@ -101,6 +105,7 @@ public class OutgoingMediaMessage { distributionType, storyType, parentStoryId, + isStoryReaction, outgoingQuote, contacts, linkPreviews, @@ -121,6 +126,7 @@ public class OutgoingMediaMessage { this.outgoingQuote = that.outgoingQuote; this.storyType = that.storyType; this.parentStoryId = that.parentStoryId; + this.isStoryReaction = that.isStoryReaction; this.identityKeyMismatches.addAll(that.identityKeyMismatches); this.networkFailures.addAll(that.networkFailures); @@ -141,6 +147,7 @@ public class OutgoingMediaMessage { distributionType, storyType, parentStoryId, + isStoryReaction, outgoingQuote, contacts, linkPreviews, @@ -202,6 +209,10 @@ public class OutgoingMediaMessage { return parentStoryId; } + public boolean isStoryReaction() { + return isStoryReaction; + } + public @Nullable QuoteModel getOutgoingQuote() { return outgoingQuote; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java index 76dab35fd5..dd546c2d92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java @@ -25,12 +25,13 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { boolean viewOnce, @NonNull StoryType storyType, @Nullable ParentStoryId parentStoryId, + boolean isStoryReaction, @Nullable QuoteModel quote, @NonNull List contacts, @NonNull List previews, @NonNull List mentions) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, storyType, parentStoryId, quote, contacts, previews, mentions, Collections.emptySet(), Collections.emptySet()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, storyType, parentStoryId, isStoryReaction, quote, contacts, previews, mentions, Collections.emptySet(), Collections.emptySet()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { @@ -53,6 +54,7 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { isViewOnce(), getStoryType(), getParentStoryId(), + isStoryReaction(), getOutgoingQuote(), getSharedContacts(), getLinkPreviews(), @@ -69,6 +71,7 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { isViewOnce(), getStoryType(), getParentStoryId(), + isStoryReaction(), getOutgoingQuote(), getSharedContacts(), getLinkPreviews(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 338d6010ea..b27a2578e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -89,6 +89,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver { 0, StoryType.NONE, null, + false, null, Collections.emptyList(), Collections.emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index b73f91a726..2ffe235a58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -198,6 +198,7 @@ public final class MultiShareSender { ThreadDatabase.DistributionTypes.DEFAULT, storyType, null, + false, null, Collections.emptyList(), multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) @@ -221,6 +222,7 @@ public final class MultiShareSender { ThreadDatabase.DistributionTypes.DEFAULT, StoryType.NONE, null, + false, null, Collections.emptyList(), multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt index d0357bc8ca..e272c1a6d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt @@ -25,6 +25,8 @@ data class StoryTextPostModel( messageDigest.update(storyTextPost.toByteArray()) } + val text: String = storyTextPost.body + companion object { @JvmStatic fun parseFrom(body: String): StoryTextPostModel { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt index 4b8e003a79..b8148ef9ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt @@ -3,13 +3,10 @@ package org.thoughtcrime.securesms.stories.viewer.reply.composer import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.annotation.SuppressLint -import android.app.Dialog import android.content.Context import android.util.AttributeSet import android.view.View -import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.animation.addListener import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -24,13 +21,12 @@ class StoryReactionBar @JvmOverloads constructor( private var animatorSet: AnimatorSet? = null - private val emojiVerticalTranslation = context.resources.getDimensionPixelSize(R.dimen.reaction_scrubber_anim_start_translation_y) - init { inflate(context, R.layout.stories_reaction_bar, this) + alpha = 0f + setBackgroundResource(R.drawable.conversation_reaction_overlay_background) } - private val background: View = findViewById(R.id.conversation_reaction_scrubber_background) private val emojiViews: List = listOf( findViewById(R.id.reaction_1), findViewById(R.id.reaction_2), @@ -55,10 +51,14 @@ class StoryReactionBar @JvmOverloads constructor( } } } + + setOnClickListener { + callback?.onTouchOutsideOfReactionBar() + } } @SuppressLint("Recycle") - fun show() { + fun animateIn() { visible = true animatorSet?.cancel() @@ -67,7 +67,7 @@ class StoryReactionBar @JvmOverloads constructor( playTogether( emojiViews.flatMap { listOf(ObjectAnimator.ofFloat(it, View.ALPHA, 1f), ObjectAnimator.ofFloat(it, View.TRANSLATION_Y, 0f)) - } + ObjectAnimator.ofFloat(background, View.ALPHA, 1f) + } + ObjectAnimator.ofFloat(this@StoryReactionBar, View.ALPHA, 1f) ) start() @@ -75,58 +75,16 @@ class StoryReactionBar @JvmOverloads constructor( } private fun onEmojiSelected(emoji: String) { - // TODO [stories] -- Animation / Haptics - hide() callback?.onReactionSelected(emoji) } private fun onOpenReactionPicker() { - // TODO [stories] -- Animation / Haptics - hide() callback?.onOpenReactionPicker() } - @SuppressLint("Recycle") - private fun hide() { - animatorSet?.cancel() - animatorSet = AnimatorSet().apply { - - playTogether( - emojiViews.flatMap { - listOf( - ObjectAnimator.ofFloat(it, View.ALPHA, 0f), - ObjectAnimator.ofFloat(it, View.TRANSLATION_Y, emojiVerticalTranslation.toFloat()) - ) - } + ObjectAnimator.ofFloat(background, View.ALPHA, 0f) - ) - - addListener(onEnd = { - visible = false - }) - start() - } - } - interface Callback { + fun onTouchOutsideOfReactionBar() fun onReactionSelected(emoji: String) fun onOpenReactionPicker() } - - companion object { - fun installIntoBottomSheet(context: Context, dialog: Dialog): StoryReactionBar { - val container: ViewGroup = dialog.findViewById(R.id.container) - - val oldReactionBar: StoryReactionBar? = container.findViewById(R.id.reaction_bar) - if (oldReactionBar != null) { - return oldReactionBar - } - - val reactionBar = StoryReactionBar(context) - - reactionBar.id = R.id.reaction_bar - - container.addView(reactionBar) - return reactionBar - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt index ada21cb053..7e70e5a782 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt @@ -30,11 +30,11 @@ class StoryReplyComposer @JvmOverloads constructor( private val inputAwareLayout: InputAwareLayout private val quoteView: QuoteView - private val reactionButton: View private val privacyChrome: TextView private val emojiDrawerToggle: EmojiToggle private val emojiDrawer: MediaKeyboard + val reactionButton: View val input: ComposeText var isRequestingEmojiDrawer: Boolean = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt index 796fd13b58..5c51d37d7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt @@ -17,10 +17,12 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer +import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.ViewUtil @@ -31,7 +33,8 @@ class StoryDirectReplyDialogFragment : KeyboardEntryDialogFragment(R.layout.stories_reply_to_story_fragment), EmojiKeyboardPageFragment.Callback, EmojiEventListener, - EmojiSearchFragment.Callback { + EmojiSearchFragment.Callback, + ReactWithAnyEmojiBottomSheetDialogFragment.Callback { private val lifecycleDisposable = LifecycleDisposable() @@ -49,7 +52,7 @@ class StoryDirectReplyDialogFragment : ownerProducer = { requireParentFragment() } ) - private lateinit var input: StoryReplyComposer + private lateinit var composer: StoryReplyComposer private val storyId: Long get() = requireArguments().getLong(ARG_STORY_ID) @@ -60,14 +63,12 @@ class StoryDirectReplyDialogFragment : override val withDim: Boolean = true override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val reactionBar: StoryReactionBar = view.findViewById(R.id.reaction_bar) - lifecycleDisposable.bindTo(viewLifecycleOwner) - input = view.findViewById(R.id.input) - input.callback = object : StoryReplyComposer.Callback { + composer = view.findViewById(R.id.input) + composer.callback = object : StoryReplyComposer.Callback { override fun onSendActionClicked() { - lifecycleDisposable += viewModel.send(input.consumeInput().first) + lifecycleDisposable += viewModel.sendReply(composer.consumeInput().first) .observeOn(AndroidSchedulers.mainThread()) .subscribe { Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__reply_sent, Toast.LENGTH_LONG).show() @@ -76,7 +77,26 @@ class StoryDirectReplyDialogFragment : } override fun onPickReactionClicked() { - reactionBar.show() + displayInDialogAboveAnchor(composer.reactionButton, R.layout.stories_reaction_bar_layout) { dialog, view -> + view.findViewById(R.id.reaction_bar).apply { + callback = object : StoryReactionBar.Callback { + override fun onTouchOutsideOfReactionBar() { + dialog.dismiss() + } + + override fun onReactionSelected(emoji: String) { + dialog.dismiss() + sendReaction(emoji) + } + + override fun onOpenReactionPicker() { + dialog.dismiss() + ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null) + } + } + animateIn() + } + } } override fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) { @@ -89,11 +109,11 @@ class StoryDirectReplyDialogFragment : viewModel.state.observe(viewLifecycleOwner) { state -> if (state.recipient != null) { - input.displayPrivacyChrome(state.recipient) + composer.displayPrivacyChrome(state.recipient) } if (state.storyRecord != null) { - input.setQuote(state.storyRecord as MediaMmsMessageRecord) + composer.setQuote(state.storyRecord as MediaMmsMessageRecord) } } } @@ -101,21 +121,21 @@ class StoryDirectReplyDialogFragment : override fun onResume() { super.onResume() - ViewUtil.focusAndShowKeyboard(input) + ViewUtil.focusAndShowKeyboard(composer) } override fun onPause() { super.onPause() - ViewUtil.hideKeyboard(requireContext(), input) + ViewUtil.hideKeyboard(requireContext(), composer) } override fun openEmojiSearch() { - input.openEmojiSearch() + composer.openEmojiSearch() } override fun onKeyboardHidden() { - if (!input.isRequestingEmojiDrawer) { + if (!composer.isRequestingEmojiDrawer) { super.onKeyboardHidden() } } @@ -141,12 +161,28 @@ class StoryDirectReplyDialogFragment : } override fun onEmojiSelected(emoji: String?) { - input.onEmojiSelected(emoji) + composer.onEmojiSelected(emoji) } override fun closeEmojiSearch() { - input.closeEmojiSearch() + composer.closeEmojiSearch() } override fun onKeyEvent(keyEvent: KeyEvent?) = Unit + + override fun onReactWithAnyEmojiDialogDismissed() = Unit + + override fun onReactWithAnyEmojiSelected(emoji: String) { + sendReaction(emoji) + } + + private fun sendReaction(emoji: String) { + lifecycleDisposable += viewModel.sendReaction(emoji) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + // TODO [alex] -- Reaction explosion animation instead of toast. + Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__reaction_sent, Toast.LENGTH_LONG).show() + dismissAllowingStateLoss() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt index 6927893909..3b8520e0c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt @@ -25,7 +25,7 @@ class StoryDirectReplyRepository(context: Context) { }.subscribeOn(Schedulers.io()) } - fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable { + fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence, isReaction: Boolean): Completable { return Completable.create { emitter -> val message = SignalDatabase.mms.getMessageRecord(storyId) as MediaMmsMessageRecord val (recipient, threadId) = if (groupDirectReplyRecipientId == null) { @@ -35,14 +35,10 @@ class StoryDirectReplyRepository(context: Context) { resolved to SignalDatabase.threads.getOrCreateThreadIdFor(resolved) } - val quoteAuthor: Recipient = if (message.isOutgoing) { - Recipient.self() - } else { - message.individualRecipient - } - - if (!quoteAuthor.serviceId.isPresent || !quoteAuthor.e164.isPresent) { - throw AssertionError("Bad quote author.") + val quoteAuthor: Recipient = when { + groupDirectReplyRecipientId != null -> message.recipient + message.isOutgoing -> Recipient.self() + else -> message.individualRecipient } MessageSender.send( @@ -58,6 +54,7 @@ class StoryDirectReplyRepository(context: Context) { 0, StoryType.NONE, ParentStoryId.DirectReply(storyId), + isReaction, QuoteModel(message.dateSent, quoteAuthor.id, message.body, false, message.slideDeck.asAttachments(), null), emptyList(), emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt index 096367a421..5d5f12f18c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt @@ -33,8 +33,12 @@ class StoryDirectReplyViewModel( } } - fun send(charSequence: CharSequence): Completable { - return repository.send(storyId, groupDirectReplyRecipientId, charSequence) + fun sendReply(charSequence: CharSequence): Completable { + return repository.send(storyId, groupDirectReplyRecipientId, charSequence, false) + } + + fun sendReaction(emoji: CharSequence): Completable { + return repository.send(storyId, groupDirectReplyRecipientId, emoji, true) } override fun onCleared() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt index dee9af946f..4ff84cbf5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -1,11 +1,11 @@ package org.thoughtcrime.securesms.stories.viewer.reply.group -import android.database.Cursor import org.signal.paging.PagedDataSource import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient @@ -20,7 +20,7 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour cursor.moveToPosition(start - 1) val reader = MmsDatabase.Reader(cursor) while (cursor.moveToNext() && cursor.position < start + length) { - results.add(readRowFromRecord(reader.current)) + results.add(readRowFromRecord(reader.current as MmsMessageRecord)) } } @@ -35,21 +35,30 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour return data.key } - private fun readRowFromRecord(record: MessageRecord): StoryGroupReplyItemData { - return readMessageRecordFromCursor(record) + private fun readRowFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData { + return if (MmsSmsColumns.Types.isStoryReaction(record.type)) { + readReactionFromRecord(record) + } else { + readTextFromRecord(record) + } } - private fun readReactionFromCursor(cursor: Cursor): StoryGroupReplyItemData { - throw NotImplementedError("TODO -- Need to know what the special story reaction record looks like.") - } - - private fun readMessageRecordFromCursor(messageRecord: MessageRecord): StoryGroupReplyItemData { + private fun readReactionFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData { return StoryGroupReplyItemData( - key = StoryGroupReplyItemData.Key.Text(messageRecord.id), - sender = if (messageRecord.isOutgoing) Recipient.self() else messageRecord.individualRecipient, - sentAtMillis = messageRecord.dateSent, + key = StoryGroupReplyItemData.Key.Reaction(record.id), + sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient, + sentAtMillis = record.dateSent, + replyBody = StoryGroupReplyItemData.ReplyBody.Reaction(record.body) + ) + } + + private fun readTextFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData { + return StoryGroupReplyItemData( + key = StoryGroupReplyItemData.Key.Text(record.id), + sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient, + sentAtMillis = record.dateSent, replyBody = StoryGroupReplyItemData.ReplyBody.Text( - ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), messageRecord) + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record) ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index 2130f040f1..2898605101 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPager import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer import org.thoughtcrime.securesms.util.DeleteDialog +import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.ServiceUtil @@ -49,7 +50,6 @@ class StoryGroupReplyFragment : StoryViewsAndRepliesPagerChild, BottomSheetBehaviorDelegate, StoryReplyComposer.Callback, - StoryReactionBar.Callback, EmojiKeyboardCallback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback { @@ -79,12 +79,10 @@ class StoryGroupReplyFragment : private lateinit var recyclerView: RecyclerView private lateinit var composer: StoryReplyComposer - private lateinit var reactionBar: StoryReactionBar override fun onViewCreated(view: View, savedInstanceState: Bundle?) { recyclerView = view.findViewById(R.id.recycler) composer = view.findViewById(R.id.composer) - reactionBar = view.findViewById(R.id.reaction_bar) lifecycleDisposable.bindTo(viewLifecycleOwner) @@ -98,7 +96,6 @@ class StoryGroupReplyFragment : StoryGroupReplyItem.register(adapter) composer.callback = this - reactionBar.callback = this onPageSelected(findListener()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.REPLIES) @@ -189,7 +186,6 @@ class StoryGroupReplyFragment : val inputProjection = Projection.relativeToViewRoot(composer, null) val parentProjection = Projection.relativeToViewRoot(bottomSheet.parent as ViewGroup, null) composer.translationY = (parentProjection.height + parentProjection.y - (inputProjection.y + inputProjection.height)) - reactionBar.translationY = composer.translationY inputProjection.release() parentProjection.release() } @@ -204,23 +200,38 @@ class StoryGroupReplyFragment : } override fun onPickReactionClicked() { - reactionBar.show() + displayInDialogAboveAnchor(composer.reactionButton, R.layout.stories_reaction_bar_layout) { dialog, view -> + view.findViewById(R.id.reaction_bar).apply { + callback = object : StoryReactionBar.Callback { + override fun onTouchOutsideOfReactionBar() { + dialog.dismiss() + } + + override fun onReactionSelected(emoji: String) { + dialog.dismiss() + sendReaction(emoji) + } + + override fun onOpenReactionPicker() { + dialog.dismiss() + ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null) + } + } + animateIn() + } + } } override fun onEmojiSelected(emoji: String?) { composer.onEmojiSelected(emoji) } - override fun onReactionSelected(emoji: String) { + private fun sendReaction(emoji: String) { lifecycleDisposable += StoryGroupReplySender.sendReaction(requireContext(), storyId, emoji).subscribe() } override fun onKeyEvent(keyEvent: KeyEvent?) = Unit - override fun onOpenReactionPicker() { - ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null) - } - override fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) { keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) mediaKeyboard.setFragmentManager(childFragmentManager) @@ -238,7 +249,7 @@ class StoryGroupReplyFragment : } override fun onReactWithAnyEmojiSelected(emoji: String) { - onReactionSelected(emoji) + sendReaction(emoji) } override fun onHeightChanged(height: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt index 4d10143406..d8f095ad1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt @@ -14,7 +14,16 @@ import org.thoughtcrime.securesms.sms.MessageSender * Stateless message sender for Story Group replies and reactions. */ object StoryGroupReplySender { + fun sendReply(context: Context, storyId: Long, body: CharSequence, mentions: List): Completable { + return sendInternal(context, storyId, body, mentions, false) + } + + fun sendReaction(context: Context, storyId: Long, emoji: String): Completable { + return sendInternal(context, storyId, emoji, emptyList(), true) + } + + private fun sendInternal(context: Context, storyId: Long, body: CharSequence, mentions: List, isReaction: Boolean): Completable { return Completable.create { val message = SignalDatabase.mms.getMessageRecord(storyId) @@ -33,6 +42,7 @@ object StoryGroupReplySender { 0, StoryType.NONE, ParentStoryId.GroupReply(message.id), + isReaction, null, emptyList(), emptyList(), @@ -48,9 +58,4 @@ object StoryGroupReplySender { } }.subscribeOn(Schedulers.io()) } - - fun sendReaction(context: Context, storyId: Long, emoji: String): Completable { - // TODO [stories] - return Completable.complete() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt index 45fcbd3899..76c70dce89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt @@ -1,13 +1,10 @@ package org.thoughtcrime.securesms.stories.viewer.text import android.annotation.SuppressLint -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import org.signal.core.util.DimensionUnit @@ -17,9 +14,8 @@ import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment import org.thoughtcrime.securesms.stories.StoryTextPostView import org.thoughtcrime.securesms.stories.viewer.page.StoryPost import org.thoughtcrime.securesms.util.CommunicationActions -import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor import org.thoughtcrime.securesms.util.fragments.requireListener -import kotlin.math.roundToInt class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview_fragment) { @@ -85,29 +81,7 @@ class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight) - val alertDialog = AlertDialog.Builder(requireContext()) - .setView(contentView) - .create() - - alertDialog.window!!.attributes = alertDialog.window!!.attributes.apply { - val rootProjection = Projection.relativeToViewRoot(view.rootView, null) - val viewProjection = Projection.relativeToViewRoot(view, null).translateY(view.translationY) - - val dialogBottom = rootProjection.height / 2f + contentView.measuredHeight / 2f - val linkPreviewViewTop = viewProjection.y - - rootProjection.release() - viewProjection.release() - - val delta = linkPreviewViewTop - dialogBottom - this.y = delta.roundToInt() - } - alertDialog.window!!.setDimAmount(0f) - alertDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - alertDialog.setOnDismissListener { - requireListener().setIsDisplayingLinkPreviewTooltip(false) - } - alertDialog.show() + displayInDialogAboveAnchor(view, contentView, windowDim = 0f) } interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt new file mode 100644 index 0000000000..5ebd0c47a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.util + +import android.content.DialogInterface +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.stories.viewer.text.StoryTextPostPreviewFragment +import org.thoughtcrime.securesms.util.fragments.requireListener + +/** + * Helper functions to display custom views in AlertDialogs anchored to the top of the specified view. + */ +object FragmentDialogs { + + fun Fragment.displayInDialogAboveAnchor( + anchorView: View, + @LayoutRes contentLayoutId: Int, + windowDim: Float = -1f, + onShow: (DialogInterface, View) -> Unit = { _, _ -> } + ): DialogInterface { + val contentView = LayoutInflater.from(anchorView.context).inflate(contentLayoutId, requireView() as ViewGroup, false) + + contentView.measure( + View.MeasureSpec.makeMeasureSpec(contentView.layoutParams.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(contentView.layoutParams.height, View.MeasureSpec.EXACTLY) + ) + + contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight) + + return displayInDialogAboveAnchor(anchorView, contentView, windowDim, onShow) + } + + fun Fragment.displayInDialogAboveAnchor( + anchorView: View, + contentView: View, + windowDim: Float = -1f, + onShow: (DialogInterface, View) -> Unit = { _, _ -> } + ): DialogInterface { + val alertDialog = AlertDialog.Builder(requireContext()) + .setView(contentView) + .create() + + alertDialog.window!!.attributes = alertDialog.window!!.attributes.apply { + val viewProjection = Projection.relativeToViewRoot(anchorView, null).translateY(anchorView.translationY) + this.y = (viewProjection.y - contentView.height).toInt() + this.gravity = Gravity.TOP + + viewProjection.release() + } + + if (windowDim >= 0f) { + alertDialog.window!!.setDimAmount(windowDim) + } + + alertDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + alertDialog.setOnDismissListener { + requireListener().setIsDisplayingLinkPreviewTooltip(false) + } + + alertDialog.setOnShowListener { onShow(alertDialog, contentView) } + + alertDialog.show() + + return alertDialog + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt index 567df30320..dd873d174b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.util import android.content.Context import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord @@ -36,6 +37,9 @@ fun MessageRecord.isCaptionlessMms(context: Context): Boolean = fun MessageRecord.hasThumbnail(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null +fun MessageRecord.isStoryReaction(): Boolean = + isMms && MmsSmsColumns.Types.isStoryReaction((this as MmsMessageRecord).type) + fun MessageRecord.isBorderless(context: Context): Boolean { return isCaptionlessMms(context) && hasThumbnail() && diff --git a/app/src/main/res/layout/conversation_item_received_multimedia.xml b/app/src/main/res/layout/conversation_item_received_multimedia.xml index bbc40a47d0..36294393dd 100644 --- a/app/src/main/res/layout/conversation_item_received_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_received_multimedia.xml @@ -235,6 +235,18 @@ + + + android:orientation="horizontal" + android:paddingEnd="@dimen/conversation_individual_right_gutter"> @@ -46,6 +46,31 @@ android:orientation="vertical" tools:backgroundTint="@color/core_grey_05"> + + + + + + + + + android:orientation="vertical" + android:padding="8dp" /> - \ No newline at end of file diff --git a/app/src/main/res/layout/stories_reaction_bar.xml b/app/src/main/res/layout/stories_reaction_bar.xml index 6dcf03520d..20988ae004 100644 --- a/app/src/main/res/layout/stories_reaction_bar.xml +++ b/app/src/main/res/layout/stories_reaction_bar.xml @@ -2,135 +2,106 @@ - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + app:layout_constraintStart_toEndOf="@id/reaction_6" + app:layout_constraintTop_toTopOf="parent" + tools:alpha="1" + tools:translationY="0dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/stories_reaction_bar_layout.xml b/app/src/main/res/layout/stories_reaction_bar_layout.xml new file mode 100644 index 0000000000..0d9285bb46 --- /dev/null +++ b/app/src/main/res/layout/stories_reaction_bar_layout.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/stories_reply_to_story_composer_content.xml b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml index 9c7ab22f69..4389546ef5 100644 --- a/app/src/main/res/layout/stories_reply_to_story_composer_content.xml +++ b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml @@ -49,7 +49,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:message_type="story_reply" + app:message_type="story_reply_preview" app:quote_colorPrimary="@color/signal_text_primary" app:quote_colorSecondary="@color/signal_text_primary" tools:visibility="visible" /> @@ -91,8 +91,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_marginEnd="6dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toTopOf="@id/emoji_drawer_stub" + app:layout_constraintBottom_toBottomOf="@id/bubble" app:layout_constraintEnd_toEndOf="parent"> + app:srcCompat="@drawable/ic_add_reaction_outline_24" + app:tint="@color/signal_icon_tint_primary" /> - - \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 68c438f097..6b522a9c1a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -176,6 +176,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07a26d7585..4fa85aa261 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4606,6 +4606,8 @@ … See More Reply sent + + Reaction sent This story is no longer available. @@ -4622,6 +4624,11 @@ Turn off + + %1$s · Story + + Reacted to a story + View more diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt index 22895b7ef9..5776f41617 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt @@ -49,6 +49,8 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.OUTGOING_VIDEO_CA import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.PROFILE_CHANGE_TYPE import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.PUSH_MESSAGE_BIT import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SECURE_MESSAGE_BIT +import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPES_MASK +import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPE_STORY_REACTION import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.UNSUPPORTED_MESSAGE_TYPE object MessageBitmaskColumnTransformer : ColumnTransformer { @@ -108,6 +110,8 @@ object MessageBitmaskColumnTransformer : ColumnTransformer { isChangeNumber:${type == CHANGE_NUMBER_TYPE} isBoostRequest:${type == BOOST_REQUEST_TYPE} isGroupV2LeaveOnly:${type and GROUP_V2_LEAVE_BITS == GROUP_V2_LEAVE_BITS} + isSpecialType:${type and SPECIAL_TYPES_MASK != 0L} + isStoryReaction:${type and SPECIAL_TYPE_STORY_REACTION == SPECIAL_TYPE_STORY_REACTION} """.trimIndent() return "$type

" + describe.replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").replace("\n", "
") diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt index 2aff540d95..81ebb4a302 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt @@ -39,6 +39,7 @@ object TestMms { distributionType, storyType, null, + false, null, emptyList(), emptyList(),