Adds logic around downloading stories

This commit is contained in:
Josh Perez 2022-03-28 21:10:08 -04:00 committed by GitHub
parent 9d3f0072a5
commit 3b5cc26fec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 645 additions and 149 deletions

View File

@ -6992,6 +6992,10 @@
}
}
},
"StoryImage__error": {
"message": "Error displaying image",
"description": "aria-label for image errors"
},
"WhatsNew__modal-title": {
"message": "What's New",
"description": "Title for the whats new modal"

View File

@ -0,0 +1,69 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryImage {
align-items: center;
border-radius: 8px;
display: flex;
height: 100%;
justify-content: center;
overflow: hidden;
position: relative;
width: 100%;
z-index: $z-index-base;
&--thumbnail {
height: 72px;
width: 46px;
}
&__image {
height: 100%;
object-fit: cover;
width: 100%;
}
&__error {
height: 100%;
max-width: 140px;
width: 100%;
@include color-svg(
'../images/full-screen-flow/alert-outline.svg',
$color-gray-25
);
}
&__spinner-container {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
&__spinner-bubble {
align-items: center;
background-color: $color-gray-75;
border-radius: 32px;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
}
&__spinner {
@include dark-theme {
&__circle {
background-color: $color-white;
}
&__arc {
background-color: $color-gray-75;
}
}
}
}

View File

@ -22,6 +22,7 @@
&--title {
@include font-body-1-bold;
color: $color-gray-05;
}
&--timestamp {

View File

@ -64,6 +64,7 @@
position: absolute;
transform: translateX(-50%);
width: 284px;
z-index: $z-index-above-base;
&--group-avatar {
margin-left: -8px;

View File

@ -100,6 +100,7 @@
@import './components/Select.scss';
@import './components/Slider.scss';
@import './components/Stories.scss';
@import './components/StoryImage.scss';
@import './components/StoryListItem.scss';
@import './components/StoryReplyQuote.scss';
@import './components/StoryViewsNRepliesModal.scss';

View File

@ -59,59 +59,55 @@ function createStory({
};
}
function getAttachmentWithThumbnail(url: string): AttachmentType {
return fakeAttachment({
url,
thumbnail: fakeThumbnail(url),
});
}
const getDefaultProps = (): PropsType => ({
hiddenStories: [],
i18n,
openConversationInternal: action('openConversationInternal'),
preferredWidthFromStorage: 380,
queueStoryDownload: action('queueStoryDownload'),
renderStoryViewer: () => <div />,
stories: [
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
attachment: getAttachmentWithThumbnail(
'/fixtures/tina-rolf-269345-unsplash.jpg'
),
timestamp: Date.now() - 2 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
),
}),
attachment: getAttachmentWithThumbnail(
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
),
timestamp: Date.now() - 5 * durations.MINUTE,
}),
createStory({
group: { title: 'BBQ in the park' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
attachment: getAttachmentWithThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
timestamp: Date.now() - 65 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'),
timestamp: Date.now() - 92 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-1-64-64.jpg'),
}),
attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'),
timestamp: Date.now() - 164 * durations.MINUTE,
}),
createStory({
group: { title: 'Breaking Signal for Science' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-2-64-64.jpg'),
}),
attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-3-64-64.jpg'),
}),
attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'),
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],

View File

@ -16,6 +16,7 @@ export type PropsType = {
preferredWidthFromStorage: number;
openConversationInternal: (_: { conversationId: string }) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
queueStoryDownload: (storyId: string) => unknown;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
@ -31,6 +32,7 @@ export const Stories = ({
i18n,
openConversationInternal,
preferredWidthFromStorage,
queueStoryDownload,
renderStoryViewer,
stories,
toggleHideStories,
@ -99,6 +101,7 @@ export const Stories = ({
}
}}
openConversationInternal={openConversationInternal}
queueStoryDownload={queueStoryDownload}
stories={stories}
toggleHideStories={toggleHideStories}
/>

View File

@ -44,12 +44,17 @@ function search(
);
}
function getNewestStory(story: ConversationStoryType): StoryViewType {
return story.stories[story.stories.length - 1];
}
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
onBack: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
openConversationInternal: (_: { conversationId: string }) => unknown;
queueStoryDownload: (storyId: string) => unknown;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
};
@ -59,6 +64,7 @@ export const StoriesPane = ({
onBack,
onStoryClicked,
openConversationInternal,
queueStoryDownload,
stories,
toggleHideStories,
}: PropsType): JSX.Element => {
@ -103,18 +109,19 @@ export const StoriesPane = ({
>
{renderedStories.map(story => (
<StoryListItem
key={story.stories[0].timestamp}
key={getNewestStory(story).timestamp}
i18n={i18n}
onClick={() => {
onStoryClicked(story.conversationId);
}}
onHideStory={() => {
toggleHideStories(story.stories[0].sender.id);
toggleHideStories(getNewestStory(story).sender.id);
}}
onGoToConversation={conversationId => {
openConversationInternal({ conversationId });
}}
story={story.stories[0]}
queueStoryDownload={queueStoryDownload}
story={getNewestStory(story)}
/>
))}
{!stories.length && i18n('Stories__list-empty')}

View File

@ -0,0 +1,89 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { v4 as uuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryImage';
import { StoryImage } from './StoryImage';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import {
fakeAttachment,
fakeThumbnail,
} from '../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryImage', module);
function getDefaultProps(): PropsType {
return {
attachment: fakeAttachment({
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
thumbnail: fakeThumbnail('/fixtures/nathan-anderson-316188-unsplash.jpg'),
}),
i18n,
label: 'A story',
queueStoryDownload: action('queueStoryDownload'),
storyId: uuid(),
};
}
story.add('Good story', () => <StoryImage {...getDefaultProps()} />);
story.add('Good story (thumbnail)', () => (
<StoryImage {...getDefaultProps()} isThumbnail />
));
story.add('Not downloaded', () => (
<StoryImage {...getDefaultProps()} attachment={fakeAttachment()} />
));
story.add('Not downloaded (thumbnail)', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment()}
isThumbnail
/>
));
story.add('Pending download', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
pending: true,
})}
/>
));
story.add('Pending download (thumbnail)', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
pending: true,
})}
isThumbnail
/>
));
story.add('Broken Image', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
url: '/this/path/does/not/exist.jpg',
})}
/>
));
story.add('Broken Image (thumbnail)', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
url: '/this/path/does/not/exist.jpg',
})}
isThumbnail
/>
));

View File

