From 3b5cc26fec3df962cfa94c79f366c11da124fc71 Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Mon, 28 Mar 2022 21:10:08 -0400
Subject: [PATCH] Adds logic around downloading stories
---
_locales/en/messages.json | 4 +
stylesheets/components/StoryImage.scss | 69 ++++++++++
stylesheets/components/StoryListItem.scss | 1 +
stylesheets/components/StoryViewer.scss | 1 +
stylesheets/manifest.scss | 1 +
ts/components/Stories.stories.tsx | 46 +++----
ts/components/Stories.tsx | 3 +
ts/components/StoriesPane.tsx | 13 +-
ts/components/StoryImage.stories.tsx | 89 ++++++++++++
ts/components/StoryImage.tsx | 112 +++++++++++++++
ts/components/StoryListItem.stories.tsx | 1 +
ts/components/StoryListItem.tsx | 22 +--
ts/components/StoryViewer.stories.tsx | 1 +
ts/components/StoryViewer.tsx | 70 +++++++---
ts/components/conversation/GIF.tsx | 12 +-
ts/components/conversation/Image.tsx | 7 +-
ts/components/conversation/Message.tsx | 12 +-
ts/components/conversation/MessageAudio.tsx | 4 +-
ts/model-types.d.ts | 1 +
ts/models/messages.ts | 37 +++--
ts/services/storyLoader.ts | 22 +--
ts/sql/Client.ts | 11 ++
ts/sql/Interface.ts | 2 +
ts/sql/Server.ts | 35 ++++-
ts/state/ducks/stories.ts | 145 ++++++++++++++++----
ts/state/selectors/stories.ts | 16 ++-
ts/test-electron/sql/stories_test.ts | 24 ++--
ts/types/Attachment.ts | 10 +-
ts/util/shouldDownloadStory.ts | 23 ++++
29 files changed, 645 insertions(+), 149 deletions(-)
create mode 100644 stylesheets/components/StoryImage.scss
create mode 100644 ts/components/StoryImage.stories.tsx
create mode 100644 ts/components/StoryImage.tsx
create mode 100644 ts/util/shouldDownloadStory.ts
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 1dde50b0a579..75ff1cdbb919 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -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"
diff --git a/stylesheets/components/StoryImage.scss b/stylesheets/components/StoryImage.scss
new file mode 100644
index 000000000000..d2e8333f3f89
--- /dev/null
+++ b/stylesheets/components/StoryImage.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss
index e4aef1f831fe..baef66b982a2 100644
--- a/stylesheets/components/StoryListItem.scss
+++ b/stylesheets/components/StoryListItem.scss
@@ -22,6 +22,7 @@
&--title {
@include font-body-1-bold;
+ color: $color-gray-05;
}
&--timestamp {
diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss
index e8809c8b5e60..36d6006df3a8 100644
--- a/stylesheets/components/StoryViewer.scss
+++ b/stylesheets/components/StoryViewer.scss
@@ -64,6 +64,7 @@
position: absolute;
transform: translateX(-50%);
width: 284px;
+ z-index: $z-index-above-base;
&--group-avatar {
margin-left: -8px;
diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss
index 3f44deb8bddc..fd51d1b42fa0 100644
--- a/stylesheets/manifest.scss
+++ b/stylesheets/manifest.scss
@@ -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';
diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx
index 38fcd2cd8c5d..62b50c650ae4 100644
--- a/ts/components/Stories.stories.tsx
+++ b/ts/components/Stories.stories.tsx
@@ -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: () =>
,
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,
}),
],
diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx
index 1539fdcc8dcf..8c8e8dca1ad2 100644
--- a/ts/components/Stories.tsx
+++ b/ts/components/Stories.tsx
@@ -16,6 +16,7 @@ export type PropsType = {
preferredWidthFromStorage: number;
openConversationInternal: (_: { conversationId: string }) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
+ queueStoryDownload: (storyId: string) => unknown;
stories: Array;
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}
/>
diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx
index 94b6aa5f8ddd..b7293db462a9 100644
--- a/ts/components/StoriesPane.tsx
+++ b/ts/components/StoriesPane.tsx
@@ -44,12 +44,17 @@ function search(
);
}
+function getNewestStory(story: ConversationStoryType): StoryViewType {
+ return story.stories[story.stories.length - 1];
+}
+
export type PropsType = {
hiddenStories: Array;
i18n: LocalizerType;
onBack: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
openConversationInternal: (_: { conversationId: string }) => unknown;
+ queueStoryDownload: (storyId: string) => unknown;
stories: Array;
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 => (
{
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')}
diff --git a/ts/components/StoryImage.stories.tsx b/ts/components/StoryImage.stories.tsx
new file mode 100644
index 000000000000..b527affaff42
--- /dev/null
+++ b/ts/components/StoryImage.stories.tsx
@@ -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', () => );
+
+story.add('Good story (thumbnail)', () => (
+
+));
+
+story.add('Not downloaded', () => (
+
+));
+
+story.add('Not downloaded (thumbnail)', () => (
+
+));
+
+story.add('Pending download', () => (
+
+));
+
+story.add('Pending download (thumbnail)', () => (
+
+));
+
+story.add('Broken Image', () => (
+
+));
+
+story.add('Broken Image (thumbnail)', () => (
+
+));
diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx
new file mode 100644
index 000000000000..296dd7b921bd
--- /dev/null
+++ b/ts/components/StoryImage.tsx
@@ -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(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 = (
+
+ );
+ } else if (attachmentBroken) {
+ storyElement = (
+
+ );
+ } else {
+ storyElement = (
+ setAttachmentBroken(true)}
+ src={
+ isThumbnail && attachment.thumbnail
+ ? attachment.thumbnail.url
+ : attachment.url
+ }
+ />
+ );
+ }
+
+ let spinner: JSX.Element | undefined;
+ if (isPending) {
+ spinner = (
+
+ );
+ }
+
+ return (
+
+ {storyElement}
+ {spinner}
+
+ );
+};
diff --git a/ts/components/StoryListItem.stories.tsx b/ts/components/StoryListItem.stories.tsx
index df918d6780df..eff359e24720 100644
--- a/ts/components/StoryListItem.stories.tsx
+++ b/ts/components/StoryListItem.stories.tsx
@@ -23,6 +23,7 @@ function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
+ queueStoryDownload: action('queueStoryDownload'),
story: {
messageId: '123',
sender: getDefaultConversation(),
diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx
index 992e4b94cb57..28a238e8986b 100644
--- a/ts/components/StoryListItem.tsx
+++ b/ts/components/StoryListItem.tsx
@@ -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 && }
- {attachment && (
-
- )}
+
,
replies: Math.floor(Math.random() * 20),
stories: [
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx
index de8863d7b20e..8763a181e4c9 100644
--- a/ts/components/StoryViewer.tsx
+++ b/ts/components/StoryViewer.tsx
@@ -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;
+ queueStoryDownload: (storyId: string) => unknown;
recentEmojis?: Array;
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"
/>
- {visibleStory.attachment && (
-
- )}
+
{stories.map((story, index) => (
{currentStoryIndex === index ? (
{
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={[]}
/>
)}
diff --git a/ts/components/conversation/GIF.tsx b/ts/components/conversation/GIF.tsx
index cab70bffb576..1bd4b63ca7fb 100644
--- a/ts/components/conversation/GIF.tsx
+++ b/ts/components/conversation/GIF.tsx
@@ -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 => {
};
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 = (
{attachment.fileSize} ยท GIF
@@ -178,7 +178,7 @@ export const GIF: React.FC
= props => {
}
let gif: JSX.Element | undefined;
- if (isNotDownloaded || isPending) {
+ if (isNotResolved || isPending) {
gif = (
= 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',
]);
diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx
index dcdbdc538870..0d62045d0ac4 100644
--- a/ts/components/conversation/Image.tsx
+++ b/ts/components/conversation/Image.tsx
@@ -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 {
const canClick = this.canClick();
const imgNotDownloaded = isDownloaded
? false
- : hasNotDownloaded(attachment);
+ : !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index 24c5d600a853..37401228265e 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -50,7 +50,7 @@ import {
getGridDimensions,
getImageDimensions,
hasImage,
- hasNotDownloaded,
+ isDownloaded,
hasVideoScreenshot,
isAudio,
isImage,
@@ -917,7 +917,7 @@ export class Message extends React.PureComponent {
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 {
}
);
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 {
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 {
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 {
}
const attachment = attachments[0];
- if (hasNotDownloaded(attachment)) {
+ if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx
index 69e1653d2df0..ac861921f5d9 100644
--- a/ts/components/conversation/MessageAudio.tsx
+++ b/ts/components/conversation/MessageAudio.tsx
@@ -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) => {
if (attachment.pending) {
state = State.Pending;
- } else if (hasNotDownloaded(attachment)) {
+ } else if (!isDownloaded(attachment)) {
state = State.NotDownloaded;
} else if (!hasPeaks) {
state = State.Computing;
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index 812c8c47b01b..72187fc42767 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -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;
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index a2bf2805634e..f16d8513df7f 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -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 {
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 {
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 {
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 {
// 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
) {
diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts
index f78effbae873..4264c2620d8e 100644
--- a/ts/services/storyLoader.ts
+++ b/ts/services/storyLoader.ts
@@ -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) || {}
diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts
index b1086a0081d8..2fa2eae1e990 100644
--- a/ts/sql/Client.ts
+++ b/ts/sql/Client.ts
@@ -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> {
return channels.getLastStoryReadsForAuthor(options);
}
+async function countStoryReadsByConversation(
+ conversationId: string
+): Promise {
+ return channels.countStoryReadsByConversation(conversationId);
+}
// Other
diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts
index 9a54429378b5..fd14e219e0d6 100644
--- a/ts/sql/Interface.ts
+++ b/ts/sql/Interface.ts
@@ -371,6 +371,7 @@ export type DataInterface = {
// searchMessagesInConversation is JSON on server, full message on Client
getMessageCount: (conversationId?: string) => Promise;
+ getStoryCount: (conversationId: string) => Promise;
saveMessage: (
data: MessageType,
options: {
@@ -561,6 +562,7 @@ export type DataInterface = {
conversationId?: UUIDStringType;
limit?: number;
}): Promise>;
+ countStoryReadsByConversation(conversationId: string): Promise;
removeAll: () => Promise;
removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise;
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index 56b122208c0e..330fb7d9f0a8 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -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 {
+ const db = getInstance();
+ return db
+ .prepare(
+ `
+ SELECT count(*)
+ FROM messages
+ WHERE conversationId = $conversationId AND isStory = 1;
+ `
+ )
+ .pluck()
+ .get({ conversationId });
+}
+
async function getMessageCount(conversationId?: string): Promise {
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 {
+ const db = getInstance();
+ return db
+ .prepare(
+ `
+ SELECT COUNT(storyId) FROM storyReads
+ WHERE conversationId = $conversationId;
+ `
+ )
+ .pluck()
+ .get({ conversationId });
+}
+
// All data in database
async function removeAll(): Promise {
const db = getInstance();
diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts
index 1cacd2f96e11..58f705fbc172 100644
--- a/ts/state/ducks/stories.ts
+++ b/ts/state/ducks/stories.ts
@@ -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 {
+): ThunkAction {
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 {
+ 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;
}
diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts
index abcf583f32e7..7a6660aa883a 100644
--- a/ts/state/selectors/stories.ts
+++ b/ts/state/selectors/stories.ts
@@ -24,13 +24,22 @@ export const shouldShowStoriesView = createSelector(
export const getStories = createSelector(
getConversationSelector,
getStoriesState,
+ shouldShowStoriesView,
(
conversationSelector,
- { stories }: Readonly
+ { stories }: Readonly,
+ isShowingStoriesView
): {
hiddenStories: Array;
stories: Array;
} => {
+ if (!isShowingStoriesView) {
+ return {
+ hiddenStories: [],
+ stories: [],
+ };
+ }
+
const storiesById = new Map();
const hiddenStoriesById = new Map();
@@ -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(),
};
}
);
diff --git a/ts/test-electron/sql/stories_test.ts b/ts/test-electron/sql/stories_test.ts
index 8120c54c6ec6..f73e29171021 100644
--- a/ts/test-electron/sql/stories_test.ts
+++ b/ts/test-electron/sql/stories_test.ts
@@ -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'
);
});
diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts
index 885dbda919ee..6595a8923c55 100644
--- a/ts/types/Attachment.ts
+++ b/ts/types/Attachment.ts
@@ -695,10 +695,18 @@ export function isGIF(attachments?: ReadonlyArray): 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): boolean {
const firstAttachment = attachments ? attachments[0] : null;
diff --git a/ts/util/shouldDownloadStory.ts b/ts/util/shouldDownloadStory.ts
new file mode 100644
index 000000000000..54ad4bc2bf35
--- /dev/null
+++ b/ts/util/shouldDownloadStory.ts
@@ -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 {
+ 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;
+}