Adds logic around downloading stories
This commit is contained in:
parent
9d3f0072a5
commit
3b5cc26fec
29 changed files with 645 additions and 149 deletions
|
@ -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"
|
||||
|
|
69
stylesheets/components/StoryImage.scss
Normal file
69
stylesheets/components/StoryImage.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
&--title {
|
||||
@include font-body-1-bold;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&--timestamp {
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 284px;
|
||||
z-index: $z-index-above-base;
|
||||
|
||||
&--group-avatar {
|
||||
margin-left: -8px;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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')}
|
||||
|
|
89
ts/components/StoryImage.stories.tsx
Normal file
89
ts/components/StoryImage.stories.tsx
Normal 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
|
||||
/>
|
||||
));
|
112
ts/components/StoryImage.tsx
Normal file
112
ts/components/StoryImage.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -23,6 +23,7 @@ function getDefaultProps(): PropsType {
|
|||
return {
|
||||
i18n,
|
||||
onClick: action('onClick'),
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
story: {
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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={[]}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
1
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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) || {}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
23
ts/util/shouldDownloadStory.ts
Normal file
23
ts/util/shouldDownloadStory.ts
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue