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",
|
"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"
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
.StoryReplyQuote {
|
.StoryReplyQuote {
|
||||||
&__primary {
|
&__primary {
|
||||||
min-height: 64px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon-container {
|
&__icon-container {
|
||||||
|
|
|
@ -152,8 +152,8 @@
|
||||||
|
|
||||||
&__quote {
|
&__quote {
|
||||||
&__container {
|
&__container {
|
||||||
margin-top: 8px;
|
margin: 8px 0;
|
||||||
margin-bottom: 8px;
|
margin-right: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--outgoing-ultramarine {
|
&--outgoing-ultramarine {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
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} · {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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
2
ts/model-types.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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!`);
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
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