Adjust story replies for direct conversations

This commit is contained in:
Josh Perez 2022-05-10 15:02:21 -04:00 committed by GitHub
parent fa7b7fcd08
commit 0ca66d6e95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 490 additions and 131 deletions

View File

@ -7114,6 +7114,32 @@
"message": "Visit link", "message": "Visit link",
"description": "Title for the link preview tooltip" "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": { "WhatsNew__modal-title": {
"message": "What's New", "message": "What's New",
"description": "Title for the whats new modal" "description": "Title for the whats new modal"

View File

@ -62,6 +62,19 @@
transition: background 0.1s ease-out; 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 { .module-message--expired {
animation: module-message__shake 0.2s linear infinite; animation: module-message__shake 0.2s linear infinite;
} }

View File

@ -30,6 +30,25 @@
box-shadow: 0px 0px 0px 2px $color-ultramarine; 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 { .module-quote--no-click {

View File

@ -3,7 +3,7 @@
.StoryReplyQuote { .StoryReplyQuote {
&__primary { &__primary {
min-height: 64px; min-height: 52px;
} }
&__icon-container { &__icon-container {

View File

@ -152,8 +152,8 @@
&__quote { &__quote {
&__container { &__container {
margin-top: 8px; margin: 8px 0;
margin-bottom: 8px; margin-right: 38px;
} }
&--outgoing-ultramarine { &--outgoing-ultramarine {

View File

@ -3,16 +3,24 @@
.TextAttachment { .TextAttachment {
max-height: 100%; max-height: 100%;
max-width: 100%;
display: flex;
justify-content: center;
align-items: center;
&__story { &__story {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 1280px;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transform-origin: top center;
user-select: none; user-select: none;
height: 1280px;
max-height: 1280px;
max-width: 720px;
min-height: 1280px;
min-width: 720px;
width: 720px; width: 720px;
} }

View File

@ -2869,8 +2869,9 @@ export async function startApp(): Promise<void> {
source: ReactionSource.FromSomeoneElse, source: ReactionSource.FromSomeoneElse,
}; };
const reactionModel = Reactions.getSingleton().add(attributes); const reactionModel = Reactions.getSingleton().add(attributes);
// Note: We do not wait for completion here // Note: We do not wait for completion here
Reactions.getSingleton().onReaction(reactionModel); Reactions.getSingleton().onReaction(reactionModel, message);
confirm(); confirm();
return Promise.resolve(); return Promise.resolve();
} }
@ -3237,7 +3238,7 @@ export async function startApp(): Promise<void> {
}; };
const reactionModel = Reactions.getSingleton().add(attributes); const reactionModel = Reactions.getSingleton().add(attributes);
// Note: We do not wait for completion here // Note: We do not wait for completion here
Reactions.getSingleton().onReaction(reactionModel); Reactions.getSingleton().onReaction(reactionModel, message);
event.confirm(); event.confirm();
return Promise.resolve(); return Promise.resolve();

View File

@ -670,7 +670,7 @@ export const StoryViewer = ({
/> />
{hasReplyModal && canReply && ( {hasReplyModal && canReply && (
<StoryViewsNRepliesModal <StoryViewsNRepliesModal
authorTitle={title} authorTitle={firstName || title}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
isGroupStory={isGroupStory} isGroupStory={isGroupStory}

View File

@ -27,6 +27,7 @@ import { Tabs } from './Tabs';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
import { getStoryReplyText } from '../util/getStoryReplyText';
type ViewType = Pick< type ViewType = Pick<
ConversationType, ConversationType,
@ -135,88 +136,88 @@ export const StoryViewsNRepliesModal = ({
if (!isMyStory) { if (!isMyStory) {
composerElement = ( composerElement = (
<div className="StoryViewsNRepliesModal__compose-container"> <>
<div className="StoryViewsNRepliesModal__composer"> {!isGroupStory && (
{!isGroupStory && ( <Quote
<Quote authorTitle={authorTitle}
authorTitle={authorTitle} conversationColor="ultramarine"
conversationColor="ultramarine"
i18n={i18n}
isFromMe={false}
isViewOnce={false}
moduleClassName="StoryViewsNRepliesModal__quote"
rawAttachment={storyPreviewAttachment}
referencedMessageNotFound={false}
text={i18n('message--getNotificationText--text-with-emoji', {
text: i18n('message--getNotificationText--photo'),
emoji: '📷',
})}
/>
)}
<CompositionInput
draftText={messageBodyText}
getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
inputApi={inputApiRef} isFromMe={false}
moduleClassName="StoryViewsNRepliesModal__input" isStoryReply
onEditorStateChange={messageText => { isViewOnce={false}
setMessageBodyText(messageText); moduleClassName="StoryViewsNRepliesModal__quote"
}} rawAttachment={storyPreviewAttachment}
onPickEmoji={insertEmoji} referencedMessageNotFound={false}
onSubmit={(...args) => { text={getStoryReplyText(i18n, storyPreviewAttachment)}
inputApiRef.current?.reset(); />
onReply(...args);
}}
onTextTooLong={onTextTooLong}
placeholder={
isGroupStory
? i18n('StoryViewer__reply-group')
: i18n('StoryViewer__reply')
}
theme={ThemeType.dark}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onClose={focusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
</div>
<button
aria-label={i18n('StoryViewsNRepliesModal__react')}
className="StoryViewsNRepliesModal__react"
onClick={() => {
setShowReactionPicker(!showReactionPicker);
}}
ref={setReferenceElement}
type="button"
/>
{showReactionPicker && (
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ReactionPicker
i18n={i18n}
onClose={() => {
setShowReactionPicker(false);
}}
onPick={emoji => {
setShowReactionPicker(false);
onReact(emoji);
}}
onSetSkinTone={onSetSkinTone}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
)} )}
</div> <div className="StoryViewsNRepliesModal__compose-container">
<div className="StoryViewsNRepliesModal__composer">
<CompositionInput
draftText={messageBodyText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={messageText => {
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}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onClose={focusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
</div>
<button
aria-label={i18n('StoryViewsNRepliesModal__react')}
className="StoryViewsNRepliesModal__react"
onClick={() => {
setShowReactionPicker(!showReactionPicker);
}}
ref={setReferenceElement}
type="button"
/>
{showReactionPicker && (
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ReactionPicker
i18n={i18n}
onClose={() => {
setShowReactionPicker(false);
}}
onPick={emoji => {
setShowReactionPicker(false);
onReact(emoji);
}}
onSetSkinTone={onSetSkinTone}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
)}
</div>
</>
); );
} }

View File

@ -1540,15 +1540,53 @@ story.add('Story reply', () => {
const conversation = getDefaultConversation(); const conversation = getDefaultConversation();
return renderThree({ return renderThree({
...createProps({ text: 'Wow!' }), ...createProps({ direction: 'outgoing', text: 'Wow!' }),
storyReplyContext: { storyReplyContext: {
authorTitle: conversation.title, authorTitle: conversation.firstName || conversation.title,
conversationColor: ConversationColors[0], conversationColor: ConversationColors[0],
isFromMe: false, isFromMe: false,
rawAttachment: fakeAttachment({ rawAttachment: fakeAttachment({
url: '/fixtures/snow.jpg', url: '/fixtures/snow.jpg',
thumbnail: fakeThumbnail('/fixtures/snow.jpg'), thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}), }),
text: 'Photo',
},
});
});
story.add('Story reply (yours)', () => {
const conversation = getDefaultConversation();
return renderThree({
...createProps({ direction: 'incoming', text: 'Wow!' }),
storyReplyContext: {
authorTitle: conversation.firstName || conversation.title,
conversationColor: ConversationColors[0],
isFromMe: true,
rawAttachment: fakeAttachment({
url: '/fixtures/snow.jpg',
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
text: 'Photo',
},
});
});
story.add('Story reply (emoji)', () => {
const conversation = getDefaultConversation();
return renderThree({
...createProps({ direction: 'outgoing', text: 'Wow!' }),
storyReplyContext: {
authorTitle: conversation.firstName || conversation.title,
conversationColor: ConversationColors[0],
emoji: '💄',
isFromMe: false,
rawAttachment: fakeAttachment({
url: '/fixtures/snow.jpg',
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
text: 'Photo',
}, },
}); });
}); });

View File

@ -227,8 +227,11 @@ export type PropsData = {
authorTitle: string; authorTitle: string;
conversationColor: ConversationColorType; conversationColor: ConversationColorType;
customColor?: CustomColorType; customColor?: CustomColorType;
emoji?: string;
isFromMe: boolean; isFromMe: boolean;
rawAttachment?: QuotedAttachmentType; rawAttachment?: QuotedAttachmentType;
referencedMessageNotFound?: boolean;
text: string;
}; };
previews: Array<LinkPreviewType>; previews: Array<LinkPreviewType>;
@ -1299,27 +1302,35 @@ export class Message extends React.PureComponent<Props, State> {
} }
return ( return (
<Quote <>
authorTitle={storyReplyContext.authorTitle} {storyReplyContext.emoji && (
conversationColor={conversationColor} <div className="module-message__quote-story-reaction-header">
curveTopLeft={curveTopLeft} {i18n('Quote__story-reaction', [storyReplyContext.authorTitle])}
curveTopRight={curveTopRight} </div>
customColor={customColor} )}
i18n={i18n} <Quote
isFromMe={storyReplyContext.isFromMe} authorTitle={storyReplyContext.authorTitle}
isIncoming={isIncoming} conversationColor={conversationColor}
isViewOnce={false} curveTopLeft={curveTopLeft}
moduleClassName="StoryReplyQuote" curveTopRight={curveTopRight}
onClick={() => { customColor={customColor}
// TODO DESKTOP-3255 i18n={i18n}
}} isFromMe={storyReplyContext.isFromMe}
rawAttachment={storyReplyContext.rawAttachment} isIncoming={isIncoming}
referencedMessageNotFound={false} isStoryReply
text={i18n('message--getNotificationText--text-with-emoji', { isViewOnce={false}
text: i18n('message--getNotificationText--photo'), moduleClassName="StoryReplyQuote"
emoji: '📷', onClick={() => {
})} // TODO DESKTOP-3255
/> }}
rawAttachment={storyReplyContext.rawAttachment}
reactionEmoji={storyReplyContext.emoji}
referencedMessageNotFound={Boolean(
storyReplyContext.referencedMessageNotFound
)}
text={storyReplyContext.text}
/>
</>
); );
} }

View File

@ -521,3 +521,51 @@ story.add('Custom Color', () => (
/> />
</> </>
)); ));
story.add('isStoryReply', () => {
const props = createProps({
text: 'Wow!',
});
return (
<Quote
{...props}
authorTitle="Amanda"
isStoryReply
moduleClassName="StoryReplyQuote"
onClose={undefined}
rawAttachment={{
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
}}
/>
);
});
story.add('isStoryReply emoji', () => {
const props = createProps();
return (
<Quote
{...props}
authorTitle="Charlie"
isStoryReply
moduleClassName="StoryReplyQuote"
onClose={undefined}
rawAttachment={{
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
}}
reactionEmoji="🏋️"
/>
);
});

View File

@ -17,6 +17,8 @@ import type {
CustomColorType, CustomColorType,
} from '../../types/Colors'; } from '../../types/Colors';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Emojify } from './Emojify';
import { TextAttachment } from '../TextAttachment';
import { getTextWithMentions } from '../../util/getTextWithMentions'; import { getTextWithMentions } from '../../util/getTextWithMentions';
import { getClassNamesFor } from '../../util/getClassNamesFor'; import { getClassNamesFor } from '../../util/getClassNamesFor';
import { getCustomColorStyle } from '../../util/getCustomColorStyle'; import { getCustomColorStyle } from '../../util/getCustomColorStyle';
@ -31,12 +33,14 @@ export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
isFromMe: boolean; isFromMe: boolean;
isIncoming?: boolean; isIncoming?: boolean;
isStoryReply?: boolean;
moduleClassName?: string; moduleClassName?: string;
onClick?: () => void; onClick?: () => void;
onClose?: () => void; onClose?: () => void;
text: string; text: string;
rawAttachment?: QuotedAttachmentType; rawAttachment?: QuotedAttachmentType;
isViewOnce: boolean; isViewOnce: boolean;
reactionEmoji?: string;
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
doubleCheckMissingQuoteReference?: () => unknown; doubleCheckMissingQuoteReference?: () => unknown;
}; };
@ -51,6 +55,13 @@ export type QuotedAttachmentType = Pick<
>; >;
function validateQuote(quote: Props): boolean { function validateQuote(quote: Props): boolean {
if (
quote.isStoryReply &&
(quote.referencedMessageNotFound || quote.reactionEmoji)
) {
return true;
}
if (quote.text) { if (quote.text) {
return true; return true;
} }
@ -250,7 +261,7 @@ export class Quote extends React.Component<Props, State> {
} }
public renderIconContainer(): JSX.Element | null { public renderIconContainer(): JSX.Element | null {
const { rawAttachment, isViewOnce } = this.props; const { rawAttachment, isViewOnce, i18n } = this.props;
const { imageBroken } = this.state; const { imageBroken } = this.state;
const attachment = getAttachment(rawAttachment); const attachment = getAttachment(rawAttachment);
@ -265,9 +276,16 @@ export class Quote extends React.Component<Props, State> {
return this.renderIcon('view-once'); return this.renderIcon('view-once');
} }
// TODO DESKTOP-3433
if (textAttachment) { if (textAttachment) {
return this.renderIcon('image'); return (
<div className={this.getClassName('__icon-container')}>
<TextAttachment
i18n={i18n}
isThumbnail
textAttachment={textAttachment}
/>
</div>
);
} }
if (GoogleChrome.isVideoTypeSupported(contentType)) { if (GoogleChrome.isVideoTypeSupported(contentType)) {
@ -385,7 +403,17 @@ export class Quote extends React.Component<Props, State> {
} }
public renderAuthor(): JSX.Element { public renderAuthor(): JSX.Element {
const { authorTitle, i18n, isFromMe, isIncoming } = this.props; const { authorTitle, i18n, isFromMe, isIncoming, isStoryReply } =
this.props;
const title = isFromMe ? i18n('you') : <ContactName title={authorTitle} />;
const author = isStoryReply ? (
<>
{title} &middot; {i18n('Quote__story')}
</>
) : (
title
);
return ( return (
<div <div
@ -394,7 +422,7 @@ export class Quote extends React.Component<Props, State> {
isIncoming ? this.getClassName('__primary__author--incoming') : null isIncoming ? this.getClassName('__primary__author--incoming') : null
)} )}
> >
{isFromMe ? i18n('you') : <ContactName title={authorTitle} />} {author}
</div> </div>
); );
} }
@ -405,10 +433,11 @@ export class Quote extends React.Component<Props, State> {
customColor, customColor,
i18n, i18n,
isIncoming, isIncoming,
isStoryReply,
referencedMessageNotFound, referencedMessageNotFound,
} = this.props; } = this.props;
if (!referencedMessageNotFound) { if (!referencedMessageNotFound || isStoryReply) {
return null; return null;
} }
@ -452,6 +481,8 @@ export class Quote extends React.Component<Props, State> {
customColor, customColor,
isIncoming, isIncoming,
onClick, onClick,
rawAttachment,
reactionEmoji,
referencedMessageNotFound, referencedMessageNotFound,
} = this.props; } = this.props;
@ -486,6 +517,17 @@ export class Quote extends React.Component<Props, State> {
{this.renderGenericFile()} {this.renderGenericFile()}
{this.renderText()} {this.renderText()}
</div> </div>
{reactionEmoji && (
<div
className={
rawAttachment
? this.getClassName('__reaction-emoji')
: this.getClassName('__reaction-emoji--story-unavailable')
}
>
<Emojify text={reactionEmoji} />
</div>
)}
{this.renderIconContainer()} {this.renderIconContainer()}
{this.renderClose()} {this.renderClose()}
</button> </button>

View File

@ -26,6 +26,7 @@ import { canReact, isStory } from '../../state/selectors/message';
import { findAndFormatContact } from '../../util/findAndFormatContact'; import { findAndFormatContact } from '../../util/findAndFormatContact';
import { UUID } from '../../types/UUID'; import { UUID } from '../../types/UUID';
import { handleMultipleSendErrors } from './handleMultipleSendErrors'; import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { incrementMessageCounter } from '../../util/incrementMessageCounter';
import type { import type {
ConversationQueueJobBundle, ConversationQueueJobBundle,
@ -138,9 +139,8 @@ export async function sendReaction(
type: 'outgoing', type: 'outgoing',
conversationId: conversation.get('id'), conversationId: conversation.get('id'),
sent_at: pendingReaction.timestamp, sent_at: pendingReaction.timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: incrementMessageCounter(),
received_at_ms: pendingReaction.timestamp, received_at_ms: pendingReaction.timestamp,
reaction: reactionForSend,
timestamp: pendingReaction.timestamp, timestamp: pendingReaction.timestamp,
sendStateByConversationId: zipObject( sendStateByConversationId: zipObject(
Object.keys(pendingReaction.isSentByConversationId || {}), 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; let didFullySend: boolean;
const successfulConversationIds = new Set<string>(); const successfulConversationIds = new Set<string>();
@ -308,6 +319,22 @@ export async function sendReaction(
didFullySend = false; 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( const newReactions = reactionUtil.markOutgoingReactionSent(

View File

@ -4,11 +4,12 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone'; 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 * 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<ReactionAttributesType> {} export class ReactionModel extends Model<ReactionAttributesType> {}
@ -55,7 +56,10 @@ export class Reactions extends Collection<ReactionModel> {
return []; return [];
} }
async onReaction(reaction: ReactionModel): Promise<void> { async onReaction(
reaction: ReactionModel,
generatedMessage: MessageModel
): Promise<void> {
try { try {
// The conversation the target message was in; we have to find it in the database // The conversation the target message was in; we have to find it in the database
// to to figure that out. // to to figure that out.
@ -133,6 +137,35 @@ export class Reactions extends Collection<ReactionModel> {
targetMessage 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); await message.handleReaction(reaction);
this.remove(reaction); this.remove(reaction);

2
ts/model-types.d.ts vendored
View File

@ -186,7 +186,7 @@ export type MessageAttributesType = {
unidentifiedDeliveries?: Array<string>; unidentifiedDeliveries?: Array<string>;
contact?: Array<EmbeddedContactType>; contact?: Array<EmbeddedContactType>;
conversationId: string; conversationId: string;
reaction?: WhatIsThis; storyReactionEmoji?: string;
expirationTimerUpdate?: { expirationTimerUpdate?: {
expireTimer: number; expireTimer: number;

View File

@ -306,7 +306,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
async hydrateStoryContext(): Promise<void> { async hydrateStoryContext(inMemoryMessage?: MessageModel): Promise<void> {
const storyId = this.get('storyId'); const storyId = this.get('storyId');
if (!storyId) { if (!storyId) {
return; return;
@ -316,9 +316,24 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
const message = await getMessageById(storyId); const message = inMemoryMessage || (await getMessageById(storyId));
if (!message) { 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; return;
} }
@ -772,6 +787,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const { text, emoji } = this.getNotificationData(); const { text, emoji } = this.getNotificationData();
const { attributes } = this; 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; let modifiedText = text;
const bodyRanges = processBodyRanges(attributes, { const bodyRanges = processBodyRanges(attributes, {
@ -936,6 +966,25 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async doubleCheckMissingQuoteReference(): Promise<void> { async doubleCheckMissingQuoteReference(): Promise<void> {
const logId = this.idForLogging(); 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'); const quote = this.get('quote');
if (!quote) { if (!quote) {
log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`); log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`);

View File

@ -76,6 +76,7 @@ import {
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes';
import { DAY, HOUR } from '../../util/durations'; import { DAY, HOUR } from '../../util/durations';
import { getStoryReplyText } from '../../util/getStoryReplyText';
const THREE_HOURS = 3 * HOUR; const THREE_HOURS = 3 * HOUR;
@ -435,7 +436,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
( (
message: Pick< message: Pick<
MessageWithUIFieldsType, MessageWithUIFieldsType,
'body' | 'conversationId' | 'storyReplyContext' 'body' | 'conversationId' | 'storyReactionEmoji' | 'storyReplyContext'
>, >,
{ {
conversationSelector, conversationSelector,
@ -445,14 +446,14 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
ourConversationId?: string; ourConversationId?: string;
} }
): PropsData['storyReplyContext'] => { ): PropsData['storyReplyContext'] => {
const { storyReplyContext } = message; const { storyReactionEmoji, storyReplyContext } = message;
if (!storyReplyContext) { if (!storyReplyContext) {
return undefined; return undefined;
} }
const contact = conversationSelector(storyReplyContext.authorUuid); const contact = conversationSelector(storyReplyContext.authorUuid);
const authorTitle = contact.title; const authorTitle = contact.firstName || contact.title;
const isFromMe = contact.id === ourConversationId; const isFromMe = contact.id === ourConversationId;
const conversation = getConversation(message, conversationSelector); const conversation = getConversation(message, conversationSelector);
@ -464,10 +465,13 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
authorTitle, authorTitle,
conversationColor, conversationColor,
customColor, customColor,
emoji: storyReactionEmoji,
isFromMe, isFromMe,
rawAttachment: storyReplyContext.attachment rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment) ? processQuoteAttachment(storyReplyContext.attachment)
: undefined, : undefined,
referencedMessageNotFound: !storyReplyContext.messageId,
text: getStoryReplyText(window.i18n, storyReplyContext.attachment),
}; };
}, },

View File

@ -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');
}