From ff8d7fa6c237797f4d5cebe23d49c046e83971a8 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 9 Mar 2022 13:11:56 -0400 Subject: [PATCH] Add send/recv/render support for text stories. --- .../securesms/components/QuoteView.java | 27 +++- .../securesms/components/ThumbnailView.java | 26 ++++ .../forward/MultiselectForwardRepository.kt | 3 +- .../securesms/database/model/StoryType.kt | 40 +++++- .../thoughtcrime/securesms/fonts/TextFont.kt | 15 +++ .../jobs/PushDistributionListSendJob.java | 24 ++-- .../securesms/jobs/PushGroupSendJob.java | 8 +- .../v2/text/TextStoryBackgroundColors.kt | 16 +-- .../v2/text/send/TextStoryPostSendFragment.kt | 10 +- .../text/send/TextStoryPostSendRepository.kt | 112 ++++++++++++++++- .../v2/text/send/TextStoryPostSendResult.kt | 8 ++ .../text/send/TextStoryPostSendViewModel.kt | 23 +++- .../messages/MessageContentProcessor.java | 84 +++++++++++-- .../securesms/messages/StorySendUtil.kt | 58 +++++++++ .../securesms/mms/SignalGlideComponents.java | 3 + .../securesms/sms/MessageSender.java | 30 +++++ .../securesms/stories/StoryLinkPreviewView.kt | 10 +- .../securesms/stories/StoryTextPostModel.kt | 60 +++++++++ .../securesms/stories/StoryTextPostView.kt | 43 ++++++- .../stories/landing/StoriesLandingItem.kt | 19 ++- .../securesms/stories/my/MyStoriesItem.kt | 13 +- .../stories/viewer/page/StoryPost.kt | 24 +++- .../viewer/page/StoryViewerPageFragment.kt | 41 +++++-- .../viewer/page/StoryViewerPageRepository.kt | 24 +++- .../viewer/page/StoryViewerPageViewModel.kt | 4 + .../viewer/page/StoryViewerPlaybackState.kt | 6 +- .../reply/composer/StoryReplyComposer.kt | 2 +- .../direct/StoryDirectReplyDialogFragment.kt | 2 +- .../direct/StoryDirectReplyRepository.kt | 8 +- .../reply/direct/StoryDirectReplyViewModel.kt | 9 +- .../text/StoryTextPostPreviewFragment.kt | 116 ++++++++++++++++++ .../viewer/text/StoryTextPostRepository.kt | 14 +++ .../stories/viewer/text/StoryTextPostState.kt | 16 +++ .../viewer/text/StoryTextPostViewModel.kt | 50 ++++++++ .../securesms/util/FeatureFlags.java | 4 +- app/src/main/proto/Database.proto | 17 +++ .../main/res/layout/stories_link_popup.xml | 77 ++++++++++++ .../res/layout/stories_my_stories_item.xml | 4 +- .../stories_text_post_preview_fragment.xml | 5 + app/src/main/res/values/strings.xml | 1 + 40 files changed, 963 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messages/StorySendUtil.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt create mode 100644 app/src/main/res/layout/stories_link_popup.xml create mode 100644 app/src/main/res/layout/stories_text_post_preview_fragment.xml 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 6340fe001..701cfb561 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.stories.StoryTextPostModel; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ThemeUtil; @@ -209,7 +210,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { this.author.observeForever(this); setQuoteAuthor(author); setQuoteText(body, attachments); - setQuoteAttachment(glideRequests, attachments); + setQuoteAttachment(glideRequests, body, attachments); setQuoteMissingFooter(originalMissing); if (Build.VERSION.SDK_INT < 21 && messageType == MessageType.INCOMING && chatColors != null) { @@ -264,7 +265,9 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { } private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { - if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) { + boolean isTextStory = !attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY; + + if (!isTextStory && (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide())) { bodyView.setVisibility(VISIBLE); bodyView.setText(body == null ? "" : body); mediaDescriptionText.setVisibility(GONE); @@ -274,6 +277,11 @@ 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); @@ -305,7 +313,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { } } - private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) { + private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck) { + if (!attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY) { + StoryTextPostModel model = StoryTextPostModel.parseFrom(body.toString()); + attachmentVideoOverlayView.setVisibility(GONE); + attachmentContainerView.setVisibility(GONE); + thumbnailView.setVisibility(VISIBLE); + glideRequests.load(model) + .centerCrop() + .override(thumbWidth, thumbHeight) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .into(thumbnailView); + return; + } + Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null); Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null); Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index 396bc549c..150b118ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.stories.StoryTextPostModel; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; @@ -391,6 +392,31 @@ public class ThumbnailView extends FrameLayout { return future; } + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull StoryTextPostModel model, int width, int height) { + SettableFuture future = new SettableFuture<>(); + + if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); + + GlideRequest request = glideRequests.load(model) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(withCrossFade()); + + if (width > 0 && height > 0) { + request = request.override(width, height); + } + + if (radius > 0) { + request = request.transforms(new CenterCrop(), new RoundedCorners(radius)); + } else { + request = request.transforms(new CenterCrop()); + } + + request.into(new GlideDrawableListeningTarget(image, future)); + blurhash.setImageDrawable(null); + + return future; + } + public void setThumbnailClickListener(SlideClickListener listener) { this.thumbnailClickListener = listener; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt index b1b51c696..ec4d43f3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -5,6 +5,7 @@ import androidx.core.util.Consumer import io.reactivex.rxjava3.core.Single import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.identity.IdentityRecordList @@ -30,7 +31,7 @@ class MultiselectForwardRepository(context: Context) { fun checkForBadIdentityRecords(contactSearchKeys: Set, consumer: Consumer>) { SignalExecutors.BOUNDED.execute { val recipients: List = contactSearchKeys - .filterIsInstance() + .filterIsInstance() .map { Recipient.resolved(it.recipientId) } val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryType.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryType.kt index e09ca3452..860500717 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryType.kt @@ -17,11 +17,33 @@ enum class StoryType(val code: Int) { /** * User cannot send replies to this story. */ - STORY_WITHOUT_REPLIES(2); + STORY_WITHOUT_REPLIES(2), - val isStory get() = this == STORY_WITH_REPLIES || this == STORY_WITHOUT_REPLIES + /** + * Text story that allows replies + */ + TEXT_STORY_WITH_REPLIES(3), - val isStoryWithReplies get() = this == STORY_WITH_REPLIES + /** + * Text story that does not allow replies + */ + TEXT_STORY_WITHOUT_REPLIES(4); + + val isStory get() = this != NONE + + val isStoryWithReplies get() = this == STORY_WITH_REPLIES || this == TEXT_STORY_WITH_REPLIES + + val isTextStory get() = this == TEXT_STORY_WITHOUT_REPLIES || this == TEXT_STORY_WITH_REPLIES + + fun toTextStoryType(): StoryType { + return when (this) { + NONE -> NONE + STORY_WITH_REPLIES -> TEXT_STORY_WITH_REPLIES + STORY_WITHOUT_REPLIES -> TEXT_STORY_WITHOUT_REPLIES + TEXT_STORY_WITH_REPLIES -> TEXT_STORY_WITH_REPLIES + TEXT_STORY_WITHOUT_REPLIES -> TEXT_STORY_WITHOUT_REPLIES + } + } companion object { @JvmStatic @@ -29,8 +51,20 @@ enum class StoryType(val code: Int) { return when (code) { 1 -> STORY_WITH_REPLIES 2 -> STORY_WITHOUT_REPLIES + 3 -> TEXT_STORY_WITH_REPLIES + 4 -> TEXT_STORY_WITHOUT_REPLIES else -> NONE } } + + @JvmStatic + fun withReplies(isTextStory: Boolean): StoryType { + return if (isTextStory) TEXT_STORY_WITH_REPLIES else STORY_WITH_REPLIES + } + + @JvmStatic + fun withoutReplies(isTextStory: Boolean): StoryType { + return if (isTextStory) TEXT_STORY_WITHOUT_REPLIES else STORY_WITHOUT_REPLIES + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt index e1a0f3039..3ed906587 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.fonts import android.graphics.Typeface import androidx.annotation.DrawableRes import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost /** * Describes which font the user wishes to render content in. @@ -13,4 +14,18 @@ enum class TextFont(@DrawableRes val icon: Int, val fallbackFamily: String, val SERIF(R.drawable.ic_font_serif, "serif", Typeface.NORMAL, false), SCRIPT(R.drawable.ic_font_script, "serif", Typeface.BOLD, false), CONDENSED(R.drawable.ic_font_condensed, "sans-serif", Typeface.BOLD, true); + + companion object { + fun fromStyle(style: StoryTextPost.Style): TextFont { + return when (style) { + StoryTextPost.Style.DEFAULT -> REGULAR + StoryTextPost.Style.REGULAR -> REGULAR + StoryTextPost.Style.BOLD -> BOLD + StoryTextPost.Style.SERIF -> SERIF + StoryTextPost.Style.SCRIPT -> SCRIPT + StoryTextPost.Style.CONDENSED -> CONDENSED + StoryTextPost.Style.UNRECOGNIZED -> REGULAR + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java index e0742dcd0..342673a45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.messages.GroupSendUtil; +import org.thoughtcrime.securesms.messages.StorySendUtil; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; @@ -59,11 +60,11 @@ public final class PushDistributionListSendJob extends PushSendJob { public PushDistributionListSendJob(long messageId, @NonNull RecipientId destination, boolean hasMedia) { this(new Parameters.Builder() - .setQueue(destination.toQueueKey(hasMedia)) - .addConstraint(NetworkConstraint.KEY) - .setLifespan(TimeUnit.DAYS.toMillis(1)) - .setMaxAttempts(Parameters.UNLIMITED) - .build(), + .setQueue(destination.toQueueKey(hasMedia)) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), messageId); } @@ -147,7 +148,7 @@ public final class PushDistributionListSendJob extends PushSendJob { List target; if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList(); - else target = Stream.of(getFullRecipients(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList(); + else target = Stream.of(getFullRecipients(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList(); List results = deliver(message, target); Log.i(TAG, JobLogger.format(this, "Finished send.")); @@ -173,10 +174,15 @@ public final class PushDistributionListSendJob extends PushSendJob { List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List attachmentPointers = getAttachmentPointersFor(attachments); - boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) - .anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED); + boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) + .anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED); - SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + final SignalServiceStoryMessage storyMessage; + if (message.getStoryType().isTextStory()) { + storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), null, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies()); + } else { + storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + } return GroupSendUtil.sendStoryMessage(context, message.getRecipient().requireDistributionListId(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage); } catch (ServerRejectedException e) { throw new UndeliverableMessageException(e); 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 4c9a2afd9..bc7f6c24d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.messages.GroupSendUtil; +import org.thoughtcrime.securesms.messages.StorySendUtil; import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; @@ -249,7 +250,12 @@ public final class PushGroupSendJob extends PushSendJob { .withRevision(v2GroupProperties.getGroupRevision()) .build(); - SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + final SignalServiceStoryMessage storyMessage; + if (message.getStoryType().isTextStory()) { + storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), groupContext, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies()); + } else { + storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + } return GroupSendUtil.sendGroupStoryMessage(context, groupId.requireV2(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt index 2dc97cc48..121151d91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt @@ -9,16 +9,16 @@ object TextStoryBackgroundColors { id = ChatColors.Id.NotSet, linearGradient = ChatColors.LinearGradient( degrees = 191.41f, - colors = intArrayOf(0xFFF53844.toInt(), 0xFFF33845.toInt(), 0xFFEC3848.toInt(), 0xFFE2384C.toInt(), 0xFFD63851.toInt(), 0xFFC73857.toInt(), 0xFFB6385E.toInt(), 0xFFA43866.toInt(), 0xFF93376D.toInt(), 0xFF813775.toInt(), 0xFF70377C.toInt(), 0xFF613782.toInt(), 0xFF553787.toInt(), 0xFF4B378B.toInt(), 0xFF44378E.toInt(), 0xFF42378F.toInt()), - positions = floatArrayOf(0.2109f, 0.2168f, 0.2339f, 0.2611f, 0.2975f, 0.3418f, 0.3932f, 0.4506f, 0.5129f, 0.5791f, 0.6481f, 0.719f, 0.7907f, 0.8621f, 0.9322f, 1.0f) + colors = intArrayOf(0xFFF53844.toInt(), 0xFF42378F.toInt()), + positions = floatArrayOf(0f, 1.0f) ) ), ChatColors.forGradient( id = ChatColors.Id.NotSet, linearGradient = ChatColors.LinearGradient( degrees = 192.04f, - colors = intArrayOf(0xFFF04CE6.toInt(), 0xFFEE4BE6.toInt(), 0xFFE54AE5.toInt(), 0xFFD949E5.toInt(), 0xFFC946E4.toInt(), 0xFFB644E3.toInt(), 0xFFA141E3.toInt(), 0xFF8B3FE2.toInt(), 0xFF743CE1.toInt(), 0xFF5E39E0.toInt(), 0xFF4936DF.toInt(), 0xFF3634DE.toInt(), 0xFF2632DD.toInt(), 0xFF1930DD.toInt(), 0xFF112FDD.toInt(), 0xFF0E2FDD.toInt()), - positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + colors = intArrayOf(0xFFF04CE6.toInt(), 0xFF0E2FDD.toInt()), + positions = floatArrayOf(0.0f, 1.0f) ), ), ChatColors.forGradient( @@ -33,16 +33,16 @@ object TextStoryBackgroundColors { id = ChatColors.Id.NotSet, linearGradient = ChatColors.LinearGradient( degrees = 180f, - colors = intArrayOf(0xFF0093E9.toInt(), 0xFF0294E9.toInt(), 0xFF0696E7.toInt(), 0xFF0D99E5.toInt(), 0xFF169EE3.toInt(), 0xFF21A3E0.toInt(), 0xFF2DA8DD.toInt(), 0xFF3AAEDA.toInt(), 0xFF46B5D6.toInt(), 0xFF53BBD3.toInt(), 0xFF5FC0D0.toInt(), 0xFF6AC5CD.toInt(), 0xFF73CACB.toInt(), 0xFF7ACDC9.toInt(), 0xFF7ECFC7.toInt(), 0xFF80D0C7.toInt()), - positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + colors = intArrayOf(0xFF0093E9.toInt(), 0xFF80D0C7.toInt()), + positions = floatArrayOf(0.0f, 1.0f) ) ), ChatColors.forGradient( id = ChatColors.Id.NotSet, linearGradient = ChatColors.LinearGradient( degrees = 180f, - colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF64CDAB.toInt(), 0xFF60CBA8.toInt(), 0xFF5BC8A3.toInt(), 0xFF55C49D.toInt(), 0xFF4DC096.toInt(), 0xFF45BB8F.toInt(), 0xFF3CB687.toInt(), 0xFF33B17F.toInt(), 0xFF2AAC76.toInt(), 0xFF21A76F.toInt(), 0xFF1AA268.toInt(), 0xFF139F62.toInt(), 0xFF0E9C5E.toInt(), 0xFF0B9A5B.toInt(), 0xFF0A995A.toInt()), - positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF0A995A.toInt()), + positions = floatArrayOf(0.0f, 1.0f) ) ), ChatColors.forColor( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt index c2dc05ecf..bf4878c27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet @@ -45,7 +46,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm private val viewModel: TextStoryPostSendViewModel by viewModels( factoryProducer = { - TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository()) + TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository(requireContext())) } ) @@ -99,6 +100,10 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm } } + disposables += viewModel.untrustedIdentities.subscribe { + SafetyNumberChangeDialog.show(childFragmentManager, it) + } + searchField.doAfterTextChanged { contactSearchMediator.onFilterChanged(it?.toString()) } @@ -158,9 +163,8 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm shareConfirmButton.isEnabled = false val textStoryPostCreationState = creationViewModel.state.value - val linkPreviewState = linkPreviewViewModel.linkPreviewState.value - viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewState!!) + viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewViewModel.onSend().firstOrNull()) } private fun animateInSelection() { 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 ba734f42b..4b36306e0 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 @@ -1,12 +1,30 @@ package org.thoughtcrime.securesms.mediasend.v2.text.send -import io.reactivex.rxjava3.core.Completable +import android.content.Context +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.ThreadUtil import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.identity.IdentityRecordList +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.fonts.TextFont import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.util.Base64 -class TextStoryPostSendRepository { +class TextStoryPostSendRepository(context: Context) { + + private val context = context.applicationContext fun isFirstSendToStory(shareContacts: Set): Boolean { if (SignalStore.storyValues().userHasAddedToAStory) { @@ -16,8 +34,92 @@ class TextStoryPostSendRepository { return shareContacts.any { it is ContactSearchKey.Story } } - fun send(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Completable { - // TODO [stories] -- Implementation once we know what text post messages look like. - return Completable.complete() + fun send(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single { + return checkForBadIdentityRecords(contactSearchKey).flatMap { result -> + if (result is TextStoryPostSendResult.Success) { + performSend(contactSearchKey, textStoryPostCreationState, linkPreview) + } else { + Single.just(result) + } + } + } + + private fun checkForBadIdentityRecords(contactSearchKeys: Set): Single { + return Single.fromCallable { + val recipients: List = contactSearchKeys + .filterIsInstance() + .map { Recipient.resolved(it.recipientId) } + val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients) + + if (identityRecordList.untrustedRecords.isNotEmpty()) { + TextStoryPostSendResult.UntrustedRecordsError(identityRecordList.untrustedRecords) + } else { + TextStoryPostSendResult.Success + } + }.subscribeOn(Schedulers.io()) + } + + private fun performSend(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single { + return Single.fromCallable { + val messages: MutableList = mutableListOf() + + for (contact in contactSearchKey) { + val recipient = Recipient.resolved(contact.requireShareContact().recipientId.get()) + val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList + + if (isStory && recipient.isActiveGroup) { + SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId()) + } + + val storyType: StoryType = when { + recipient.isDistributionList -> SignalDatabase.distributionLists.getStoryType(recipient.requireDistributionListId()) + isStory -> StoryType.STORY_WITH_REPLIES + else -> StoryType.NONE + } + + val message = OutgoingMediaMessage( + recipient, + serializeTextStoryState(textStoryPostCreationState), + emptyList(), + System.currentTimeMillis(), + -1, + 0, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + storyType.toTextStoryType(), + null, + null, + emptyList(), + listOfNotNull(linkPreview), + emptyList(), + mutableSetOf(), + mutableSetOf() + ) + + messages.add(OutgoingSecureMediaMessage(message)) + ThreadUtil.sleep(5) + } + + MessageSender.sendMediaBroadcast(context, messages, emptyList()) + TextStoryPostSendResult.Success + } + } + + private fun serializeTextStoryState(textStoryPostCreationState: TextStoryPostCreationState): String { + val builder = StoryTextPost.newBuilder() + + builder.body = textStoryPostCreationState.body.toString() + builder.background = textStoryPostCreationState.backgroundColor.serialize() + builder.style = when (textStoryPostCreationState.textFont) { + TextFont.REGULAR -> StoryTextPost.Style.REGULAR + TextFont.BOLD -> StoryTextPost.Style.BOLD + TextFont.SERIF -> StoryTextPost.Style.SERIF + TextFont.SCRIPT -> StoryTextPost.Style.SCRIPT + TextFont.CONDENSED -> StoryTextPost.Style.CONDENSED + } + builder.textBackgroundColor = textStoryPostCreationState.textBackgroundColor + builder.textForegroundColor = textStoryPostCreationState.textForegroundColor + + return Base64.encodeBytes(builder.build().toByteArray()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendResult.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendResult.kt new file mode 100644 index 000000000..3af21e639 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendResult.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.mediasend.v2.text.send + +import org.thoughtcrime.securesms.database.model.IdentityRecord + +sealed class TextStoryPostSendResult { + object Success : TextStoryPostSendResult() + data class UntrustedRecordsError(val untrustedRecords: List) : TextStoryPostSendResult() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt index aeb5b841c..b7e55fe78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt @@ -3,20 +3,25 @@ package org.thoughtcrime.securesms.mediasend.v2.text.send import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.PublishSubject import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState import org.thoughtcrime.securesms.util.livedata.Store class TextStoryPostSendViewModel(private val repository: TextStoryPostSendRepository) : ViewModel() { private val store = Store(TextStoryPostSendState.INIT) + private val untrustedIdentitySubject = PublishSubject.create>() private val disposables = CompositeDisposable() val state: LiveData = store.stateLiveData + val untrustedIdentities: Observable> = untrustedIdentitySubject override fun onCleared() { disposables.clear() @@ -36,14 +41,22 @@ class TextStoryPostSendViewModel(private val repository: TextStoryPostSendReposi } } - fun onSend(contactSearchKeys: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreviewState: LinkPreviewViewModel.LinkPreviewState) { + fun onSend(contactSearchKeys: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?) { store.update { TextStoryPostSendState.SENDING } - disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreviewState.linkPreview.orNull()).subscribeBy( - onComplete = { - store.update { TextStoryPostSendState.SENT } + disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreview).subscribeBy( + onSuccess = { + when (it) { + is TextStoryPostSendResult.Success -> { + store.update { TextStoryPostSendState.SENT } + } + is TextStoryPostSendResult.UntrustedRecordsError -> { + untrustedIdentitySubject.onNext(it.untrustedRecords) + store.update { TextStoryPostSendState.INIT } + } + } }, onError = { // TODO [stories] -- Error of some sort. 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 8a512b064..f06f82156 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -52,6 +52,9 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.database.model.StoryType; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor; +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost; +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPostOrBuilder; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.BadGroupIdException; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; @@ -116,6 +119,7 @@ import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.stories.Stories; +import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; @@ -139,6 +143,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTextAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; import org.whispersystems.signalservice.api.messages.calls.BusyMessage; @@ -165,10 +170,12 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.OptionalUtil; import java.io.IOException; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -1333,9 +1340,9 @@ public final class MessageContentProcessor { try { final StoryType storyType; if (message.getAllowsReplies().or(false)) { - storyType = StoryType.STORY_WITH_REPLIES; + storyType = StoryType.withReplies(message.getTextAttachment().isPresent()); } else { - storyType = StoryType.STORY_WITHOUT_REPLIES; + storyType = StoryType.withoutReplies(message.getTextAttachment().isPresent()); } IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), @@ -1349,12 +1356,15 @@ public final class MessageContentProcessor { false, false, content.isNeedsReceipt(), - Optional.absent(), + message.getTextAttachment().transform(this::serializeTextAttachment), Optional.fromNullable(GroupUtil.getGroupContextIfPresent(content)), message.getFileAttachment().transform(Collections::singletonList), Optional.absent(), Optional.absent(), - Optional.absent(), + getLinkPreviews(OptionalUtil.flatMap(message.getTextAttachment(), + t -> t.getPreview().transform(Collections::singletonList)), + "", + true), Optional.absent(), Optional.absent(), content.getServerUuid()); @@ -1382,6 +1392,64 @@ public final class MessageContentProcessor { } } + private @NonNull String serializeTextAttachment(@NonNull SignalServiceTextAttachment textAttachment) { + StoryTextPost.Builder builder = StoryTextPost.newBuilder(); + + if (textAttachment.getText().isPresent()) { + builder.setBody(textAttachment.getText().get()); + } + + if (textAttachment.getStyle().isPresent()) { + switch (textAttachment.getStyle().get()) { + case DEFAULT: + builder.setStyle(StoryTextPost.Style.DEFAULT); + break; + case REGULAR: + builder.setStyle(StoryTextPost.Style.REGULAR); + break; + case BOLD: + builder.setStyle(StoryTextPost.Style.BOLD); + break; + case SERIF: + builder.setStyle(StoryTextPost.Style.SERIF); + break; + case SCRIPT: + builder.setStyle(StoryTextPost.Style.SCRIPT); + break; + case CONDENSED: + builder.setStyle(StoryTextPost.Style.CONDENSED); + break; + } + } + + if (textAttachment.getTextBackgroundColor().isPresent()) { + builder.setTextBackgroundColor(textAttachment.getTextBackgroundColor().get()); + } + + if (textAttachment.getTextForegroundColor().isPresent()) { + builder.setTextForegroundColor(textAttachment.getTextForegroundColor().get()); + } + + ChatColor.Builder chatColorBuilder = ChatColor.newBuilder(); + if (textAttachment.getBackgroundColor().isPresent()) { + chatColorBuilder.setSingleColor(ChatColor.SingleColor.newBuilder().setColor(textAttachment.getBackgroundColor().get())); + } else if (textAttachment.getBackgroundGradient().isPresent()) { + SignalServiceTextAttachment.Gradient gradient = textAttachment.getBackgroundGradient().get(); + ChatColor.LinearGradient.Builder linearGradientBuilder = ChatColor.LinearGradient.newBuilder(); + + linearGradientBuilder.setRotation(gradient.getAngle().or(0).floatValue()); + linearGradientBuilder.addColors(gradient.getStartColor().get()); + linearGradientBuilder.addColors(gradient.getEndColor().get()); + linearGradientBuilder.addAllPositions(Arrays.asList(0f, 1f)); + + chatColorBuilder.setLinearGradient(linearGradientBuilder); + } + + builder.setBackground(chatColorBuilder); + + return Base64.encodeBytes(builder.build().toByteArray()); + } + private void handleStoryReply(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { log(content.getTimestamp(), "Story reply."); @@ -1473,7 +1541,7 @@ public final class MessageContentProcessor { try { Optional quote = getValidatedQuote(message.getQuote()); Optional> sharedContacts = getContacts(message.getSharedContacts()); - Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); + Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""), false); Optional> mentions = getMentions(message.getMentions()); Optional sticker = getStickerAttachment(message.getSticker()); @@ -1569,7 +1637,7 @@ public final class MessageContentProcessor { Optional quote = getValidatedQuote(message.getMessage().getQuote()); Optional sticker = getStickerAttachment(message.getMessage().getSticker()); Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); - Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or("")); + Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""), false); Optional> mentions = getMentions(message.getMessage().getMentions()); boolean viewOnce = message.getMessage().isViewOnce(); List syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) @@ -2314,7 +2382,7 @@ public final class MessageContentProcessor { return Optional.of(contacts); } - private Optional> getLinkPreviews(Optional> previews, @NonNull String message) { + private Optional> getLinkPreviews(Optional> previews, @NonNull String message, boolean isStoryEmbed) { if (!previews.isPresent() || previews.get().isEmpty()) return Optional.absent(); List linkPreviews = new ArrayList<>(previews.get().size()); @@ -2329,7 +2397,7 @@ public final class MessageContentProcessor { boolean presentInBody = url.isPresent() && urlsInMessage.containsUrl(url.get()); boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get()); - if (hasTitle && presentInBody && validDomain) { + if (hasTitle && (presentInBody || isStoryEmbed) && validDomain) { LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), description.or(""), preview.getDate(), thumbnail); linkPreviews.add(linkPreview); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/StorySendUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/StorySendUtil.kt new file mode 100644 index 000000000..949c6ac0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/StorySendUtil.kt @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.messages + +import com.google.protobuf.InvalidProtocolBufferException +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.util.Base64 +import org.whispersystems.libsignal.util.guava.Optional +import org.whispersystems.signalservice.api.messages.SignalServicePreview +import org.whispersystems.signalservice.api.messages.SignalServiceTextAttachment +import kotlin.math.roundToInt + +object StorySendUtil { + @JvmStatic + @Throws(InvalidProtocolBufferException::class) + fun deserializeBodyToStoryTextAttachment(message: OutgoingMediaMessage, getPreviewsFor: (OutgoingMediaMessage) -> List): SignalServiceTextAttachment { + val storyTextPost = StoryTextPost.parseFrom(Base64.decode(message.body)) + val preview = if (message.linkPreviews.isEmpty()) { + Optional.absent() + } else { + Optional.of(getPreviewsFor(message)[0]) + } + + return if (storyTextPost.background.hasLinearGradient()) { + SignalServiceTextAttachment.forGradientBackground( + Optional.fromNullable(storyTextPost.body), + Optional.fromNullable(getStyle(storyTextPost.style)), + Optional.of(storyTextPost.textForegroundColor), + Optional.of(storyTextPost.textBackgroundColor), + preview, + SignalServiceTextAttachment.Gradient( + Optional.of(storyTextPost.background.linearGradient.getColors(0)), + Optional.of(storyTextPost.background.linearGradient.getColors(1)), + Optional.of(storyTextPost.background.linearGradient.rotation.roundToInt()) + ) + ) + } else { + SignalServiceTextAttachment.forSolidBackground( + Optional.fromNullable(storyTextPost.body), + Optional.fromNullable(getStyle(storyTextPost.style)), + Optional.of(storyTextPost.textForegroundColor), + Optional.of(storyTextPost.textBackgroundColor), + preview, + storyTextPost.background.singleColor.color + ) + } + } + + private fun getStyle(style: StoryTextPost.Style): SignalServiceTextAttachment.Style { + return when (style) { + StoryTextPost.Style.REGULAR -> SignalServiceTextAttachment.Style.REGULAR + StoryTextPost.Style.BOLD -> SignalServiceTextAttachment.Style.BOLD + StoryTextPost.Style.SERIF -> SignalServiceTextAttachment.Style.SERIF + StoryTextPost.Style.SCRIPT -> SignalServiceTextAttachment.Style.SCRIPT + StoryTextPost.Style.CONDENSED -> SignalServiceTextAttachment.Style.CONDENSED + else -> SignalServiceTextAttachment.Style.DEFAULT + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideComponents.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideComponents.java index 5ae723ac1..67aa49139 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideComponents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideComponents.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.stickers.StickerRemoteUri; import org.thoughtcrime.securesms.stickers.StickerRemoteUriLoader; +import org.thoughtcrime.securesms.stories.StoryTextPostModel; import org.thoughtcrime.securesms.util.ConversationShortcutPhoto; import java.io.File; @@ -78,7 +79,9 @@ public class SignalGlideComponents implements RegisterGlideComponents { registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder()); registry.prepend(BlurHash.class, Bitmap.class, new BlurHashResourceDecoder()); + registry.prepend(StoryTextPostModel.class, Bitmap.class, new StoryTextPostModel.Decoder()); + registry.append(StoryTextPostModel.class, StoryTextPostModel.class, UnitModelLoader.Factory.getInstance()); registry.append(ConversationShortcutPhoto.class, Bitmap.class, new ConversationShortcutPhoto.Loader.Factory(context)); registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context)); registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 8c0c3b138..2db4f174f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MessageId; 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; @@ -85,7 +86,9 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; public class MessageSender { @@ -229,6 +232,7 @@ public class MessageSender { attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId); messageIds.add(primaryMessageId); + uploadLinkPreviews(primaryMessage, primaryMessageId, jobManager, preUploadJobIds, messageDependsOnIds); if (messages.size() > 0) { List secondaryMessages = messages.subList(1, messages.size()); @@ -257,6 +261,7 @@ public class MessageSender { attachmentDatabase.updateMessageId(attachmentIds, messageId); messageIds.add(messageId); + uploadLinkPreviews(secondaryMessage, messageId, jobManager, preUploadJobIds, messageDependsOnIds); } for (int i = 0; i < attachmentCopies.size(); i++) { @@ -291,6 +296,31 @@ public class MessageSender { } } + @WorkerThread + private static void uploadLinkPreviews(@NonNull OutgoingMediaMessage mediaMessage, + long outgoingMessageId, + @NonNull JobManager jobManager, + @NonNull List preUploadJobIds, + @NonNull List messageDependsOnIds) + { + if (!mediaMessage.getLinkPreviews().isEmpty()) { + try { + MmsMessageRecord freshRecord = (MmsMessageRecord) SignalDatabase.mms().getMessageRecord(outgoingMessageId); + freshRecord.getLinkPreviews() + .stream() + .map(LinkPreview::getAttachmentId) + .filter(Objects::nonNull) + .forEach(previewId -> { + Job job = new AttachmentUploadJob(previewId); + jobManager.add(job, preUploadJobIds); + messageDependsOnIds.add(job.getId()); + }); + } catch (NoSuchMessageException e) { + throw new AssertionError("Cannot fetch record we just inserted"); + } + } + } + /** * @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't * be enqueued (like in the case of a local self-send). diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt index e45f73dd7..9358ce8ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt @@ -33,13 +33,11 @@ class StoryLinkPreviewView @JvmOverloads constructor( private val title: TextView = findViewById(R.id.link_preview_title) private val url: TextView = findViewById(R.id.link_preview_url) - fun bind(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE) { - if (linkPreviewState.linkPreview.isPresent) { + fun bind(linkPreview: LinkPreview?, hiddenVisibility: Int = View.INVISIBLE) { + if (linkPreview != null) { visibility = View.VISIBLE isClickable = true - val linkPreview: LinkPreview = linkPreviewState.linkPreview.get() - val corners = DimensionUnit.DP.toPixels(18f).toInt() image.setCorners(corners, corners, corners, corners) @@ -60,6 +58,10 @@ class StoryLinkPreviewView @JvmOverloads constructor( } } + fun bind(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE) { + bind(linkPreviewState.linkPreview.orNull(), hiddenVisibility) + } + private fun formatUrl(linkPreview: LinkPreview) { var domain: String? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt new file mode 100644 index 000000000..d0357bc8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.stories + +import android.graphics.Bitmap +import android.view.View +import androidx.core.graphics.scale +import androidx.core.view.drawToBitmap +import com.bumptech.glide.load.Key +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.SimpleResource +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.util.Base64 +import java.security.MessageDigest + +/** + * Glide model to render a StoryTextPost as a bitmap + */ +data class StoryTextPostModel( + private val storyTextPost: StoryTextPost +) : Key { + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(storyTextPost.toByteArray()) + } + + companion object { + @JvmStatic + fun parseFrom(body: String): StoryTextPostModel { + return StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(body))) + } + } + + class Decoder : ResourceDecoder { + + companion object { + private const val RENDER_WIDTH = 1080 + private const val RENDER_HEIGHT = 1920 + } + + override fun handles(source: StoryTextPostModel, options: Options): Boolean = true + + override fun decode(source: StoryTextPostModel, width: Int, height: Int, options: Options): Resource { + val view = StoryTextPostView(ApplicationDependencies.getApplication()) + + view.measure(View.MeasureSpec.makeMeasureSpec(RENDER_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(RENDER_HEIGHT, View.MeasureSpec.EXACTLY)) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + + view.bindFromStoryTextPost(source.storyTextPost) + + view.measure(View.MeasureSpec.makeMeasureSpec(RENDER_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(RENDER_HEIGHT, View.MeasureSpec.EXACTLY)) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + + val bitmap = view.drawToBitmap().scale(width, height) + + return SimpleResource(bitmap) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt index fbb2ba2b1..08e546893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -6,6 +6,7 @@ import android.graphics.Typeface import android.graphics.drawable.Drawable import android.util.AttributeSet import android.util.TypedValue +import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.annotation.ColorInt @@ -18,12 +19,18 @@ import androidx.core.widget.doAfterTextChanged import com.airbnb.lottie.SimpleColorFilter import org.signal.core.util.DimensionUnit import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.fonts.Fonts +import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.text.TextAlignment import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.visible +import java.util.Locale class StoryTextPostView @JvmOverloads constructor( context: Context, @@ -116,10 +123,8 @@ class StoryTextPostView @JvmOverloads constructor( setPostBackground(state.backgroundColor.chatBubbleMask) setText( - if (state.body.isEmpty()) { + state.body.ifEmpty { context.getString(R.string.TextStoryPostCreationFragment__tap_to_add_text) - } else { - state.body }, state.body.isEmpty() ) @@ -134,6 +139,32 @@ class StoryTextPostView @JvmOverloads constructor( postAdjustLinkPreviewTranslationY() } + fun bindFromStoryTextPost(storyTextPost: StoryTextPost) { + linkPreviewView.visible = false + + textAlignment = TextAlignment.CENTER + + setPostBackground(ChatColors.forChatColor(ChatColors.Id.NotSet, storyTextPost.background).chatBubbleMask) + setText(storyTextPost.body, false) + setTextColor(storyTextPost.textForegroundColor) + setTextBackgroundColor(storyTextPost.textBackgroundColor) + setTextGravity(TextAlignment.CENTER) + + when (val fontResult = Fonts.resolveFont(context, Locale.getDefault(), TextFont.fromStyle(storyTextPost.style))) { + is Fonts.FontResult.Immediate -> setTypeface(fontResult.typeface) + is Fonts.FontResult.Async -> setTypeface(fontResult.future.get()) + } + + hideCloseButton() + + postAdjustTextTranslationX(TextAlignment.CENTER) + postAdjustLinkPreviewTranslationY() + } + + fun bindLinkPreview(linkPreview: LinkPreview?) { + linkPreviewView.bind(linkPreview, View.GONE) + } + fun bindLinkPreviewState(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int) { linkPreviewView.bind(linkPreviewState, hiddenVisibility) } @@ -145,7 +176,7 @@ class StoryTextPostView @JvmOverloads constructor( } } - fun postAdjustTextTranslationX(textAlignment: TextAlignment) { + private fun postAdjustTextTranslationX(textAlignment: TextAlignment) { doOnNextLayout { adjustTextTranslationX(textAlignment) } @@ -159,6 +190,10 @@ class StoryTextPostView @JvmOverloads constructor( linkPreviewView.setOnCloseClickListener(onClickListener) } + fun setLinkPreviewClickListener(onClickListener: OnClickListener?) { + linkPreviewView.setOnClickListener(onClickListener) + } + fun showPostContent() { textView.alpha = 1f linkPreviewView.alpha = 1f diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index 1b86a3ce4..7da195351 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -10,9 +10,12 @@ import org.thoughtcrime.securesms.avatar.view.AvatarView import org.thoughtcrime.securesms.components.ThumbnailView import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.util.Base64 import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -80,11 +83,23 @@ object StoriesLandingItem { val record = model.data.primaryStory.messageRecord as MediaMmsMessageRecord avatarView.setStoryRingFromState(model.data.storyViewState) - storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true) + + if (record.storyType.isTextStory) { + storyPreview.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(record.body))), 0, 0) + } else if (record.slideDeck.thumbnailSlide != null) { + storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true) + } else { + storyPreview.clear(GlideApp.with(storyPreview)) + } if (model.data.secondaryStory != null) { val secondaryRecord = model.data.secondaryStory.messageRecord as MediaMmsMessageRecord - storyMulti.setImageResource(GlideApp.with(storyPreview), secondaryRecord.slideDeck.thumbnailSlide!!, false, true) + + if (secondaryRecord.storyType.isTextStory) { + storyMulti.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(secondaryRecord.body))), 0, 0) + } else { + storyMulti.setImageResource(GlideApp.with(storyPreview), secondaryRecord.slideDeck.thumbnailSlide!!, false, true) + } storyMulti.visible = true } else { storyMulti.visible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt index 23c4b38cc..7ea00fde6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt @@ -10,7 +10,11 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.stories.StoryTextPostModel +import org.thoughtcrime.securesms.util.Base64 import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -55,8 +59,13 @@ object MyStoriesItem { viewCount.text = context.resources.getQuantityString(R.plurals.MyStories__d_views, model.distributionStory.messageRecord.readReceiptCount, model.distributionStory.messageRecord.readReceiptCount) date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.distributionStory.messageRecord.dateSent) - val thumbnail = (model.distributionStory.messageRecord as MmsMessageRecord).slideDeck.thumbnailSlide - if (thumbnail != null) { + val record: MmsMessageRecord = model.distributionStory.messageRecord as MmsMessageRecord + val thumbnail: Slide? = record.slideDeck.thumbnailSlide + + @Suppress("CascadeIf") + if (record.storyType.isTextStory) { + storyPreview.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(record.body))), 0, 0) + } else if (thumbnail != null) { storyPreview.setImageResource(GlideApp.with(itemView), thumbnail, false, true) } else { storyPreview.clear(GlideApp.with(itemView)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt index d243648db..7ccf94566 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.stories.viewer.page +import android.net.Uri import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MediaUtil /** * Each story is made up of a collection of posts @@ -15,7 +18,24 @@ class StoryPost( val viewCount: Int, val replyCount: Int, val dateInMilliseconds: Long, - val attachment: Attachment, + val content: Content, val conversationMessage: ConversationMessage, val allowsReplies: Boolean -) +) { + sealed class Content(val uri: Uri?) { + class AttachmentContent(val attachment: Attachment) : Content(attachment.uri) { + override val transferState: Int = attachment.transferState + + override fun isVideo(): Boolean = MediaUtil.isVideo(attachment) + } + class TextContent(uri: Uri, val recordId: Long) : Content(uri) { + override val transferState: Int = AttachmentDatabase.TRANSFER_PROGRESS_DONE + + override fun isVideo(): Boolean = false + } + + abstract val transferState: Int + + abstract fun isVideo(): Boolean + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 4f5ff06b0..6a0c15c17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -46,12 +46,12 @@ import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.tabs.StoryViewsAndRepliesDialogFragment +import org.thoughtcrime.securesms.stories.viewer.text.StoryTextPostPreviewFragment import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsBottomSheetDialogFragment import org.thoughtcrime.securesms.util.AvatarUtil import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.LifecycleDisposable -import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout import org.thoughtcrime.securesms.util.visible @@ -59,7 +59,12 @@ import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.math.abs -class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), MediaPreviewFragment.Events, MultiselectForwardBottomSheet.Callback, StorySlateView.Callback { +class StoryViewerPageFragment : + Fragment(R.layout.stories_viewer_fragment_page), + MediaPreviewFragment.Events, + MultiselectForwardBottomSheet.Callback, + StorySlateView.Callback, + StoryTextPostPreviewFragment.Callback { private lateinit var progressBar: SegmentedProgressBar private lateinit var storySlate: StorySlateView @@ -140,7 +145,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), ) ) - cardWrapper.setOnInterceptTouchEventListener { storySlate.state == StorySlateView.State.HIDDEN } + cardWrapper.setOnInterceptTouchEventListener { storySlate.state == StorySlateView.State.HIDDEN && childFragmentManager.findFragmentById(R.id.story_content_container) !is StoryTextPostPreviewFragment } cardWrapper.setOnTouchListener { _, event -> val result = gestureDetector.onTouchEvent(event) if (event.actionMasked == MotionEvent.ACTION_DOWN) { @@ -176,8 +181,8 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), } override fun onRequestSegmentProgressPercentage(): Float? { - val attachmentUri = if (viewModel.hasPost() && MediaUtil.isVideo(viewModel.getPost().attachment)) { - viewModel.getPost().attachment.uri + val attachmentUri = if (viewModel.hasPost() && viewModel.getPost().content.isVideo()) { + viewModel.getPost().content.uri } else { null } @@ -228,7 +233,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), val durations: Map = state.posts .mapIndexed { index, storyPost -> - index to if (MediaUtil.isVideo(storyPost.attachment)) -1L else TimeUnit.SECONDS.toMillis(5) + index to if (storyPost.content.isVideo()) -1L else TimeUnit.SECONDS.toMillis(5) } .toMap() @@ -336,7 +341,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), private fun resumeProgress() { if (progressBar.segmentCount != 0 && viewModel.hasPost()) { - val postUri = viewModel.getPost().attachment.uri + val postUri = viewModel.getPost().content.uri if (postUri != null) { progressBar.start() videoControlsDelegate.resume(postUri) @@ -385,12 +390,12 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), private fun presentStory(post: StoryPost, index: Int) { val fragment = childFragmentManager.findFragmentById(R.id.story_content_container) - if (fragment != null && fragment.requireArguments().getParcelable(MediaPreviewFragment.DATA_URI) == post.attachment.uri) { + if (fragment != null && fragment.requireArguments().getParcelable(MediaPreviewFragment.DATA_URI) == post.content.uri) { progressBar.setPosition(index) return } - if (post.attachment.uri == null) { + if (post.content.uri == null) { progressBar.setPosition(index) progressBar.invalidate() } else { @@ -404,7 +409,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), } private fun presentSlate(post: StoryPost) { - when (post.attachment.transferState) { + when (post.content.transferState) { AttachmentDatabase.TRANSFER_PROGRESS_DONE -> { storySlate.moveToState(StorySlateView.State.HIDDEN, post.id) viewModel.setIsDisplayingSlate(false) @@ -448,7 +453,12 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), @SuppressLint("SetTextI18n") private fun presentCaption(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View, storyPost: StoryPost) { - val displayBody = storyPost.conversationMessage.getDisplayBody(requireContext()) + val displayBody = if (storyPost.content is StoryPost.Content.AttachmentContent) { + storyPost.conversationMessage.getDisplayBody(requireContext()) + } else { + "" + } + caption.text = displayBody largeCaption.text = displayBody caption.visible = displayBody.isNotEmpty() @@ -556,7 +566,14 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), } private fun createFragmentForPost(storyPost: StoryPost): Fragment { - return MediaPreviewFragment.newInstance(storyPost.attachment, false) + return when (storyPost.content) { + is StoryPost.Content.AttachmentContent -> MediaPreviewFragment.newInstance(storyPost.content.attachment, false) + is StoryPost.Content.TextContent -> StoryTextPostPreviewFragment.create(storyPost.content) + } + } + + override fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) { + viewModel.setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip) } override fun getVideoControlsDelegate(): VideoControlsDelegate { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index 44e9a8d6e..b97baaad9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.stories.viewer.page import android.content.Context +import android.net.Uri import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.schedulers.Schedulers @@ -71,7 +72,7 @@ class StoryViewerPageRepository(context: Context) { viewCount = record.viewedReceiptCount, replyCount = SignalDatabase.mms.getNumberOfStoryReplies(record.id), dateInMilliseconds = record.dateSent, - attachment = (record as MmsMessageRecord).slideDeck.firstSlide!!.asAttachment(), + content = getContent(record as MmsMessageRecord), conversationMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, record), allowsReplies = record.storyType.isStoryWithReplies ) @@ -125,9 +126,11 @@ class StoryViewerPageRepository(context: Context) { } fun forceDownload(post: StoryPost) { - ApplicationDependencies.getJobManager().add( - AttachmentDownloadJob(post.id, (post.attachment as DatabaseAttachment).attachmentId, true) - ) + if (post.content is StoryPost.Content.AttachmentContent) { + ApplicationDependencies.getJobManager().add( + AttachmentDownloadJob(post.id, (post.content.attachment as DatabaseAttachment).attachmentId, true) + ) + } } fun getStoryPostsFor(recipientId: RecipientId): Observable> { @@ -167,4 +170,17 @@ class StoryViewerPageRepository(context: Context) { } } } + + private fun getContent(record: MmsMessageRecord): StoryPost.Content { + return if (record.storyType.isTextStory) { + StoryPost.Content.TextContent( + uri = Uri.parse("story_text_post://${record.id}"), + recordId = record.id + ) + } else { + StoryPost.Content.AttachmentContent( + attachment = record.slideDeck.asAttachments().first() + ) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 9a2f9bf53..6294b5feb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -158,6 +158,10 @@ class StoryViewerPageViewModel( storyViewerPlaybackStore.update { it.copy(areSegmentsInitialized = areSegmentsInitialized) } } + fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) { + storyViewerPlaybackStore.update { it.copy(isDisplayingLinkPreviewTooltip = isDisplayingLinkPreviewTooltip) } + } + private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int = state.selectedPostIndex): StoryViewerPageState.ReplyState { if (index !in state.posts.indices) { return StoryViewerPageState.ReplyState.NONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt index aa5bac700..5bc6ee77d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt @@ -12,7 +12,8 @@ data class StoryViewerPlaybackState( val isUserScrollingParent: Boolean = false, val isSelectedPage: Boolean = false, val isDisplayingSlate: Boolean = false, - val isFragmentResumed: Boolean = false + val isFragmentResumed: Boolean = false, + val isDisplayingLinkPreviewTooltip: Boolean = false ) { val isPaused: Boolean = !areSegmentsInitialized || isUserTouching || @@ -26,5 +27,6 @@ data class StoryViewerPlaybackState( isUserScrollingParent || !isSelectedPage || isDisplayingSlate || - !isFragmentResumed + !isFragmentResumed || + isDisplayingLinkPreviewTooltip } 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 4113d2cd9..ada21cb05 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 @@ -92,7 +92,7 @@ class StoryReplyComposer @JvmOverloads constructor( GlideApp.with(this), messageRecord.dateSent, messageRecord.recipient, - null, + messageRecord.body, false, messageRecord.slideDeck, null 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 dc1a1ba72..796fd13b5 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 @@ -37,7 +37,7 @@ class StoryDirectReplyDialogFragment : private val viewModel: StoryDirectReplyViewModel by viewModels( factoryProducer = { - StoryDirectReplyViewModel.Factory(storyId, recipientId, StoryDirectReplyRepository()) + StoryDirectReplyViewModel.Factory(storyId, recipientId, StoryDirectReplyRepository(requireContext())) } ) 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 90594246e..692789390 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 @@ -15,7 +15,9 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sms.MessageSender -class StoryDirectReplyRepository { +class StoryDirectReplyRepository(context: Context) { + + private val context = context.applicationContext fun getStoryPost(storyId: Long): Single { return Single.fromCallable { @@ -23,7 +25,7 @@ class StoryDirectReplyRepository { }.subscribeOn(Schedulers.io()) } - fun send(context: Context, storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable { + fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable { return Completable.create { emitter -> val message = SignalDatabase.mms.getMessageRecord(storyId) as MediaMmsMessageRecord val (recipient, threadId) = if (groupDirectReplyRecipientId == null) { @@ -56,7 +58,7 @@ class StoryDirectReplyRepository { 0, StoryType.NONE, ParentStoryId.DirectReply(storyId), - QuoteModel(message.dateSent, quoteAuthor.id, "", false, message.slideDeck.asAttachments(), null), + QuoteModel(message.dateSent, quoteAuthor.id, message.body, false, message.slideDeck.asAttachments(), null), emptyList(), 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 748829d6d..096367a42 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 @@ -1,26 +1,21 @@ package org.thoughtcrime.securesms.stories.viewer.reply.direct -import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.livedata.Store class StoryDirectReplyViewModel( - context: Context, private val storyId: Long, private val groupDirectReplyRecipientId: RecipientId?, private val repository: StoryDirectReplyRepository ) : ViewModel() { - private val context = context.applicationContext - private val store = Store(StoryDirectReplyState()) private val disposables = CompositeDisposable() @@ -39,7 +34,7 @@ class StoryDirectReplyViewModel( } fun send(charSequence: CharSequence): Completable { - return repository.send(context, storyId, groupDirectReplyRecipientId, charSequence) + return repository.send(storyId, groupDirectReplyRecipientId, charSequence) } override fun onCleared() { @@ -54,7 +49,7 @@ class StoryDirectReplyViewModel( ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return modelClass.cast( - StoryDirectReplyViewModel(ApplicationDependencies.getApplication(), storyId, groupDirectReplyRecipientId, repository) + StoryDirectReplyViewModel(storyId, groupDirectReplyRecipientId, repository) ) as T } } 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 new file mode 100644 index 000000000..45fcbd389 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt @@ -0,0 +1,116 @@ +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 +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.linkpreview.LinkPreview +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.fragments.requireListener +import kotlin.math.roundToInt + +class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview_fragment) { + + companion object { + private const val STORY_ID = "STORY_ID" + + fun create(content: StoryPost.Content.TextContent): Fragment { + return StoryTextPostPreviewFragment().apply { + arguments = Bundle().apply { + putParcelable(MediaPreviewFragment.DATA_URI, content.uri) + putLong(STORY_ID, content.recordId) + } + } + } + } + + private val viewModel: StoryTextPostViewModel by viewModels( + factoryProducer = { + StoryTextPostViewModel.Factory(requireArguments().getLong(STORY_ID), StoryTextPostRepository()) + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val storyTextPostView: StoryTextPostView = view.findViewById(R.id.story_text_post) + + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state.loadState) { + StoryTextPostState.LoadState.INIT -> Unit + StoryTextPostState.LoadState.LOADED -> { + storyTextPostView.bindFromStoryTextPost(state.storyTextPost!!) + storyTextPostView.bindLinkPreview(state.linkPreview) + + if (state.linkPreview != null) { + storyTextPostView.setLinkPreviewClickListener { + showLinkPreviewTooltip(it, state.linkPreview) + } + } else { + storyTextPostView.setLinkPreviewClickListener(null) + } + } + StoryTextPostState.LoadState.FAILED -> { + requireListener().mediaNotAvailable() + } + } + } + } + + @SuppressLint("AlertDialogBuilderUsage") + private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) { + requireListener().setIsDisplayingLinkPreviewTooltip(true) + + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.stories_link_popup, null, false) + + contentView.findViewById(R.id.url).text = linkPreview.url + contentView.setOnClickListener { + CommunicationActions.openBrowserLink(requireContext(), linkPreview.url) + } + + contentView.measure( + View.MeasureSpec.makeMeasureSpec(DimensionUnit.DP.toPixels(275f).toInt(), View.MeasureSpec.EXACTLY), + 0 + ) + + 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() + } + + interface Callback { + fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt new file mode 100644 index 000000000..0aeabe844 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.stories.viewer.text + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MmsMessageRecord + +class StoryTextPostRepository { + fun getRecord(recordId: Long): Single { + return Single.fromCallable { + SignalDatabase.mms.getMessageRecord(recordId) as MmsMessageRecord + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt new file mode 100644 index 000000000..fc38cade4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.stories.viewer.text + +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.linkpreview.LinkPreview + +data class StoryTextPostState( + val storyTextPost: StoryTextPost? = null, + val linkPreview: LinkPreview? = null, + val loadState: LoadState = LoadState.INIT +) { + enum class LoadState { + INIT, + LOADED, + FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt new file mode 100644 index 000000000..33744ae3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.stories.viewer.text + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost +import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.livedata.Store + +class StoryTextPostViewModel(recordId: Long, repository: StoryTextPostRepository) : ViewModel() { + + private val store = Store(StoryTextPostState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + disposables += repository.getRecord(recordId).subscribeBy( + onSuccess = { record -> + store.update { state -> + state.copy( + storyTextPost = StoryTextPost.parseFrom(Base64.decode(record.body)), + linkPreview = record.linkPreviews.firstOrNull(), + loadState = StoryTextPostState.LoadState.LOADED + ) + } + }, + onError = { + store.update { state -> + state.copy( + loadState = StoryTextPostState.LoadState.FAILED + ) + } + } + ) + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val recordId: Long, private val repository: StoryTextPostRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryTextPostViewModel(recordId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 9fcaf23f0..41cba4652 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -86,9 +86,9 @@ public final class FeatureFlags { private static final String DONOR_BADGES = "android.donorBadges.6"; private static final String DONOR_BADGES_DISPLAY = "android.donorBadges.display.4"; private static final String CDSH = "android.cdsh"; - private static final String STORIES = "android.stories"; + private static final String STORIES = "android.stories.2"; private static final String STORIES_TEXT_FUNCTIONS = "android.stories.text.functions"; - private static final String STORIES_TEXT_POSTS = "android.stories.text.posts"; + private static final String STORIES_TEXT_POSTS = "android.stories.text.posts.2"; private static final String HARDWARE_AEC_BLOCKLIST_MODELS = "android.calling.hardwareAecBlockList"; private static final String SOFTWARE_AEC_BLOCKLIST_MODELS = "android.calling.softwareAecBlockList"; private static final String USE_HARDWARE_AEC_IF_OLD = "android.calling.useHardwareAecIfOlderThanApi29"; diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index a9510c0c8..ba2ec213f 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -204,4 +204,21 @@ message CustomAvatar { Vector vector = 2; Photo photo = 3; } +} + +message StoryTextPost { + enum Style { + DEFAULT = 0; + REGULAR = 1; + BOLD = 2; + SERIF = 3; + SCRIPT = 4; + CONDENSED = 5; + } + + string body = 1; + Style style = 2; + int32 textForegroundColor = 3; + int32 textBackgroundColor = 4; + ChatColor background = 5; } \ No newline at end of file diff --git a/app/src/main/res/layout/stories_link_popup.xml b/app/src/main/res/layout/stories_link_popup.xml new file mode 100644 index 000000000..381642e40 --- /dev/null +++ b/app/src/main/res/layout/stories_link_popup.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_my_stories_item.xml b/app/src/main/res/layout/stories_my_stories_item.xml index a3a6136d9..4b15852e7 100644 --- a/app/src/main/res/layout/stories_my_stories_item.xml +++ b/app/src/main/res/layout/stories_my_stories_item.xml @@ -8,7 +8,7 @@ android:paddingEnd="@dimen/dsl_settings_gutter" android:background="?selectableItemBackground"> - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90113a2a0..2edcea7a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4614,6 +4614,7 @@ View more + Visit link