From 0ca66d6e9562a475065d8e83dd61973d3ea4566f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 10 May 2022 15:02:21 -0400 Subject: [PATCH] Adjust story replies for direct conversations --- _locales/en/messages.json | 26 +++ stylesheets/_modules.scss | 13 ++ stylesheets/components/Quote.scss | 19 +++ stylesheets/components/StoryReplyQuote.scss | 2 +- .../components/StoryViewsNRepliesModal.scss | 4 +- stylesheets/components/TextAttachment.scss | 12 +- ts/background.ts | 5 +- ts/components/StoryViewer.tsx | 2 +- ts/components/StoryViewsNRepliesModal.tsx | 161 +++++++++--------- .../conversation/Message.stories.tsx | 42 ++++- ts/components/conversation/Message.tsx | 53 +++--- ts/components/conversation/Quote.stories.tsx | 48 ++++++ ts/components/conversation/Quote.tsx | 54 +++++- ts/jobs/helpers/sendReaction.ts | 33 +++- ts/messageModifiers/Reactions.ts | 43 ++++- ts/model-types.d.ts | 2 +- ts/models/messages.ts | 53 +++++- ts/state/selectors/message.ts | 10 +- ts/util/getStoryReplyText.ts | 39 +++++ 19 files changed, 490 insertions(+), 131 deletions(-) create mode 100644 ts/util/getStoryReplyText.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2707d6a2b..d901c2cde 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7114,6 +7114,32 @@ "message": "Visit link", "description": "Title for the link preview tooltip" }, + "Quote__story": { + "message": "Story", + "description": "Title for replies to stories" + }, + "Quote__story-reaction": { + "message": "Reacted to a story from $name$", + "description": "Label for when a person reacts to a story", + "placeholders": { + "name": { + "content": "$1", + "example": "Charlie" + } + } + }, + "Quote__story-reaction--yours": { + "message": "Reacted to your story", + "description": "Label for when a person reacts to your story" + }, + "Quote__story-reaction--single": { + "message": "Reacted to a story", + "description": "Used whenever we can't find a user's first name" + }, + "Quote__story-unavailable": { + "message": "No longer available", + "description": "Label for when a story is not found" + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f8257afab..9fd844807 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -62,6 +62,19 @@ transition: background 0.1s ease-out; } +.module-message__quote-story-reaction-header { + @include font-subtitle; + margin-bottom: 6px; + + .module-message__container-outgoing & { + color: $color-white-alpha-80; + } + + .module-message__container-incoming & { + color: $color-gray-60; + } +} + .module-message--expired { animation: module-message__shake 0.2s linear infinite; } diff --git a/stylesheets/components/Quote.scss b/stylesheets/components/Quote.scss index 2cf0717ef..e02f4a623 100644 --- a/stylesheets/components/Quote.scss +++ b/stylesheets/components/Quote.scss @@ -30,6 +30,25 @@ box-shadow: 0px 0px 0px 2px $color-ultramarine; } } + + &__reaction-emoji { + bottom: 5px; + position: absolute; + right: 25px; + z-index: $z-index-base; + + img.emoji { + height: 24px; + width: 24px; + } + + &--story-unavailable { + align-items: flex-end; + display: flex; + margin-left: 32px; + padding-bottom: 7px; + } + } } .module-quote--no-click { diff --git a/stylesheets/components/StoryReplyQuote.scss b/stylesheets/components/StoryReplyQuote.scss index bc7b7c194..fe2dba289 100644 --- a/stylesheets/components/StoryReplyQuote.scss +++ b/stylesheets/components/StoryReplyQuote.scss @@ -3,7 +3,7 @@ .StoryReplyQuote { &__primary { - min-height: 64px; + min-height: 52px; } &__icon-container { diff --git a/stylesheets/components/StoryViewsNRepliesModal.scss b/stylesheets/components/StoryViewsNRepliesModal.scss index b8e291c69..d6a60e64f 100644 --- a/stylesheets/components/StoryViewsNRepliesModal.scss +++ b/stylesheets/components/StoryViewsNRepliesModal.scss @@ -152,8 +152,8 @@ &__quote { &__container { - margin-top: 8px; - margin-bottom: 8px; + margin: 8px 0; + margin-right: 38px; } &--outgoing-ultramarine { diff --git a/stylesheets/components/TextAttachment.scss b/stylesheets/components/TextAttachment.scss index 0fac48844..39502d211 100644 --- a/stylesheets/components/TextAttachment.scss +++ b/stylesheets/components/TextAttachment.scss @@ -3,16 +3,24 @@ .TextAttachment { max-height: 100%; + max-width: 100%; + display: flex; + justify-content: center; + align-items: center; &__story { align-items: center; display: flex; flex-direction: column; - height: 1280px; justify-content: center; overflow: hidden; - transform-origin: top center; user-select: none; + + height: 1280px; + max-height: 1280px; + max-width: 720px; + min-height: 1280px; + min-width: 720px; width: 720px; } diff --git a/ts/background.ts b/ts/background.ts index e9ff35fc5..0b172269c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2869,8 +2869,9 @@ export async function startApp(): Promise { source: ReactionSource.FromSomeoneElse, }; const reactionModel = Reactions.getSingleton().add(attributes); + // Note: We do not wait for completion here - Reactions.getSingleton().onReaction(reactionModel); + Reactions.getSingleton().onReaction(reactionModel, message); confirm(); return Promise.resolve(); } @@ -3237,7 +3238,7 @@ export async function startApp(): Promise { }; const reactionModel = Reactions.getSingleton().add(attributes); // Note: We do not wait for completion here - Reactions.getSingleton().onReaction(reactionModel); + Reactions.getSingleton().onReaction(reactionModel, message); event.confirm(); return Promise.resolve(); diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 89fa769d2..909aecec9 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -670,7 +670,7 @@ export const StoryViewer = ({ /> {hasReplyModal && canReply && ( -
- {!isGroupStory && ( - - )} - + {!isGroupStory && ( + { - setMessageBodyText(messageText); - }} - onPickEmoji={insertEmoji} - onSubmit={(...args) => { - inputApiRef.current?.reset(); - onReply(...args); - }} - onTextTooLong={onTextTooLong} - placeholder={ - isGroupStory - ? i18n('StoryViewer__reply-group') - : i18n('StoryViewer__reply') - } - theme={ThemeType.dark} - > - - -
- diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 9d3b5432a..70337b939 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -26,6 +26,7 @@ import { canReact, isStory } from '../../state/selectors/message'; import { findAndFormatContact } from '../../util/findAndFormatContact'; import { UUID } from '../../types/UUID'; import { handleMultipleSendErrors } from './handleMultipleSendErrors'; +import { incrementMessageCounter } from '../../util/incrementMessageCounter'; import type { ConversationQueueJobBundle, @@ -138,9 +139,8 @@ export async function sendReaction( type: 'outgoing', conversationId: conversation.get('id'), sent_at: pendingReaction.timestamp, - received_at: window.Signal.Util.incrementMessageCounter(), + received_at: incrementMessageCounter(), received_at_ms: pendingReaction.timestamp, - reaction: reactionForSend, timestamp: pendingReaction.timestamp, sendStateByConversationId: zipObject( Object.keys(pendingReaction.isSentByConversationId || {}), @@ -150,7 +150,18 @@ export async function sendReaction( }) ), }); - ephemeralMessageForReactionSend.doNotSave = true; + + if ( + isStory(message.attributes) && + isDirectConversation(conversation.attributes) + ) { + ephemeralMessageForReactionSend.set({ + storyId: message.id, + storyReactionEmoji: reactionForSend.emoji, + }); + } else { + ephemeralMessageForReactionSend.doNotSave = true; + } let didFullySend: boolean; const successfulConversationIds = new Set(); @@ -308,6 +319,22 @@ export async function sendReaction( didFullySend = false; } } + + if (!ephemeralMessageForReactionSend.doNotSave) { + const reactionMessage = ephemeralMessageForReactionSend; + + await Promise.all([ + await window.Signal.Data.saveMessage(reactionMessage.attributes, { + ourUuid, + forceSave: true, + }), + reactionMessage.hydrateStoryContext(message), + ]); + + conversation.addSingleMessage( + window.MessageController.register(reactionMessage.id, reactionMessage) + ); + } } const newReactions = reactionUtil.markOutgoingReactionSent( diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index b9f416c4e..ab63c88fe 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -4,11 +4,12 @@ /* eslint-disable max-classes-per-file */ import { Collection, Model } from 'backbone'; -import type { MessageModel } from '../models/messages'; -import { getContactId, getContact } from '../messages/helpers'; -import { isOutgoing } from '../state/selectors/message'; -import type { ReactionAttributesType } from '../model-types.d'; import * as log from '../logging/log'; +import type { MessageModel } from '../models/messages'; +import type { ReactionAttributesType } from '../model-types.d'; +import { getContactId, getContact } from '../messages/helpers'; +import { isDirectConversation } from '../util/whatTypeOfConversation'; +import { isOutgoing, isStory } from '../state/selectors/message'; export class ReactionModel extends Model {} @@ -55,7 +56,10 @@ export class Reactions extends Collection { return []; } - async onReaction(reaction: ReactionModel): Promise { + async onReaction( + reaction: ReactionModel, + generatedMessage: MessageModel + ): Promise { try { // The conversation the target message was in; we have to find it in the database // to to figure that out. @@ -133,6 +137,35 @@ export class Reactions extends Collection { targetMessage ); + // Use the generated message in ts/background.ts to create a message + // if the reaction is targetted at a story on a 1:1 conversation. + if ( + isStory(targetMessage) && + isDirectConversation(targetConversation.attributes) + ) { + generatedMessage.set({ + storyId: targetMessage.id, + storyReactionEmoji: reaction.get('emoji'), + }); + + await Promise.all([ + window.Signal.Data.saveMessage(generatedMessage.attributes, { + ourUuid: window.textsecure.storage.user + .getCheckedUuid() + .toString(), + forceSave: true, + }), + generatedMessage.hydrateStoryContext(message), + ]); + + targetConversation.addSingleMessage( + window.MessageController.register( + generatedMessage.id, + generatedMessage + ) + ); + } + await message.handleReaction(reaction); this.remove(reaction); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 0ae7bd988..ed7658062 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -186,7 +186,7 @@ export type MessageAttributesType = { unidentifiedDeliveries?: Array; contact?: Array; conversationId: string; - reaction?: WhatIsThis; + storyReactionEmoji?: string; expirationTimerUpdate?: { expireTimer: number; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 0b79be443..c7fe758a7 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -306,7 +306,7 @@ export class MessageModel extends window.Backbone.Model { ); } - async hydrateStoryContext(): Promise { + async hydrateStoryContext(inMemoryMessage?: MessageModel): Promise { const storyId = this.get('storyId'); if (!storyId) { return; @@ -316,9 +316,24 @@ export class MessageModel extends window.Backbone.Model { return; } - const message = await getMessageById(storyId); + const message = inMemoryMessage || (await getMessageById(storyId)); if (!message) { + const conversation = this.getConversation(); + strictAssert( + conversation && isDirectConversation(conversation.attributes), + 'Hydrating story context on a non-private conversation' + ); + this.set({ + storyReplyContext: { + attachment: undefined, + // This is ok to do because story replies only show in 1:1 conversations + // so the story that was quoted should be from the same conversation. + authorUuid: conversation.get('uuid'), + // No messageId, referenced story not found! + messageId: '', + }, + }); return; } @@ -772,6 +787,21 @@ export class MessageModel extends window.Backbone.Model { const { text, emoji } = this.getNotificationData(); const { attributes } = this; + if (attributes.storyReactionEmoji) { + const conversation = this.getConversation(); + const firstName = conversation?.attributes.profileName; + + if (!conversation || !firstName) { + return window.i18n('Quote__story-reaction--single'); + } + + if (isMe(conversation.attributes)) { + return window.i18n('Quote__story-reaction--yours'); + } + + return window.i18n('Quote__story-reaction', [firstName]); + } + let modifiedText = text; const bodyRanges = processBodyRanges(attributes, { @@ -936,6 +966,25 @@ export class MessageModel extends window.Backbone.Model { async doubleCheckMissingQuoteReference(): Promise { const logId = this.idForLogging(); + + const storyId = this.get('storyId'); + if (storyId) { + log.warn( + `doubleCheckMissingQuoteReference/${logId}: missing story reference` + ); + + const message = window.MessageController.getById(storyId); + if (!message) { + return; + } + + if (this.get('storyReplyContext')) { + this.unset('storyReplyContext'); + } + await this.hydrateStoryContext(message); + return; + } + const quote = this.get('quote'); if (!quote) { log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index ab08f0d35..84c022679 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -76,6 +76,7 @@ import { import * as log from '../../logging/log'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import { DAY, HOUR } from '../../util/durations'; +import { getStoryReplyText } from '../../util/getStoryReplyText'; const THREE_HOURS = 3 * HOUR; @@ -435,7 +436,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator( ( message: Pick< MessageWithUIFieldsType, - 'body' | 'conversationId' | 'storyReplyContext' + 'body' | 'conversationId' | 'storyReactionEmoji' | 'storyReplyContext' >, { conversationSelector, @@ -445,14 +446,14 @@ export const getPropsForStoryReplyContext = createSelectorCreator( ourConversationId?: string; } ): PropsData['storyReplyContext'] => { - const { storyReplyContext } = message; + const { storyReactionEmoji, storyReplyContext } = message; if (!storyReplyContext) { return undefined; } const contact = conversationSelector(storyReplyContext.authorUuid); - const authorTitle = contact.title; + const authorTitle = contact.firstName || contact.title; const isFromMe = contact.id === ourConversationId; const conversation = getConversation(message, conversationSelector); @@ -464,10 +465,13 @@ export const getPropsForStoryReplyContext = createSelectorCreator( authorTitle, conversationColor, customColor, + emoji: storyReactionEmoji, isFromMe, rawAttachment: storyReplyContext.attachment ? processQuoteAttachment(storyReplyContext.attachment) : undefined, + referencedMessageNotFound: !storyReplyContext.messageId, + text: getStoryReplyText(window.i18n, storyReplyContext.attachment), }; }, diff --git a/ts/util/getStoryReplyText.ts b/ts/util/getStoryReplyText.ts new file mode 100644 index 000000000..30e9e395b --- /dev/null +++ b/ts/util/getStoryReplyText.ts @@ -0,0 +1,39 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AttachmentType } from '../types/Attachment'; +import type { LocalizerType } from '../types/Util'; +import { isGIF, isImage, isVideo } from '../types/Attachment'; + +export function getStoryReplyText( + i18n: LocalizerType, + attachment?: AttachmentType +): string { + if (!attachment) { + return i18n('Quote__story-unavailable'); + } + + if (attachment.caption) { + return attachment.caption; + } + + const attachments = [attachment]; + + if (isImage(attachments)) { + return i18n('message--getNotificationText--photo'); + } + + if (isGIF(attachments)) { + return i18n('message--getNotificationText--gif'); + } + + if (isVideo(attachments)) { + return i18n('message--getNotificationText--video'); + } + + if (attachment.textAttachment && attachment.textAttachment.text) { + return attachment.textAttachment.text; + } + + return i18n('message--getNotificationText--file'); +}