@ -0,0 +1,112 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import type { AttachmentType } from '../types/Attachment';
import type { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { ThemeType } from '../types/Util';
import {
defaultBlurHash,
isDownloaded,
hasNotResolved,
isDownloading,
} from '../types/Attachment';
import { getClassNamesFor } from '../util/getClassNamesFor';
export type PropsType = {
readonly attachment?: AttachmentType;
i18n: LocalizerType;
readonly isThumbnail?: boolean;
readonly label: string;
readonly moduleClassName?: string;
readonly queueStoryDownload: (storyId: string) => unknown;
readonly storyId: string;
};
export const StoryImage = ({
attachment,
i18n,
isThumbnail,
label,
moduleClassName,
queueStoryDownload,
storyId,
}: PropsType): JSX.Element | null => {
const [attachmentBroken, setAttachmentBroken] = useState<boolean>(false);
const shouldDownloadAttachment =
!isDownloaded(attachment) && !isDownloading(attachment);
useEffect(() => {
if (shouldDownloadAttachment) {
queueStoryDownload(storyId);
}
}, [queueStoryDownload, shouldDownloadAttachment, storyId]);
if (!attachment) {
return null;
}
const isPending = Boolean(attachment.pending);
const isNotReadyToShow = hasNotResolved(attachment) || isPending;
const getClassName = getClassNamesFor('StoryImage', moduleClassName);
let storyElement: JSX.Element;
if (isNotReadyToShow) {
storyElement = (
<Blurhash
hash={attachment.blurHash || defaultBlurHash(ThemeType.dark)}
height={attachment.height}
width={attachment.width}
/>
);
} else if (attachmentBroken) {
storyElement = (
<div
aria-label={i18n('StoryImage__error')}
className="StoryImage__error"
/>
);
} else {
storyElement = (
<img
alt={label}
className={getClassName('__image')}
onError={() => setAttachmentBroken(true)}
src={
isThumbnail && attachment.thumbnail
? attachment.thumbnail.url
: attachment.url
}
/>
);
}
let spinner: JSX.Element | undefined;
if (isPending) {
spinner = (
<div className="StoryImage__spinner-container">
<div className="StoryImage__spinner-bubble" title={i18n('loading')}>
<Spinner moduleClassName="StoryImage__spinner" svgSize="small" />
</div>
</div>
);
}
return (
<div
className={classNames(
getClassName(''),
isThumbnail ? getClassName('--thumbnail') : undefined
)}
>
{storyElement}
{spinner}
</div>
);
};

View File

@ -23,6 +23,7 @@ function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
queueStoryDownload: action('queueStoryDownload'),
story: {
messageId: '123',
sender: getDefaultConversation(),

View File

@ -9,8 +9,9 @@ import type { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu';
import { getAvatarColor } from '../types/Colors';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { getAvatarColor } from '../types/Colors';
export type ConversationStoryType = {
conversationId: string;
@ -53,6 +54,7 @@ export type PropsType = Pick<
onClick: () => unknown;
onGoToConversation?: (conversationId: string) => unknown;
onHideStory?: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown;
story: StoryViewType;
};
@ -64,6 +66,7 @@ export const StoryListItem = ({
onClick,
onGoToConversation,
onHideStory,
queueStoryDownload,
story,
}: PropsType): JSX.Element => {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
@ -182,14 +185,15 @@ export const StoryListItem = ({
/>
)}
{hasMultiple && <div className="StoryListItem__previews--more" />}
{attachment && (
<div
className="StoryListItem__previews--image"
style={{
backgroundImage: `url("${attachment.thumbnail?.url}")`,
}}
/>
)}
<StoryImage
attachment={attachment}
i18n={i18n}
isThumbnail
label=""
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
/>
</div>
</button>
<ContextMenuPopper

View File

@ -31,6 +31,7 @@ function getDefaultProps(): PropsType {
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
queueStoryDownload: action('queueStoryDownload'),
renderEmojiPicker: () => <div />,
replies: Math.floor(Math.random() * 20),
stories: [

View File

@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSpring, animated, to } from '@react-spring/web';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
@ -12,7 +12,9 @@ import type { StoryViewType } from './StoryListItem';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { isDownloaded, isDownloading } from '../types/Attachment';
import { getAvatarColor } from '../types/Colors';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
@ -37,6 +39,7 @@ export type PropsType = {
) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
queueStoryDownload: (storyId: string) => unknown;
recentEmojis?: Array<string>;
replies?: number;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
@ -59,6 +62,7 @@ export const StoryViewer = ({
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
queueStoryDownload,
recentEmojis,
renderEmojiPicker,
replies,
@ -70,6 +74,7 @@ export const StoryViewer = ({
const visibleStory = stories[currentStoryIndex];
const { attachment, messageId, timestamp } = visibleStory;
const {
acceptedMessageRequest,
avatarPath,
@ -93,19 +98,20 @@ export const StoryViewer = ({
useEscapeHandling(onEscape);
// Either we show the next story in the current user's stories or we ask
// for the next user's stories.
const showNextStory = useCallback(() => {
// Either we show the next story in the current user's stories or we ask
// for the next user's stories.
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(currentStoryIndex + 1);
} else {
setCurrentStoryIndex(0);
onNextUserStories();
}
}, [currentStoryIndex, onNextUserStories, stories.length]);
// Either we show the previous story in the current user's stories or we ask
// for the prior user's stories.
const showPrevStory = useCallback(() => {
// Either we show the previous story in the current user's stories or we ask
// for the prior user's stories.
if (currentStoryIndex === 0) {
onPrevUserStories();
} else {
@ -128,8 +134,18 @@ export const StoryViewer = ({
spring.start({
from: { width: 0 },
to: { width: 100 },
onRest: showNextStory,
onRest: {
width: ({ value }) => {
if (value === 100) {
showNextStory();
}
},
},
});
return () => {
spring.stop();
};
}, [currentStoryIndex, showNextStory, spring]);
useEffect(() => {
@ -141,8 +157,21 @@ export const StoryViewer = ({
}, [hasReplyModal, spring]);
useEffect(() => {
markStoryRead(visibleStory.messageId);
}, [markStoryRead, visibleStory.messageId]);
markStoryRead(messageId);
}, [markStoryRead, messageId]);
// Queue all undownloaded stories once we're viewing someone's stories
const storiesToDownload = useMemo(() => {
return stories
.filter(
story =>
!isDownloaded(story.attachment) && !isDownloading(story.attachment)
)
.map(story => story.messageId);
}, [stories]);
useEffect(() => {
storiesToDownload.forEach(id => queueStoryDownload(id));
}, [queueStoryDownload, storiesToDownload]);
const navigateStories = useCallback(
(ev: KeyboardEvent) => {
@ -183,13 +212,14 @@ export const StoryViewer = ({
type="button"
/>
<div className="StoryViewer__container">
{visibleStory.attachment && (
<img
alt={i18n('lightboxImageAlt')}
className="StoryViewer__story"
src={visibleStory.attachment.url}
/>
)}
<StoryImage
attachment={attachment}
i18n={i18n}
label={i18n('lightboxImageAlt')}
moduleClassName="StoryViewer__story"
queueStoryDownload={queueStoryDownload}
storyId={messageId}
/>
<div className="StoryViewer__meta">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
@ -233,13 +263,13 @@ export const StoryViewer = ({
<MessageTimestamp
i18n={i18n}
module="StoryViewer__meta--timestamp"
timestamp={visibleStory.timestamp}
timestamp={timestamp}
/>
<div className="StoryViewer__progress">
{stories.map((story, index) => (
<div
className="StoryViewer__progress--container"
key={story.timestamp}
key={story.messageId}
>
{currentStoryIndex === index ? (
<animated.div
@ -315,9 +345,9 @@ export const StoryViewer = ({
onReact={emoji => {
onReactToStory(emoji, visibleStory);
}}
onReply={(message, mentions, timestamp) => {
onReply={(message, mentions, replyTimestamp) => {
setHasReplyModal(false);
onReplyToStory(message, mentions, timestamp, visibleStory);
onReplyToStory(message, mentions, replyTimestamp, visibleStory);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong}
@ -327,7 +357,7 @@ export const StoryViewer = ({
renderEmojiPicker={renderEmojiPicker}
replies={[]}
skinTone={skinTone}
storyPreviewAttachment={visibleStory.attachment}
storyPreviewAttachment={attachment}
views={[]}
/>
)}

View File

@ -10,7 +10,7 @@ import { Spinner } from '../Spinner';
import type { AttachmentType } from '../../types/Attachment';
import {
hasNotDownloaded,
hasNotResolved,
getImageDimensions,
defaultBlurHash,
} from '../../types/Attachment';
@ -166,10 +166,10 @@ export const GIF: React.FC<Props> = props => {
};
const isPending = Boolean(attachment.pending);
const isNotDownloaded = hasNotDownloaded(attachment) && !isPending;
const isNotResolved = hasNotResolved(attachment) && !isPending;
let fileSize: JSX.Element | undefined;
if (isNotDownloaded && attachment.fileSize) {
if (isNotResolved && attachment.fileSize) {
fileSize = (
<div className="module-image--gif__filesize">
{attachment.fileSize} · GIF
@ -178,7 +178,7 @@ export const GIF: React.FC<Props> = props => {
}
let gif: JSX.Element | undefined;
if (isNotDownloaded || isPending) {
if (isNotResolved || isPending) {
gif = (
<Blurhash
hash={attachment.blurHash || defaultBlurHash(theme)}
@ -213,12 +213,12 @@ export const GIF: React.FC<Props> = props => {
}
let overlay: JSX.Element | undefined;
if ((tapToPlay && !isPlaying) || isNotDownloaded) {
if ((tapToPlay && !isPlaying) || isNotResolved) {
const className = classNames([
'module-image__border-overlay',
'module-image__border-overlay--with-click-handler',
'module-image--soft-corners',
isNotDownloaded
isNotResolved
? 'module-image--not-downloaded'
: 'module-image--tap-to-play',
]);

View File

@ -8,7 +8,10 @@ import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import { hasNotDownloaded, defaultBlurHash } from '../../types/Attachment';
import {
isDownloaded as isDownloadedFunction,
defaultBlurHash,
} from '../../types/Attachment';
export type Props = {
alt: string;
@ -169,7 +172,7 @@ export class Image extends React.Component<Props> {
const canClick = this.canClick();
const imgNotDownloaded = isDownloaded
? false
: hasNotDownloaded(attachment);
: !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme);

View File

@ -50,7 +50,7 @@ import {
getGridDimensions,
getImageDimensions,
hasImage,
hasNotDownloaded,
isDownloaded,
hasVideoScreenshot,
isAudio,
isImage,
@ -917,7 +917,7 @@ export class Message extends React.PureComponent<Props, State> {
onError={this.handleImageError}
tabIndex={tabIndex}
onClick={attachment => {
if (hasNotDownloaded(attachment)) {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({ attachment, messageId: id });
} else {
showVisualAttachment({ attachment, messageId: id });
@ -1093,7 +1093,7 @@ export class Message extends React.PureComponent<Props, State> {
}
);
const onPreviewImageClick = () => {
if (first.image && hasNotDownloaded(first.image)) {
if (first.image && !isDownloaded(first.image)) {
kickOffAttachmentDownload({
attachment: first.image,
messageId: id,
@ -2388,7 +2388,7 @@ export class Message extends React.PureComponent<Props, State> {
return;
}
if (attachments && hasNotDownloaded(attachments[0])) {
if (attachments && !isDownloaded(attachments[0])) {
event.preventDefault();
event.stopPropagation();
kickOffAttachmentDownload({
@ -2420,7 +2420,7 @@ export class Message extends React.PureComponent<Props, State> {
attachments.length > 0 &&
!isAttachmentPending &&
(isImage(attachments) || isVideo(attachments)) &&
hasNotDownloaded(attachments[0])
!isDownloaded(attachments[0])
) {
event.preventDefault();
event.stopPropagation();
@ -2511,7 +2511,7 @@ export class Message extends React.PureComponent<Props, State> {
}
const attachment = attachments[0];
if (hasNotDownloaded(attachment)) {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,

View File

@ -8,7 +8,7 @@ import { noop } from 'lodash';
import { assert } from '../../util/assert';
import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import { hasNotDownloaded } from '../../types/Attachment';
import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message';
import type { ComputePeaksResult } from '../GlobalAudioContext';
@ -203,7 +203,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
if (attachment.pending) {
state = State.Pending;
} else if (hasNotDownloaded(attachment)) {
} else if (!isDownloaded(attachment)) {
state = State.NotDownloaded;
} else if (!hasPeaks) {
state = State.Computing;

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

@ -287,6 +287,7 @@ export type ConversationAttributesType = {
// Shared fields
active_at?: number | null;
draft?: string | null;
hasPostedStory?: boolean;
isArchived?: boolean;
lastMessage?: string | null;
name?: string;

View File

@ -149,6 +149,8 @@ import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -233,7 +235,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
messageChanged(this.id, conversationId, { ...this.attributes });
}
const { addStory } = window.reduxActions.stories;
const { storyChanged } = window.reduxActions.stories;
if (isStory(this.attributes)) {
const ourConversationId =
@ -247,17 +249,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
// TODO DESKTOP-3179
// Only add stories to redux if we've downloaded them. This should work
// because once we download a story we'll receive another change event
// which kicks off this function again.
if (Attachment.hasNotDownloaded(storyData.attachment)) {
return;
}
// This is fine to call multiple times since the addStory action only
// adds new stories.
addStory(storyData);
storyChanged(storyData);
}
}
@ -2438,6 +2430,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
if (isStory(message.attributes)) {
attributes.hasPostedStory = true;
}
attributes.active_at = now;
conversation.set(attributes);
@ -2556,14 +2552,25 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// outgoing message or we've accepted the conversation
const reduxState = window.reduxStore.getState();
const attachments = this.get('attachments') || [];
let queueStoryForDownload = false;
if (isStory(message.attributes)) {
const isShowingStories = shouldShowStoriesView(reduxState);
queueStoryForDownload =
isShowingStories ||
(await shouldDownloadStory(conversation.attributes));
}
const shouldHoldOffDownload =
(isImage(attachments) || isVideo(attachments)) &&
isInCall(reduxState);
!queueStoryForDownload ||
((isImage(attachments) || isVideo(attachments)) &&
isInCall(reduxState));
if (
this.hasAttachmentDownloads() &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(this.getConversation()!.getAccepted() ||
isStory(message.attributes) ||
queueStoryForDownload ||
isOutgoing(message.attributes)) &&
!shouldHoldOffDownload
) {

View File

@ -7,7 +7,6 @@ import type { StoryDataType } from '../state/ducks/stories';
import * as log from '../logging/log';
import dataInterface from '../sql/Client';
import { getAttachmentsForMessage } from '../state/selectors/message';
import { hasNotDownloaded } from '../types/Attachment';
import { isNotNil } from '../util/isNotNil';
import { strictAssert } from '../util/assert';
@ -30,24 +29,9 @@ export function getStoryDataFromMessageAttributes(
return;
}
// Quickly determine if item hasn't been
// downloaded before we run getAttachmentsForMessage which is cached.
if (!unresolvedAttachment.path) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} not downloaded (no path)`
);
return;
}
const [attachment] = getAttachmentsForMessage(message);
// TODO DESKTOP-3179
if (hasNotDownloaded(attachment)) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} not downloaded (no url)`
);
return;
}
const [attachment] = unresolvedAttachment.path
? getAttachmentsForMessage(message)
: [unresolvedAttachment];
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}

View File

@ -212,6 +212,7 @@ const dataInterface: ClientInterface = {
searchMessagesInConversation,
getMessageCount,
getStoryCount,
saveMessage,
saveMessages,
removeMessage,
@ -295,6 +296,7 @@ const dataInterface: ClientInterface = {
_deleteAllStoryReads,
addNewStoryRead,
getLastStoryReadsForAuthor,
countStoryReadsByConversation,
removeAll,
removeAllConfiguration,
@ -1078,6 +1080,10 @@ async function getMessageCount(conversationId?: string) {
return channels.getMessageCount(conversationId);
}
async function getStoryCount(conversationId: string) {
return channels.getStoryCount(conversationId);
}
async function saveMessage(
data: MessageType,
options: {
@ -1633,6 +1639,11 @@ async function getLastStoryReadsForAuthor(options: {
}): Promise<Array<StoryReadType>> {
return channels.getLastStoryReadsForAuthor(options);
}
async function countStoryReadsByConversation(
conversationId: string
): Promise<number> {
return channels.countStoryReadsByConversation(conversationId);
}
// Other

View File

@ -371,6 +371,7 @@ export type DataInterface = {
// searchMessagesInConversation is JSON on server, full message on Client
getMessageCount: (conversationId?: string) => Promise<number>;
getStoryCount: (conversationId: string) => Promise<number>;
saveMessage: (
data: MessageType,
options: {
@ -561,6 +562,7 @@ export type DataInterface = {
conversationId?: UUIDStringType;
limit?: number;
}): Promise<Array<StoryReadType>>;
countStoryReadsByConversation(conversationId: string): Promise<number>;
removeAll: () => Promise<void>;
removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise<void>;

View File

@ -208,6 +208,7 @@ const dataInterface: ServerInterface = {
searchMessagesInConversation,
getMessageCount,
getStoryCount,
saveMessage,
saveMessages,
removeMessage,
@ -291,6 +292,7 @@ const dataInterface: ServerInterface = {
_deleteAllStoryReads,
addNewStoryRead,
getLastStoryReadsForAuthor,
countStoryReadsByConversation,
removeAll,
removeAllConfiguration,
@ -1686,6 +1688,20 @@ function getMessageCountSync(
return count;
}
async function getStoryCount(conversationId: string): Promise<number> {
const db = getInstance();
return db
.prepare<Query>(
`
SELECT count(*)
FROM messages
WHERE conversationId = $conversationId AND isStory = 1;
`
)
.pluck()
.get({ conversationId });
}
async function getMessageCount(conversationId?: string): Promise<number> {
return getMessageCountSync(conversationId);
}
@ -2392,7 +2408,7 @@ function getOlderMessagesByConversationSync(
async function getOlderStories({
conversationId,
limit = 10,
limit = 9999,
receivedAt = Number.MAX_VALUE,
sentAt,
sourceUuid,
@ -2416,7 +2432,7 @@ async function getOlderStories({
(received_at < $receivedAt
OR (received_at IS $receivedAt AND sent_at < $sentAt)
)
ORDER BY received_at DESC, sent_at DESC
ORDER BY received_at ASC, sent_at ASC
LIMIT $limit;
`
)
@ -4157,6 +4173,21 @@ async function getLastStoryReadsForAuthor({
});
}
async function countStoryReadsByConversation(
conversationId: string
): Promise<number> {
const db = getInstance();
return db
.prepare<Query>(
`
SELECT COUNT(storyId) FROM storyReads
WHERE conversationId = $conversationId;
`
)
.pluck()
.get({ conversationId });
}
// All data in database
async function removeAll(): Promise<void> {
const db = getInstance();

View File

@ -12,12 +12,17 @@ import type { StateType as RootStateType } from '../reducer';
import type { StoryViewType } from '../../components/StoryListItem';
import type { SyncType } from '../../jobs/helpers/syncHelpers';
import * as log from '../../logging/log';
import dataInterface from '../../sql/Client';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { UUID } from '../../types/UUID';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { replaceIndex } from '../../util/replaceIndex';
import { showToast } from '../../util/showToast';
import { isDownloaded, isDownloading } from '../../types/Attachment';
import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
@ -40,13 +45,14 @@ export type StoriesStateType = {
// Actions
const ADD_STORY = 'stories/ADD_STORY';
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
type AddStoryActionType = {
type: typeof ADD_STORY;
payload: StoryDataType;
type MarkStoryReadActionType = {
type: typeof MARK_STORY_READ;
payload: string;
};
type ReactToStoryActionType = {
@ -57,38 +63,38 @@ type ReactToStoryActionType = {
};
};
type StoryChangedActionType = {
type: typeof STORY_CHANGED;
payload: StoryDataType;
};
type ToggleViewActionType = {
type: typeof TOGGLE_VIEW;
};
export type StoriesActionType =
| AddStoryActionType
| MarkStoryReadActionType
| MessageDeletedActionType
| ReactToStoryActionType
| StoryChangedActionType
| ToggleViewActionType;
// Action Creators
export const actions = {
addStory,
markStoryRead,
queueStoryDownload,
reactToStory,
replyToStory,
storyChanged,
toggleStoriesView,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
function addStory(story: StoryDataType): AddStoryActionType {
return {
type: ADD_STORY,
payload: story,
};
}
function markStoryRead(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
): ThunkAction<void, RootStateType, unknown, MarkStoryReadActionType> {
return async (dispatch, getState) => {
const { stories } = getState().stories;
@ -109,7 +115,9 @@ function markStoryRead(
return;
}
markViewed(message.attributes, Date.now());
const storyReadDate = Date.now();
markViewed(message.attributes, storyReadDate);
const viewedReceipt = {
messageId,
@ -125,6 +133,55 @@ function markStoryRead(
viewedReceiptsJobQueue.add({ viewedReceipt });
await dataInterface.addNewStoryRead({
authorId: message.attributes.sourceUuid,
conversationId: message.attributes.conversationId,
storyId: new UUID(messageId).toString(),
storyReadDate,
});
dispatch({
type: MARK_STORY_READ,
payload: messageId,
});
};
}
function queueStoryDownload(
storyId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const story = await getMessageById(storyId);
if (!story) {
return;
}
const storyAttributes: MessageAttributesType = story.attributes;
const { attachments } = storyAttributes;
const attachment = attachments && attachments[0];
if (!attachment) {
log.warn('queueStoryDownload: No attachment found for story', {
storyId,
});
return;
}
if (isDownloaded(attachment)) {
return;
}
if (isDownloading(attachment)) {
return;
}
// We want to ensure that we re-hydrate the story reply context with the
// completed attachment download.
story.set({ storyReplyContext: undefined });
await queueAttachmentDownloads(story.attributes);
dispatch({
type: 'NOOP',
payload: null,
@ -188,6 +245,13 @@ function replyToStory(
};
}
function storyChanged(story: StoryDataType): StoryChangedActionType {
return {
type: STORY_CHANGED,
payload: story,
};
}
function toggleStoriesView(): ToggleViewActionType {
return {
type: TOGGLE_VIEW,
@ -226,7 +290,7 @@ export function reducer(
};
}
if (action.type === ADD_STORY) {
if (action.type === STORY_CHANGED) {
const newStory = pick(action.payload, [
'attachment',
'conversationId',
@ -238,18 +302,35 @@ export function reducer(
'timestamp',
]);
// TODO DEKTOP-3179
// ADD_STORY fires whenever the message model changes so we check if this
// story already exists in state -- if it does then we don't need to re-add.
const hasStory = state.stories.find(
// Stories don't really need to change except for when we don't have the
// attachment downloaded and we queue a download. Then the story's message
// will have the new attachment information. This is an optimization so
// we don't needlessly re-render.
const prevStory = state.stories.find(
existingStory => existingStory.messageId === newStory.messageId
);
if (hasStory) {
return state;
if (prevStory) {
const shouldReplace =
(!isDownloaded(prevStory.attachment) &&
isDownloaded(newStory.attachment)) ||
isDownloading(newStory.attachment);
if (!shouldReplace) {
return state;
}
const storyIndex = state.stories.findIndex(
existingStory => existingStory.messageId === newStory.messageId
);
return {
...state,
stories: replaceIndex(state.stories, storyIndex, newStory),
};
}
const stories = [newStory, ...state.stories].sort((a, b) =>
a.timestamp > b.timestamp ? -1 : 1
const stories = [...state.stories, newStory].sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1
);
return {
@ -274,5 +355,21 @@ export function reducer(
};
}
if (action.type === MARK_STORY_READ) {
return {
...state,
stories: state.stories.map(story => {
if (story.messageId === action.payload) {
return {
...story,
readStatus: ReadStatus.Viewed,
};
}
return story;
}),
};
}
return state;
}

View File

@ -24,13 +24,22 @@ export const shouldShowStoriesView = createSelector(
export const getStories = createSelector(
getConversationSelector,
getStoriesState,
shouldShowStoriesView,
(
conversationSelector,
{ stories }: Readonly<StoriesStateType>
{ stories }: Readonly<StoriesStateType>,
isShowingStoriesView
): {
hiddenStories: Array<ConversationStoryType>;
stories: Array<ConversationStoryType>;
} => {
if (!isShowingStoriesView) {
return {
hiddenStories: [],
stories: [],
};
}
const storiesById = new Map<string, ConversationStoryType>();
const hiddenStoriesById = new Map<string, ConversationStoryType>();
@ -90,9 +99,10 @@ export const getStories = createSelector(
});
});
// Reversing so that the story list is in DESC order
return {
hiddenStories: Array.from(hiddenStoriesById.values()),
stories: Array.from(storiesById.values()),
hiddenStories: Array.from(hiddenStoriesById.values()).reverse(),
stories: Array.from(storiesById.values()).reverse(),
};
}
);

View File

@ -93,15 +93,15 @@ describe('sql/stories', () => {
});
assert.lengthOf(stories, 4, 'expect four total stories');
// They are in DESC order
// They are in ASC order
assert.strictEqual(
stories[0].id,
story5.id,
story1.id,
'stories first should be story5'
);
assert.strictEqual(
stories[3].id,
story1.id,
story5.id,
'stories last should be story1'
);
@ -115,15 +115,15 @@ describe('sql/stories', () => {
'expect two stories in conversaton'
);
// They are in DESC order
// They are in ASC order
assert.strictEqual(
storiesInConversation[0].id,
story4.id,
story1.id,
'storiesInConversation first should be story4'
);
assert.strictEqual(
storiesInConversation[1].id,
story1.id,
story4.id,
'storiesInConversation last should be story1'
);
@ -133,15 +133,15 @@ describe('sql/stories', () => {
});
assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author');
// They are in DESC order
// They are in ASC order
assert.strictEqual(
storiesByAuthor[0].id,
story5.id,
story2.id,
'storiesByAuthor first should be story5'
);
assert.strictEqual(
storiesByAuthor[1].id,
story2.id,
story5.id,
'storiesByAuthor last should be story2'
);
});
@ -213,15 +213,15 @@ describe('sql/stories', () => {
});
assert.lengthOf(stories, 2, 'expect two stories');
// They are in DESC order
// They are in ASC order
assert.strictEqual(
stories[0].id,
story3.id,
story2.id,
'stories first should be story3'
);
assert.strictEqual(
stories[1].id,
story2.id,
story3.id,
'stories last should be story2'
);
});

View File

@ -695,10 +695,18 @@ export function isGIF(attachments?: ReadonlyArray<AttachmentType>): boolean {
return hasFlag && isVideoAttachment(attachment);
}
export function hasNotDownloaded(attachment?: AttachmentType): boolean {
export function isDownloaded(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.path);
}
export function hasNotResolved(attachment?: AttachmentType): boolean {
return Boolean(attachment && !attachment.url);
}
export function isDownloading(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.downloadJobId && attachment.pending);
}
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {
const firstAttachment = attachments ? attachments[0] : null;

View File

@ -0,0 +1,23 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import dataInterface from '../sql/Client';
const MAX_NUM_STORIES_TO_PREFETCH = 5;
export async function shouldDownloadStory(
conversation: ConversationAttributesType
): Promise<boolean> {
if (!conversation.hasPostedStory) {
return true;
}
const [storyReads, storyCounts] = await Promise.all([
dataInterface.countStoryReadsByConversation(conversation.id),
dataInterface.getStoryCount(conversation.id),
]);
return storyReads > 0 && storyCounts <= MAX_NUM_STORIES_TO_PREFETCH;
}