Adjust story replies for direct conversations
This commit is contained in:
parent
fa7b7fcd08
commit
0ca66d6e95
19 changed files with 490 additions and 131 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
.StoryReplyQuote {
|
||||
&__primary {
|
||||
min-height: 64px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
&__icon-container {
|
||||
|
|
|
@ -152,8 +152,8 @@
|
|||
|
||||
&__quote {
|
||||
&__container {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin: 8px 0;
|
||||
margin-right: 38px;
|
||||
}
|
||||
|
||||
&--outgoing-ultramarine {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -2869,8 +2869,9 @@ export async function startApp(): Promise<void> {
|
|||
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<void> {
|
|||
};
|
||||
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();
|
||||
|
|
|
@ -670,7 +670,7 @@ export const StoryViewer = ({
|
|||
/>
|
||||
{hasReplyModal && canReply && (
|
||||
<StoryViewsNRepliesModal
|
||||
authorTitle={title}
|
||||
authorTitle={firstName || title}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
isGroupStory={isGroupStory}
|
||||
|
|
|
@ -27,6 +27,7 @@ import { Tabs } from './Tabs';
|
|||
import { Theme } from '../util/theme';
|
||||
import { ThemeType } from '../types/Util';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryReplyText } from '../util/getStoryReplyText';
|
||||
|
||||
type ViewType = Pick<
|
||||
ConversationType,
|
||||
|
@ -135,88 +136,88 @@ export const StoryViewsNRepliesModal = ({
|
|||
|
||||
if (!isMyStory) {
|
||||
composerElement = (
|
||||
<div className="StoryViewsNRepliesModal__compose-container">
|
||||
<div className="StoryViewsNRepliesModal__composer">
|
||||
{!isGroupStory && (
|
||||
<Quote
|
||||
authorTitle={authorTitle}
|
||||
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}
|
||||
<>
|
||||
{!isGroupStory && (
|
||||
<Quote
|
||||
authorTitle={authorTitle}
|
||||
conversationColor="ultramarine"
|
||||
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>
|
||||
isFromMe={false}
|
||||
isStoryReply
|
||||
isViewOnce={false}
|
||||
moduleClassName="StoryViewsNRepliesModal__quote"
|
||||
rawAttachment={storyPreviewAttachment}
|
||||
referencedMessageNotFound={false}
|
||||
text={getStoryReplyText(i18n, storyPreviewAttachment)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1540,15 +1540,53 @@ story.add('Story reply', () => {
|
|||
const conversation = getDefaultConversation();
|
||||
|
||||
return renderThree({
|
||||
...createProps({ text: 'Wow!' }),
|
||||
...createProps({ direction: 'outgoing', text: 'Wow!' }),
|
||||
storyReplyContext: {
|
||||
authorTitle: conversation.title,
|
||||
authorTitle: conversation.firstName || conversation.title,
|
||||
conversationColor: ConversationColors[0],
|
||||
isFromMe: false,
|
||||
rawAttachment: fakeAttachment({
|
||||
url: '/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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -227,8 +227,11 @@ export type PropsData = {
|
|||
authorTitle: string;
|
||||
conversationColor: ConversationColorType;
|
||||
customColor?: CustomColorType;
|
||||
emoji?: string;
|
||||
isFromMe: boolean;
|
||||
rawAttachment?: QuotedAttachmentType;
|
||||
referencedMessageNotFound?: boolean;
|
||||
text: string;
|
||||
};
|
||||
previews: Array<LinkPreviewType>;
|
||||
|
||||
|
@ -1299,27 +1302,35 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<Quote
|
||||
authorTitle={storyReplyContext.authorTitle}
|
||||
conversationColor={conversationColor}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveTopRight={curveTopRight}
|
||||
customColor={customColor}
|
||||
i18n={i18n}
|
||||
isFromMe={storyReplyContext.isFromMe}
|
||||
isIncoming={isIncoming}
|
||||
isViewOnce={false}
|
||||
moduleClassName="StoryReplyQuote"
|
||||
onClick={() => {
|
||||
// TODO DESKTOP-3255
|
||||
}}
|
||||
rawAttachment={storyReplyContext.rawAttachment}
|
||||
referencedMessageNotFound={false}
|
||||
text={i18n('message--getNotificationText--text-with-emoji', {
|
||||
text: i18n('message--getNotificationText--photo'),
|
||||
emoji: '📷',
|
||||
})}
|
||||
/>
|
||||
<>
|
||||
{storyReplyContext.emoji && (
|
||||
<div className="module-message__quote-story-reaction-header">
|
||||
{i18n('Quote__story-reaction', [storyReplyContext.authorTitle])}
|
||||
</div>
|
||||
)}
|
||||
<Quote
|
||||
authorTitle={storyReplyContext.authorTitle}
|
||||
conversationColor={conversationColor}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveTopRight={curveTopRight}
|
||||
customColor={customColor}
|
||||
i18n={i18n}
|
||||
isFromMe={storyReplyContext.isFromMe}
|
||||
isIncoming={isIncoming}
|
||||
isStoryReply
|
||||
isViewOnce={false}
|
||||
moduleClassName="StoryReplyQuote"
|
||||
onClick={() => {
|
||||
// TODO DESKTOP-3255
|
||||
}}
|
||||
rawAttachment={storyReplyContext.rawAttachment}
|
||||
reactionEmoji={storyReplyContext.emoji}
|
||||
referencedMessageNotFound={Boolean(
|
||||
storyReplyContext.referencedMessageNotFound
|
||||
)}
|
||||
text={storyReplyContext.text}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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="🏋️"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -17,6 +17,8 @@ import type {
|
|||
CustomColorType,
|
||||
} from '../../types/Colors';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Emojify } from './Emojify';
|
||||
import { TextAttachment } from '../TextAttachment';
|
||||
import { getTextWithMentions } from '../../util/getTextWithMentions';
|
||||
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||
|
@ -31,12 +33,14 @@ export type Props = {
|
|||
i18n: LocalizerType;
|
||||
isFromMe: boolean;
|
||||
isIncoming?: boolean;
|
||||
isStoryReply?: boolean;
|
||||
moduleClassName?: string;
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
text: string;
|
||||
rawAttachment?: QuotedAttachmentType;
|
||||
isViewOnce: boolean;
|
||||
reactionEmoji?: string;
|
||||
referencedMessageNotFound: boolean;
|
||||
doubleCheckMissingQuoteReference?: () => unknown;
|
||||
};
|
||||
|
@ -51,6 +55,13 @@ export type QuotedAttachmentType = Pick<
|
|||
>;
|
||||
|
||||
function validateQuote(quote: Props): boolean {
|
||||
if (
|
||||
quote.isStoryReply &&
|
||||
(quote.referencedMessageNotFound || quote.reactionEmoji)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (quote.text) {
|
||||
return true;
|
||||
}
|
||||
|
@ -250,7 +261,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
public renderIconContainer(): JSX.Element | null {
|
||||
const { rawAttachment, isViewOnce } = this.props;
|
||||
const { rawAttachment, isViewOnce, i18n } = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
const attachment = getAttachment(rawAttachment);
|
||||
|
||||
|
@ -265,9 +276,16 @@ export class Quote extends React.Component<Props, State> {
|
|||
return this.renderIcon('view-once');
|
||||
}
|
||||
|
||||
// TODO DESKTOP-3433
|
||||
if (textAttachment) {
|
||||
return this.renderIcon('image');
|
||||
return (
|
||||
<div className={this.getClassName('__icon-container')}>
|
||||
<TextAttachment
|
||||
i18n={i18n}
|
||||
isThumbnail
|
||||
textAttachment={textAttachment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
|
@ -385,7 +403,17 @@ export class Quote extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
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} · {i18n('Quote__story')}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -394,7 +422,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
isIncoming ? this.getClassName('__primary__author--incoming') : null
|
||||
)}
|
||||
>
|
||||
{isFromMe ? i18n('you') : <ContactName title={authorTitle} />}
|
||||
{author}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -405,10 +433,11 @@ export class Quote extends React.Component<Props, State> {
|
|||
customColor,
|
||||
i18n,
|
||||
isIncoming,
|
||||
isStoryReply,
|
||||
referencedMessageNotFound,
|
||||
} = this.props;
|
||||
|
||||
if (!referencedMessageNotFound) {
|
||||
if (!referencedMessageNotFound || isStoryReply) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -452,6 +481,8 @@ export class Quote extends React.Component<Props, State> {
|
|||
customColor,
|
||||
isIncoming,
|
||||
onClick,
|
||||
rawAttachment,
|
||||
reactionEmoji,
|
||||
referencedMessageNotFound,
|
||||
} = this.props;
|
||||
|
||||
|
@ -486,6 +517,17 @@ export class Quote extends React.Component<Props, State> {
|
|||
{this.renderGenericFile()}
|
||||
{this.renderText()}
|
||||
</div>
|
||||
{reactionEmoji && (
|
||||
<div
|
||||
className={
|
||||
rawAttachment
|
||||
? this.getClassName('__reaction-emoji')
|
||||
: this.getClassName('__reaction-emoji--story-unavailable')
|
||||
}
|
||||
>
|
||||
<Emojify text={reactionEmoji} />
|
||||
</div>
|
||||
)}
|
||||
{this.renderIconContainer()}
|
||||
{this.renderClose()}
|
||||
</button>
|
||||
|
|
|
@ -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<string>();
|
||||
|
@ -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(
|
||||
|
|
|
@ -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<ReactionAttributesType> {}
|
||||
|
||||
|
@ -55,7 +56,10 @@ export class Reactions extends Collection<ReactionModel> {
|
|||
return [];
|
||||
}
|
||||
|
||||
async onReaction(reaction: ReactionModel): Promise<void> {
|
||||
async onReaction(
|
||||
reaction: ReactionModel,
|
||||
generatedMessage: MessageModel
|
||||
): Promise<void> {
|
||||
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<ReactionModel> {
|
|||
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);
|
||||
|
|
2
ts/model-types.d.ts
vendored
2
ts/model-types.d.ts
vendored
|
@ -186,7 +186,7 @@ export type MessageAttributesType = {
|
|||
unidentifiedDeliveries?: Array<string>;
|
||||
contact?: Array<EmbeddedContactType>;
|
||||
conversationId: string;
|
||||
reaction?: WhatIsThis;
|
||||
storyReactionEmoji?: string;
|
||||
|
||||
expirationTimerUpdate?: {
|
||||
expireTimer: number;
|
||||
|
|
|
@ -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');
|
||||
if (!storyId) {
|
||||
return;
|
||||
|
@ -316,9 +316,24 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
|
||||
async doubleCheckMissingQuoteReference(): Promise<void> {
|
||||
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!`);
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
39
ts/util/getStoryReplyText.ts
Normal file
39
ts/util/getStoryReplyText.ts
Normal 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');
|
||||
}
|
Loading…
Reference in a new issue