Show story replies in the timeline

This commit is contained in:
Josh Perez 2022-03-16 13:30:14 -04:00 committed by GitHub
parent 55716c5db6
commit 3620309f22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 705 additions and 461 deletions

View File

@ -1334,401 +1334,6 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
}
}
// Module: Quoted Reply
.module-quote-container {
margin: {
left: -6px;
right: -6px;
top: 3px;
bottom: 5px;
}
}
.module-quote {
@include button-reset;
width: 100%;
position: relative;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
border-left-width: 4px;
border-left-style: solid;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-ultramarine;
}
}
}
.module-quote--no-click {
cursor: auto;
}
.module-quote--with-reference-warning {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.module-quote--outgoing {
border-left-color: $color-steel;
background-color: $color-steel;
margin-top: -4px;
// To preserve contrast
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-white;
}
}
}
@each $color, $value in $conversation-colors {
.module-quote--incoming-#{$color} {
background-color: scale-color($value, $lightness: 60%);
border-left-color: $value;
@include dark-theme {
background-color: scale-color($value, $lightness: -40%);
}
}
.module-quote--outgoing-#{$color} {
background-color: scale-color($value, $lightness: 60%);
border-left-color: $color-white;
@include dark-theme {
background-color: scale-color($value, $lightness: -40%);
border-left-color: $color-white;
}
}
}
.module-quote--incoming-custom,
.module-quote--outgoing-custom {
background-attachment: fixed;
}
@each $color, $value in $conversation-colors-gradient {
.module-quote--incoming-#{$color} {
border-left-color: map-get($value, 'start');
}
.module-quote--incoming-#{$color},
.module-quote--outgoing-#{$color} {
background-attachment: fixed;
@include light-theme {
background-image: linear-gradient(
map-get($value, 'deg'),
scale-color(map-get($value, 'start'), $lightness: 60%),
scale-color(map-get($value, 'end'), $lightness: 60%)
);
}
@include dark-theme {
background-image: linear-gradient(
map-get($value, 'deg'),
scale-color(map-get($value, 'start'), $lightness: -40%),
scale-color(map-get($value, 'end'), $lightness: -40%)
);
}
}
.module-quote--outgoing-#{$color} {
border-left-color: $color-white;
}
}
.module-quote--curve-top-left {
border-top-left-radius: 12px;
}
.module-quote--curve-top-right {
border-top-right-radius: 12px;
}
.module-quote__primary {
flex-grow: 1;
padding-left: 8px;
padding-right: 8px;
padding-top: 7px;
padding-bottom: 7px;
// To leave room for image thumbnail
min-height: 54px;
}
.module-quote__primary__author {
@include font-body-2-bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__author--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__text {
@include font-body-1;
text-align: start;
@include light-theme {
color: $color-gray-90;
a {
color: $color-gray-90;
}
}
@include dark-theme {
color: $color-gray-05;
a {
color: $color-gray-05;
}
}
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.module-quote__primary__text--incoming {
@include dark-theme {
color: $color-gray-05;
a {
color: $color-gray-05;
}
}
}
.module-quote__primary__type-label {
@include font-body-2-italic;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__type-label--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__filename-label {
@include font-body-2;
}
.module-quote__close-container {
position: absolute;
top: 4px;
right: 4px;
height: 16px;
width: 16px;
border-radius: 50%;
background-color: $color-black-alpha-40;
@include keyboard-mode {
&:focus-within {
background-color: $color-ultramarine;
}
}
}
.module-quote__close-button {
@include button-reset;
width: 14px;
height: 14px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
@include color-svg('../images/icons/v2/x-24.svg', $color-white);
}
.module-quote__icon-container {
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
flex: 0 0 54px;
position: relative;
width: 54px;
}
.module-quote__icon-container__inner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.module-quote__icon-container__circle-background {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
border-radius: 50%;
background-color: $color-white;
}
.module-quote__icon-container__icon {
width: 20px;
height: 20px;
}
.module-quote__icon-container__icon--file {
@include color-svg('../images/file.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--image {
@include color-svg('../images/image.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--microphone {
@include color-svg(
'../images/icons/v2/mic-outline-24.svg',
$color-ultramarine
);
}
.module-quote__icon-container__icon--play {
@include color-svg(
'../images/icons/v2/play-solid-24.svg',
$color-ultramarine
);
}
.module-quote__icon-container__icon--movie {
@include color-svg('../images/movie.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--view-once {
@include color-svg('../images/icons/v2/view-once-24.svg', $color-ultramarine);
}
.module-quote__generic-file {
display: flex;
flex-direction: row;
align-items: center;
}
.module-quote__generic-file__icon {
background: url('../images/file-gradient.svg');
background-size: 75%;
background-repeat: no-repeat;
height: 28px;
width: 36px;
margin-left: -4px;
margin-right: -6px;
margin-bottom: 5px;
}
.module-quote__generic-file__text {
@include font-body-2;
max-width: calc(100% - 26px);
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__generic-file__text--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__reference-warning {
color: $color-gray-90;
height: 26px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-left-style: solid;
border-left-width: 4px;
padding-left: 8px;
padding-right: 8px;
}
.module-quote__reference-warning__icon {
height: 16px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-90);
}
@include dark-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-05);
}
}
.module-quote__reference-warning__icon--incoming {
@include light-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-90);
}
@include dark-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-05);
}
}
.module-quote__reference-warning__text {
@include font-caption;
margin-left: 6px;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__reference-warning__text--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-about {
&__container {
margin-left: auto;

View File

@ -0,0 +1,395 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-quote {
&__container {
margin: {
left: -6px;
right: -6px;
top: 3px;
bottom: 5px;
}
}
@include button-reset;
width: 100%;
position: relative;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
border-left-width: 4px;
border-left-style: solid;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-ultramarine;
}
}
}
.module-quote--no-click {
cursor: auto;
}
.module-quote--with-reference-warning {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.module-quote--outgoing {
border-left-color: $color-steel;
background-color: $color-steel;
margin-top: -4px;
// To preserve contrast
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-white;
}
}
}
@each $color, $value in $conversation-colors {
.module-quote--incoming-#{$color} {
background-color: scale-color($value, $lightness: 60%);
border-left-color: $value;
@include dark-theme {
background-color: scale-color($value, $lightness: -40%);
}
}
.module-quote--outgoing-#{$color} {
background-color: scale-color($value, $lightness: 60%);
border-left-color: $color-white;
@include dark-theme {
background-color: scale-color($value, $lightness: -40%);
border-left-color: $color-white;
}
}
}
.module-quote--incoming-custom,
.module-quote--outgoing-custom {
background-attachment: fixed;
}
@each $color, $value in $conversation-colors-gradient {
.module-quote--incoming-#{$color} {
border-left-color: map-get($value, 'start');
}
.module-quote--incoming-#{$color},
.module-quote--outgoing-#{$color} {
background-attachment: fixed;
@include light-theme {
background-image: linear-gradient(
map-get($value, 'deg'),
scale-color(map-get($value, 'start'), $lightness: 60%),
scale-color(map-get($value, 'end'), $lightness: 60%)
);
}
@include dark-theme {
background-image: linear-gradient(
map-get($value, 'deg'),
scale-color(map-get($value, 'start'), $lightness: -40%),
scale-color(map-get($value, 'end'), $lightness: -40%)
);
}
}
.module-quote--outgoing-#{$color} {
border-left-color: $color-white;
}
}
.module-quote--curve-top-left {
border-top-left-radius: 12px;
}
.module-quote--curve-top-right {
border-top-right-radius: 12px;
}
.module-quote__primary {
flex-grow: 1;
padding-left: 8px;
padding-right: 8px;
padding-top: 7px;
padding-bottom: 7px;
// To leave room for image thumbnail
min-height: 54px;
}
.module-quote__primary__author {
@include font-body-2-bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__author--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__text {
@include font-body-1;
text-align: start;
@include light-theme {
color: $color-gray-90;
a {
color: $color-gray-90;
}
}
@include dark-theme {
color: $color-gray-05;
a {
color: $color-gray-05;
}
}
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.module-quote__primary__text--incoming {
@include dark-theme {
color: $color-gray-05;
a {
color: $color-gray-05;
}
}
}
.module-quote__primary__type-label {
@include font-body-2-italic;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__type-label--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__primary__filename-label {
@include font-body-2;
}
.module-quote__close-container {
position: absolute;
top: 4px;
right: 4px;
height: 16px;
width: 16px;
border-radius: 50%;
background-color: $color-black-alpha-40;
@include keyboard-mode {
&:focus-within {
background-color: $color-ultramarine;
}
}
}
.module-quote__close-button {
@include button-reset;
width: 14px;
height: 14px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
@include color-svg('../images/icons/v2/x-24.svg', $color-white);
}
.module-quote__icon-container {
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
flex: 0 0 54px;
position: relative;
width: 54px;
}
.module-quote__icon-container__inner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.module-quote__icon-container__circle-background {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
border-radius: 50%;
background-color: $color-white;
}
.module-quote__icon-container__icon {
width: 20px;
height: 20px;
}
.module-quote__icon-container__icon--file {
@include color-svg('../images/file.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--image {
@include color-svg('../images/image.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--microphone {
@include color-svg(
'../images/icons/v2/mic-outline-24.svg',
$color-ultramarine
);
}
.module-quote__icon-container__icon--play {
@include color-svg(
'../images/icons/v2/play-solid-24.svg',
$color-ultramarine
);
}
.module-quote__icon-container__icon--movie {
@include color-svg('../images/movie.svg', $color-ultramarine);
}
.module-quote__icon-container__icon--view-once {
@include color-svg('../images/icons/v2/view-once-24.svg', $color-ultramarine);
}
.module-quote__generic-file {
display: flex;
flex-direction: row;
align-items: center;
}
.module-quote__generic-file__icon {
background: url('../images/file-gradient.svg');
background-size: 75%;
background-repeat: no-repeat;
height: 28px;
width: 36px;
margin-left: -4px;
margin-right: -6px;
margin-bottom: 5px;
}
.module-quote__generic-file__text {
@include font-body-2;
max-width: calc(100% - 26px);
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__generic-file__text--incoming {
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__reference-warning {
color: $color-gray-90;
height: 26px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-left-style: solid;
border-left-width: 4px;
padding-left: 8px;
padding-right: 8px;
}
.module-quote__reference-warning__icon {
height: 16px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-90);
}
@include dark-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-05);
}
}
.module-quote__reference-warning__icon--incoming {
@include light-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-90);
}
@include dark-theme {
@include color-svg('../images/icons/v2/link-broken-16.svg', $color-gray-05);
}
}
.module-quote__reference-warning__text {
@include font-caption;
margin-left: 6px;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-quote__reference-warning__text--incoming {
@include dark-theme {
color: $color-gray-05;
}
}

View File

@ -0,0 +1,14 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryReplyQuote {
&__primary {
min-height: 64px;
}
&__icon-container {
flex: 0 0 40px;
height: 64px;
width: 40px;
}
}

View File

@ -13,11 +13,6 @@
justify-content: flex-end;
}
.module-quote-container {
margin: 0;
margin-bottom: 8px;
}
&__compose-container {
display: flex;
align-items: center;
@ -131,6 +126,37 @@
margin-left: 8px;
padding: 7px 12px;
}
&__quote {
&__container {
margin-top: 8px;
margin-bottom: 8px;
}
&--outgoing-ultramarine {
@include dark-theme {
background-color: $color-gray-60;
background-image: none;
}
}
&__primary {
min-height: 64px;
color: $color-gray-05;
font-size: 12px;
font-weight: 400;
&__author,
&__text {
}
}
&__icon-container {
flex: 0 0 40px;
height: 64px;
width: 40px;
}
}
}
.Tabs.StoryViewsNRepliesModal__tabs {

View File

@ -90,6 +90,7 @@
@import './components/PermissionsPopup.scss';
@import './components/Preferences.scss';
@import './components/ProfileEditor.scss';
@import './components/Quote.scss';
@import './components/ReactionPickerPicker.scss';
@import './components/SafetyNumberChangeDialog.scss';
@import './components/SafetyNumberViewer.scss';
@ -100,6 +101,7 @@
@import './components/Slider.scss';
@import './components/Stories.scss';
@import './components/StoryListItem.scss';
@import './components/StoryReplyQuote.scss';
@import './components/StoryViewsNRepliesModal.scss';
@import './components/StoryViewer.scss';
@import './components/SystemMessage.scss';

View File

@ -146,10 +146,11 @@ export const StoryViewsNRepliesModal = ({
{!replies.length && (
<Quote
authorTitle={authorTitle}
conversationColor="steel"
conversationColor="ultramarine"
i18n={i18n}
isFromMe={false}
isViewOnce={false}
moduleClassName="StoryViewsNRepliesModal__quote"
rawAttachment={storyPreviewAttachment}
referencedMessageNotFound={false}
text={i18n('message--getNotificationText--text-with-emoji', {

View File

@ -31,7 +31,10 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver
import { WidthBreakpoint } from '../_util';
import { MINUTE } from '../../util/durations';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
import {
fakeAttachment,
fakeThumbnail,
} from '../../test-both/helpers/fakeAttachment';
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
import { ThemeType } from '../../types/Util';
@ -1463,3 +1466,22 @@ story.add('Collapsing text-only group messages', () => {
}),
]);
});
story.add('Story reply', () => {
const conversation = getDefaultConversation();
return (
<Message
{...createProps({ text: 'Wow!' })}
storyReplyContext={{
authorTitle: conversation.title,
conversationColor: ConversationColors[0],
isFromMe: false,
rawAttachment: fakeAttachment({
url: '/fixtures/snow.jpg',
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
}}
/>
);
});

View File

@ -217,6 +217,13 @@ export type PropsData = {
referencedMessageNotFound: boolean;
isViewOnce: boolean;
};
storyReplyContext?: {
authorTitle: string;
conversationColor: ConversationColorType;
customColor?: CustomColorType;
isFromMe: boolean;
rawAttachment?: QuotedAttachmentType;
};
previews: Array<LinkPreviewType>;
isTapToView?: boolean;
@ -1255,6 +1262,59 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public renderStoryReplyContext(): JSX.Element | null {
const {
conversationColor,
customColor,
direction,
i18n,
storyReplyContext,
} = this.props;
if (!storyReplyContext) {
return null;
}
const isIncoming = direction === 'incoming';
let curveTopLeft: boolean;
let curveTopRight: boolean;
if (this.shouldRenderAuthor()) {
curveTopLeft = false;
curveTopRight = false;
} else if (isIncoming) {
curveTopLeft = !this.isCollapsedAbove();
curveTopRight = true;
} else {
curveTopLeft = true;
curveTopRight = !this.isCollapsedAbove();
}
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: '📷',
})}
/>
);
}
public renderEmbeddedContact(): JSX.Element | null {
const {
contact,
@ -2284,6 +2344,7 @@ export class Message extends React.PureComponent<Props, State> {
return (
<>
{this.renderQuote()}
{this.renderStoryReplyContext()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()}

View File

@ -18,6 +18,7 @@ import type {
} from '../../types/Colors';
import { ContactName } from './ContactName';
import { getTextWithMentions } from '../../util/getTextWithMentions';
import { getClassNamesFor } from '../../util/getClassNamesFor';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
export type Props = {
@ -30,6 +31,7 @@ export type Props = {
i18n: LocalizerType;
isFromMe: boolean;
isIncoming?: boolean;
moduleClassName?: string;
onClick?: () => void;
onClose?: () => void;
text: string;
@ -113,11 +115,14 @@ function getTypeLabel({
}
export class Quote extends React.Component<Props, State> {
private getClassName: (modifier?: string) => string;
constructor(props: Props) {
super(props);
this.state = {
imageBroken: false,
};
this.getClassName = getClassNamesFor('module-quote', props.moduleClassName);
}
override componentDidMount(): void {
@ -164,12 +169,14 @@ export class Quote extends React.Component<Props, State> {
public renderImage(url: string, icon?: string): JSX.Element {
const iconElement = icon ? (
<div className="module-quote__icon-container__inner">
<div className="module-quote__icon-container__circle-background">
<div className={this.getClassName('__icon-container__inner')}>
<div
className={this.getClassName('__icon-container__circle-background')}
>
<div
className={classNames(
'module-quote__icon-container__icon',
`module-quote__icon-container__icon--${icon}`
this.getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`)
)}
/>
</div>
@ -177,7 +184,11 @@ export class Quote extends React.Component<Props, State> {
) : null;
return (
<ThumbnailImage src={url} onError={this.handleImageError}>
<ThumbnailImage
className={this.getClassName('__icon-container')}
src={url}
onError={this.handleImageError}
>
{iconElement}
</ThumbnailImage>
);
@ -185,13 +196,15 @@ export class Quote extends React.Component<Props, State> {
public renderIcon(icon: string): JSX.Element {
return (
<div className="module-quote__icon-container">
<div className="module-quote__icon-container__inner">
<div className="module-quote__icon-container__circle-background">
<div className={this.getClassName('__icon-container')}>
<div className={this.getClassName('__icon-container__inner')}>
<div
className={this.getClassName('__icon-container__circle-background')}
>
<div
className={classNames(
'module-quote__icon-container__icon',
`module-quote__icon-container__icon--${icon}`
this.getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`)
)}
/>
</div>
@ -219,12 +232,14 @@ export class Quote extends React.Component<Props, State> {
}
return (
<div className="module-quote__generic-file">
<div className="module-quote__generic-file__icon" />
<div className={this.getClassName('__generic-file')}>
<div className={this.getClassName('__generic-file__icon')} />
<div
className={classNames(
'module-quote__generic-file__text',
isIncoming ? 'module-quote__generic-file__text--incoming' : null
this.getClassName('__generic-file__text'),
isIncoming
? this.getClassName('__generic-file__text--incoming')
: null
)}
>
{fileName}
@ -279,8 +294,8 @@ export class Quote extends React.Component<Props, State> {
<div
dir="auto"
className={classNames(
'module-quote__primary__text',
isIncoming ? 'module-quote__primary__text--incoming' : null
this.getClassName('__primary__text'),
isIncoming ? this.getClassName('__primary__text--incoming') : null
)}
>
<MessageBody
@ -311,8 +326,10 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
'module-quote__primary__type-label',
isIncoming ? 'module-quote__primary__type-label--incoming' : null
this.getClassName('__primary__type-label'),
isIncoming
? this.getClassName('__primary__type-label--incoming')
: null
)}
>
{typeLabel}
@ -347,12 +364,12 @@ export class Quote extends React.Component<Props, State> {
// We need the container to give us the flexibility to implement the iOS design.
return (
<div className="module-quote__close-container">
<div className={this.getClassName('__close-container')}>
<div
tabIndex={0}
// We can't be a button because the overall quote is a button; can't nest them
role="button"
className="module-quote__close-button"
className={this.getClassName('__close-button')}
aria-label={i18n('close')}
onKeyDown={keyDownHandler}
onClick={clickHandler}
@ -367,8 +384,8 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
'module-quote__primary__author',
isIncoming ? 'module-quote__primary__author--incoming' : null
this.getClassName('__primary__author'),
isIncoming ? this.getClassName('__primary__author--incoming') : null
)}
>
{isFromMe ? i18n('you') : <ContactName title={authorTitle} />}
@ -392,26 +409,26 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
'module-quote__reference-warning',
this.getClassName('__reference-warning'),
isIncoming
? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`
? this.getClassName(`--incoming-${conversationColor}`)
: this.getClassName(`--outgoing-${conversationColor}`)
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div
className={classNames(
'module-quote__reference-warning__icon',
this.getClassName('__reference-warning__icon'),
isIncoming
? 'module-quote__reference-warning__icon--incoming'
? this.getClassName('__reference-warning__icon--incoming')
: null
)}
/>
<div
className={classNames(
'module-quote__reference-warning__text',
this.getClassName('__reference-warning__text'),
isIncoming
? 'module-quote__reference-warning__text--incoming'
? this.getClassName('__reference-warning__text--incoming')
: null
)}
>
@ -437,25 +454,28 @@ export class Quote extends React.Component<Props, State> {
}
return (
<div className="module-quote-container">
<div className={this.getClassName('__container')}>
<button
type="button"
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
className={classNames(
'module-quote',
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
this.getClassName(''),
isIncoming
? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`,
!onClick && 'module-quote--no-click',
referencedMessageNotFound && 'module-quote--with-reference-warning',
curveTopLeft && 'module-quote--curve-top-left',
curveTopRight && 'module-quote--curve-top-right'
? this.getClassName('--incoming')
: this.getClassName('--outgoing'),
isIncoming
? this.getClassName(`--incoming-${conversationColor}`)
: this.getClassName(`--outgoing-${conversationColor}`),
!onClick && this.getClassName('--no-click'),
referencedMessageNotFound &&
this.getClassName('--with-reference-warning'),
curveTopLeft && this.getClassName('--curve-top-left'),
curveTopRight && this.getClassName('--curve-top-right')
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div className="module-quote__primary">
<div className={this.getClassName('__primary')}>
{this.renderAuthor()}
{this.renderGenericFile()}
{this.renderText()}
@ -470,10 +490,12 @@ export class Quote extends React.Component<Props, State> {
}
function ThumbnailImage({
className,
src,
onError,
children,
}: Readonly<{
className: string;
src: string;
onError: () => void;
children: ReactNode;
@ -507,7 +529,7 @@ function ThumbnailImage({
return (
<div
className="module-quote__icon-container"
className={className}
style={
loadedSrc ? { backgroundImage: `url('${encodeURI(loadedSrc)}')` } : {}
}

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

@ -85,6 +85,12 @@ export type QuotedMessageType = {
messageId: string;
};
type StoryReplyContextType = {
attachment?: AttachmentType;
authorUuid?: string;
messageId: string;
};
export type StickerMessageType = {
packId: string;
stickerId: number;
@ -147,6 +153,7 @@ export type MessageAttributesType = {
retryOptions?: RetryOptions;
sourceDevice?: number;
storyId?: string;
storyReplyContext?: StoryReplyContextType;
supportedVersionAtReceive?: unknown;
synced?: boolean;
unidentifiedDeliveryReceived?: boolean;

View File

@ -1711,6 +1711,8 @@ export class ConversationModel extends window.Backbone
log.warn(`cleanModels: Upgraded schema of ${upgraded} messages`);
}
await Promise.all(result.map(model => model.hydrateStoryContext()));
return result;
}

View File

@ -150,6 +150,7 @@ import { findStoryMessage } from '../util/findStoryMessage';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -305,6 +306,33 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
);
}
async hydrateStoryContext(): Promise<void> {
const storyId = this.get('storyId');
if (!storyId) {
return;
}
if (this.get('storyReplyContext')) {
return;
}
const message = await getMessageById(storyId);
if (!message) {
return;
}
const attachments = message.get('attachments');
this.set({
storyReplyContext: {
attachment: attachments ? attachments[0] : undefined,
authorUuid: message.get('sourceUuid'),
messageId: message.get('id'),
},
});
}
getPropsForMessageDetail(ourConversationId: string): PropsForMessageDetail {
const newIdentity = window.i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
@ -2212,6 +2240,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
quote,
storyId: storyQuote?.id,
};
const dataMessage = await upgradeMessageSchema(withQuoteReference);
try {

View File

@ -2086,7 +2086,7 @@ async function getUnreadByConversationAndMarkRead({
) AND
expireTimer > 0 AND
conversationId = $conversationId AND
storyId IS $storyId AND
($storyId IS NULL OR storyId IS $storyId) AND
received_at <= $newestUnreadAt;
`
).run({
@ -2105,7 +2105,7 @@ async function getUnreadByConversationAndMarkRead({
WHERE
readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND
storyId IS $storyId AND
($storyId IS NULL OR storyId IS $storyId) AND
received_at <= $newestUnreadAt
ORDER BY received_at DESC, sent_at DESC;
`
@ -2125,7 +2125,7 @@ async function getUnreadByConversationAndMarkRead({
WHERE
readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND
storyId IS $storyId AND
($storyId IS NULL OR storyId IS $storyId) AND
received_at <= $newestUnreadAt;
`
).run({
@ -2360,7 +2360,7 @@ function getOlderMessagesByConversationSync(
conversationId = $conversationId AND
($messageId IS NULL OR id IS NOT $messageId) AND
isStory IS 0 AND
storyId IS $storyId AND
($storyId IS NULL OR storyId IS $storyId) AND
(
(received_at = $received_at AND sent_at < $sent_at) OR
received_at < $received_at
@ -2453,7 +2453,7 @@ function getNewerMessagesByConversationSync(
SELECT json FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND
storyId IS $storyId AND
($storyId IS NULL OR storyId IS $storyId) AND
(
(received_at = $received_at AND sent_at > $sent_at) OR
received_at > $received_at
@ -2483,7 +2483,7 @@ function getOldestMessageForConversation(
SELECT * FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND
storyId IS $storyId
($storyId IS NULL OR storyId IS $storyId)
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`
@ -2510,7 +2510,7 @@ function getNewestMessageForConversation(
SELECT * FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND
storyId IS $storyId
($storyId IS NULL OR storyId IS $storyId)
ORDER BY received_at DESC, sent_at DESC
LIMIT 1;
`
@ -2651,7 +2651,7 @@ function getOldestUnreadMessageForConversation(
conversationId = $conversationId AND
readStatus = ${ReadStatus.Unread} AND
isStory IS 0 AND
storyId IS $storyId
($storyId IS NULL OR storyId IS $storyId)
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`
@ -2688,7 +2688,7 @@ function getTotalUnreadForConversationSync(
conversationId = $conversationId AND
readStatus = ${ReadStatus.Unread} AND
isStory IS 0 AND
storyId IS $storyId;
($storyId IS NULL OR storyId IS $storyId);
`
)
.get({

View File

@ -432,6 +432,55 @@ export const getReactionsForMessage = createSelectorCreator(
(_, reactions): PropsData['reactions'] => reactions
);
export const getPropsForStoryReplyContext = createSelectorCreator(
memoizeByRoot,
isEqual
)(
// `memoizeByRoot` requirement
identity,
(
message: Pick<
MessageWithUIFieldsType,
'body' | 'conversationId' | 'storyReplyContext'
>,
{
conversationSelector,
ourConversationId,
}: {
conversationSelector: GetConversationByIdType;
ourConversationId?: string;
}
): PropsData['storyReplyContext'] => {
const { storyReplyContext } = message;
if (!storyReplyContext) {
return undefined;
}
const contact = conversationSelector(storyReplyContext.authorUuid);
const authorTitle = contact.title;
const isFromMe = contact.id === ourConversationId;
const conversation = getConversation(message, conversationSelector);
const { conversationColor, customColor } =
getConversationColorAttributes(conversation);
return {
authorTitle,
conversationColor,
customColor,
isFromMe,
rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment)
: undefined,
};
},
(_, storyReplyContext): PropsData['storyReplyContext'] => storyReplyContext
);
export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)(
// `memoizeByRoot` requirement
identity,
@ -651,6 +700,7 @@ export const getPropsForMessage: (
getPreviewsForMessage,
getReactionsForMessage,
getPropsForQuote,
getPropsForStoryReplyContext,
getShallowPropsForMessage,
(
_,
@ -660,6 +710,7 @@ export const getPropsForMessage: (
previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'],
quote: PropsData['quote'],
storyReplyContext: PropsData['storyReplyContext'],
shallowProps: ShallowPropsType
): Omit<PropsForMessage, 'renderingContext'> => {
return {
@ -669,6 +720,7 @@ export const getPropsForMessage: (
previews,
quote,
reactions,
storyReplyContext,
...shallowProps,
};
}

View File

@ -7,6 +7,7 @@ import type { ConversationType } from '../../state/ducks/conversations';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import { getRandomColor } from './getRandomColor';
import { ConversationColors } from '../../types/Colors';
const FIRST_NAMES = [
'James',
@ -335,6 +336,7 @@ export function getDefaultConversation(
avatarPath: getAvatarPath(),
badges: [],
e164: '+1300555000',
conversationColor: ConversationColors[0],
color: getRandomColor(),
firstName,
id: generateUuid(),

View File

@ -125,8 +125,8 @@ describe('sql/markRead', () => {
assert.lengthOf(await _getAllMessages(), 7);
assert.strictEqual(
await getTotalUnreadForConversation(conversationId),
3,
'uread count'
4,
'unread count'
);
const markedRead = await getUnreadByConversationAndMarkRead({
@ -138,7 +138,7 @@ describe('sql/markRead', () => {
assert.lengthOf(markedRead, 2, 'two messages marked read');
assert.strictEqual(
await getTotalUnreadForConversation(conversationId),
1,
2,
'unread count'
);
@ -160,7 +160,7 @@ describe('sql/markRead', () => {
readAt,
});
assert.lengthOf(markedRead2, 1, 'one message marked read');
assert.lengthOf(markedRead2, 3, 'three messages marked read');
assert.strictEqual(markedRead2[0].id, message7.id, 'should be message7');
assert.strictEqual(

View File

@ -95,7 +95,7 @@ describe('sql/timelineFetches', () => {
const messages = await getOlderMessagesByConversation(conversationId, {
limit: 5,
});
assert.lengthOf(messages, 2);
assert.lengthOf(messages, 3);
// Fetched with DESC query, but with reverse() call afterwards
assert.strictEqual(messages[0].id, message1.id);
@ -383,9 +383,9 @@ describe('sql/timelineFetches', () => {
limit: 5,
});
assert.lengthOf(messages, 2);
assert.strictEqual(messages[0].id, message4.id, 'checking message 4');
assert.strictEqual(messages[1].id, message5.id, 'checking message 5');
assert.lengthOf(messages, 3);
assert.strictEqual(messages[0].id, message3.id, 'checking message 3');
assert.strictEqual(messages[1].id, message4.id, 'checking message 4');
});
it('returns N oldest messages for a given story with no parameters', async () => {
@ -655,14 +655,18 @@ describe('sql/timelineFetches', () => {
const metricsInTimeline = await getMessageMetricsForConversation(
conversationId
);
assert.strictEqual(metricsInTimeline?.oldest?.id, oldest.id, 'oldest');
assert.strictEqual(
metricsInTimeline?.oldest?.id,
oldestInStory.id,
'oldest'
);
assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest');
assert.strictEqual(
metricsInTimeline?.oldestUnread?.id,
oldestUnread.id,
'oldestUnread'
);
assert.strictEqual(metricsInTimeline?.totalUnread, 2, 'totalUnread');
assert.strictEqual(metricsInTimeline?.totalUnread, 3, 'totalUnread');
const metricsInStory = await getMessageMetricsForConversation(
conversationId,