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": {
|
"WhatsNew__modal-title": {
|
||||||
"message": "What's New",
|
"message": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"description": "Title for the whats new modal"
|
||||||
|
|
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 {
|
&--title {
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
|
color: $color-gray-05;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--timestamp {
|
&--timestamp {
|
||||||
|
|
|
@ -64,6 +64,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 284px;
|
width: 284px;
|
||||||
|
z-index: $z-index-above-base;
|
||||||
|
|
||||||
&--group-avatar {
|
&--group-avatar {
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
|
|
|
@ -100,6 +100,7 @@
|
||||||
@import './components/Select.scss';
|
@import './components/Select.scss';
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
@import './components/Stories.scss';
|
@import './components/Stories.scss';
|
||||||
|
@import './components/StoryImage.scss';
|
||||||
@import './components/StoryListItem.scss';
|
@import './components/StoryListItem.scss';
|
||||||
@import './components/StoryReplyQuote.scss';
|
@import './components/StoryReplyQuote.scss';
|
||||||
@import './components/StoryViewsNRepliesModal.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 => ({
|
const getDefaultProps = (): PropsType => ({
|
||||||
hiddenStories: [],
|
hiddenStories: [],
|
||||||
i18n,
|
i18n,
|
||||||
openConversationInternal: action('openConversationInternal'),
|
openConversationInternal: action('openConversationInternal'),
|
||||||
preferredWidthFromStorage: 380,
|
preferredWidthFromStorage: 380,
|
||||||
|
queueStoryDownload: action('queueStoryDownload'),
|
||||||
renderStoryViewer: () => <div />,
|
renderStoryViewer: () => <div />,
|
||||||
stories: [
|
stories: [
|
||||||
createStory({
|
createStory({
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail(
|
||||||
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
|
'/fixtures/tina-rolf-269345-unsplash.jpg'
|
||||||
}),
|
),
|
||||||
timestamp: Date.now() - 2 * durations.MINUTE,
|
timestamp: Date.now() - 2 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail(
|
||||||
thumbnail: fakeThumbnail(
|
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
|
||||||
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
|
),
|
||||||
),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 5 * durations.MINUTE,
|
timestamp: Date.now() - 5 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
group: { title: 'BBQ in the park' },
|
group: { title: 'BBQ in the park' },
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail(
|
||||||
thumbnail: fakeThumbnail(
|
'/fixtures/nathan-anderson-316188-unsplash.jpg'
|
||||||
'/fixtures/nathan-anderson-316188-unsplash.jpg'
|
),
|
||||||
),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 65 * durations.MINUTE,
|
timestamp: Date.now() - 65 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'),
|
||||||
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 92 * durations.MINUTE,
|
timestamp: Date.now() - 92 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'),
|
||||||
thumbnail: fakeThumbnail('/fixtures/kitten-1-64-64.jpg'),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 164 * durations.MINUTE,
|
timestamp: Date.now() - 164 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
group: { title: 'Breaking Signal for Science' },
|
group: { title: 'Breaking Signal for Science' },
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'),
|
||||||
thumbnail: fakeThumbnail('/fixtures/kitten-2-64-64.jpg'),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 380 * durations.MINUTE,
|
timestamp: Date.now() - 380 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
createStory({
|
createStory({
|
||||||
attachment: fakeAttachment({
|
attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'),
|
||||||
thumbnail: fakeThumbnail('/fixtures/kitten-3-64-64.jpg'),
|
|
||||||
}),
|
|
||||||
timestamp: Date.now() - 421 * durations.MINUTE,
|
timestamp: Date.now() - 421 * durations.MINUTE,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
@ -16,6 +16,7 @@ export type PropsType = {
|
||||||
preferredWidthFromStorage: number;
|
preferredWidthFromStorage: number;
|
||||||
openConversationInternal: (_: { conversationId: string }) => unknown;
|
openConversationInternal: (_: { conversationId: string }) => unknown;
|
||||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||||
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
toggleHideStories: (conversationId: string) => unknown;
|
toggleHideStories: (conversationId: string) => unknown;
|
||||||
toggleStoriesView: () => unknown;
|
toggleStoriesView: () => unknown;
|
||||||
|
@ -31,6 +32,7 @@ export const Stories = ({
|
||||||
i18n,
|
i18n,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
preferredWidthFromStorage,
|
preferredWidthFromStorage,
|
||||||
|
queueStoryDownload,
|
||||||
renderStoryViewer,
|
renderStoryViewer,
|
||||||
stories,
|
stories,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
|
@ -99,6 +101,7 @@ export const Stories = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
openConversationInternal={openConversationInternal}
|
openConversationInternal={openConversationInternal}
|
||||||
|
queueStoryDownload={queueStoryDownload}
|
||||||
stories={stories}
|
stories={stories}
|
||||||
toggleHideStories={toggleHideStories}
|
toggleHideStories={toggleHideStories}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -44,12 +44,17 @@ function search(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNewestStory(story: ConversationStoryType): StoryViewType {
|
||||||
|
return story.stories[story.stories.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onBack: () => unknown;
|
onBack: () => unknown;
|
||||||
onStoryClicked: (conversationId: string) => unknown;
|
onStoryClicked: (conversationId: string) => unknown;
|
||||||
openConversationInternal: (_: { conversationId: string }) => unknown;
|
openConversationInternal: (_: { conversationId: string }) => unknown;
|
||||||
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
toggleHideStories: (conversationId: string) => unknown;
|
toggleHideStories: (conversationId: string) => unknown;
|
||||||
};
|
};
|
||||||
|
@ -59,6 +64,7 @@ export const StoriesPane = ({
|
||||||
onBack,
|
onBack,
|
||||||
onStoryClicked,
|
onStoryClicked,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
|
queueStoryDownload,
|
||||||
stories,
|
stories,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
|
@ -103,18 +109,19 @@ export const StoriesPane = ({
|
||||||
>
|
>
|
||||||
{renderedStories.map(story => (
|
{renderedStories.map(story => (
|
||||||
<StoryListItem
|
<StoryListItem
|
||||||
key={story.stories[0].timestamp}
|
key={getNewestStory(story).timestamp}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onStoryClicked(story.conversationId);
|
onStoryClicked(story.conversationId);
|
||||||
}}
|
}}
|
||||||
onHideStory={() => {
|
onHideStory={() => {
|
||||||
toggleHideStories(story.stories[0].sender.id);
|
toggleHideStories(getNewestStory(story).sender.id);
|
||||||
}}
|
}}
|
||||||
onGoToConversation={conversationId => {
|
onGoToConversation={conversationId => {
|
||||||
openConversationInternal({ conversationId });
|
openConversationInternal({ conversationId });
|
||||||
}}
|
}}
|
||||||
story={story.stories[0]}
|
queueStoryDownload={queueStoryDownload}
|
||||||
|
story={getNewestStory(story)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!stories.length && i18n('Stories__list-empty')}
|
{!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 {
|
return {
|
||||||
i18n,
|
i18n,
|
||||||
onClick: action('onClick'),
|
onClick: action('onClick'),
|
||||||
|
queueStoryDownload: action('queueStoryDownload'),
|
||||||
story: {
|
story: {
|
||||||
messageId: '123',
|
messageId: '123',
|
||||||
sender: getDefaultConversation(),
|
sender: getDefaultConversation(),
|
||||||
|
|
|
@ -9,8 +9,9 @@ import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
|
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenuPopper } from './ContextMenu';
|
import { ContextMenuPopper } from './ContextMenu';
|
||||||
import { getAvatarColor } from '../types/Colors';
|
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
|
import { StoryImage } from './StoryImage';
|
||||||
|
import { getAvatarColor } from '../types/Colors';
|
||||||
|
|
||||||
export type ConversationStoryType = {
|
export type ConversationStoryType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -53,6 +54,7 @@ export type PropsType = Pick<
|
||||||
onClick: () => unknown;
|
onClick: () => unknown;
|
||||||
onGoToConversation?: (conversationId: string) => unknown;
|
onGoToConversation?: (conversationId: string) => unknown;
|
||||||
onHideStory?: (conversationId: string) => unknown;
|
onHideStory?: (conversationId: string) => unknown;
|
||||||
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
story: StoryViewType;
|
story: StoryViewType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -64,6 +66,7 @@ export const StoryListItem = ({
|
||||||
onClick,
|
onClick,
|
||||||
onGoToConversation,
|
onGoToConversation,
|
||||||
onHideStory,
|
onHideStory,
|
||||||
|
queueStoryDownload,
|
||||||
story,
|
story,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||||
|
@ -182,14 +185,15 @@ export const StoryListItem = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasMultiple && <div className="StoryListItem__previews--more" />}
|
{hasMultiple && <div className="StoryListItem__previews--more" />}
|
||||||
{attachment && (
|
<StoryImage
|
||||||
<div
|
attachment={attachment}
|
||||||
className="StoryListItem__previews--image"
|
i18n={i18n}
|
||||||
style={{
|
isThumbnail
|
||||||
backgroundImage: `url("${attachment.thumbnail?.url}")`,
|
label=""
|
||||||
}}
|
moduleClassName="StoryListItem__previews--image"
|
||||||
/>
|
queueStoryDownload={queueStoryDownload}
|
||||||
)}
|
storyId={story.messageId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<ContextMenuPopper
|
<ContextMenuPopper
|
||||||
|
|
|
@ -31,6 +31,7 @@ function getDefaultProps(): PropsType {
|
||||||
onTextTooLong: action('onTextTooLong'),
|
onTextTooLong: action('onTextTooLong'),
|
||||||
onUseEmoji: action('onUseEmoji'),
|
onUseEmoji: action('onUseEmoji'),
|
||||||
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
|
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
|
||||||
|
queueStoryDownload: action('queueStoryDownload'),
|
||||||
renderEmojiPicker: () => <div />,
|
renderEmojiPicker: () => <div />,
|
||||||
replies: Math.floor(Math.random() * 20),
|
replies: Math.floor(Math.random() * 20),
|
||||||
stories: [
|
stories: [
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { useSpring, animated, to } from '@react-spring/web';
|
||||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
@ -12,7 +12,9 @@ import type { StoryViewType } from './StoryListItem';
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
|
import { StoryImage } from './StoryImage';
|
||||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||||
|
import { isDownloaded, isDownloading } from '../types/Attachment';
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
|
|
||||||
|
@ -37,6 +39,7 @@ export type PropsType = {
|
||||||
) => unknown;
|
) => unknown;
|
||||||
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
||||||
preferredReactionEmoji: Array<string>;
|
preferredReactionEmoji: Array<string>;
|
||||||
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
recentEmojis?: Array<string>;
|
recentEmojis?: Array<string>;
|
||||||
replies?: number;
|
replies?: number;
|
||||||
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
||||||
|
@ -59,6 +62,7 @@ export const StoryViewer = ({
|
||||||
onTextTooLong,
|
onTextTooLong,
|
||||||
onUseEmoji,
|
onUseEmoji,
|
||||||
preferredReactionEmoji,
|
preferredReactionEmoji,
|
||||||
|
queueStoryDownload,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
renderEmojiPicker,
|
renderEmojiPicker,
|
||||||
replies,
|
replies,
|
||||||
|
@ -70,6 +74,7 @@ export const StoryViewer = ({
|
||||||
|
|
||||||
const visibleStory = stories[currentStoryIndex];
|
const visibleStory = stories[currentStoryIndex];
|
||||||
|
|
||||||
|
const { attachment, messageId, timestamp } = visibleStory;
|
||||||
const {
|
const {
|
||||||
acceptedMessageRequest,
|
acceptedMessageRequest,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
@ -93,19 +98,20 @@ export const StoryViewer = ({
|
||||||
|
|
||||||
useEscapeHandling(onEscape);
|
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(() => {
|
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) {
|
if (currentStoryIndex < stories.length - 1) {
|
||||||
setCurrentStoryIndex(currentStoryIndex + 1);
|
setCurrentStoryIndex(currentStoryIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
|
setCurrentStoryIndex(0);
|
||||||
onNextUserStories();
|
onNextUserStories();
|
||||||
}
|
}
|
||||||
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
}, [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(() => {
|
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) {
|
if (currentStoryIndex === 0) {
|
||||||
onPrevUserStories();
|
onPrevUserStories();
|
||||||
} else {
|
} else {
|
||||||
|
@ -128,8 +134,18 @@ export const StoryViewer = ({
|
||||||
spring.start({
|
spring.start({
|
||||||
from: { width: 0 },
|
from: { width: 0 },
|
||||||
to: { width: 100 },
|
to: { width: 100 },
|
||||||
onRest: showNextStory,
|
onRest: {
|
||||||
|
width: ({ value }) => {
|
||||||
|
if (value === 100) {
|
||||||
|
showNextStory();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
spring.stop();
|
||||||
|
};
|
||||||
}, [currentStoryIndex, showNextStory, spring]);
|
}, [currentStoryIndex, showNextStory, spring]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -141,8 +157,21 @@ export const StoryViewer = ({
|
||||||
}, [hasReplyModal, spring]);
|
}, [hasReplyModal, spring]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
markStoryRead(visibleStory.messageId);
|
markStoryRead(messageId);
|
||||||
}, [markStoryRead, visibleStory.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(
|
const navigateStories = useCallback(
|
||||||
(ev: KeyboardEvent) => {
|
(ev: KeyboardEvent) => {
|
||||||
|
@ -183,13 +212,14 @@ export const StoryViewer = ({
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<div className="StoryViewer__container">
|
<div className="StoryViewer__container">
|
||||||
{visibleStory.attachment && (
|
<StoryImage
|
||||||
<img
|
attachment={attachment}
|
||||||
alt={i18n('lightboxImageAlt')}
|
i18n={i18n}
|
||||||
className="StoryViewer__story"
|
label={i18n('lightboxImageAlt')}
|
||||||
src={visibleStory.attachment.url}
|
moduleClassName="StoryViewer__story"
|
||||||
/>
|
queueStoryDownload={queueStoryDownload}
|
||||||
)}
|
storyId={messageId}
|
||||||
|
/>
|
||||||
<div className="StoryViewer__meta">
|
<div className="StoryViewer__meta">
|
||||||
<Avatar
|
<Avatar
|
||||||
acceptedMessageRequest={acceptedMessageRequest}
|
acceptedMessageRequest={acceptedMessageRequest}
|
||||||
|
@ -233,13 +263,13 @@ export const StoryViewer = ({
|
||||||
<MessageTimestamp
|
<MessageTimestamp
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
module="StoryViewer__meta--timestamp"
|
module="StoryViewer__meta--timestamp"
|
||||||
timestamp={visibleStory.timestamp}
|
timestamp={timestamp}
|
||||||
/>
|
/>
|
||||||
<div className="StoryViewer__progress">
|
<div className="StoryViewer__progress">
|
||||||
{stories.map((story, index) => (
|
{stories.map((story, index) => (
|
||||||
<div
|
<div
|
||||||
className="StoryViewer__progress--container"
|
className="StoryViewer__progress--container"
|
||||||
key={story.timestamp}
|
key={story.messageId}
|
||||||
>
|
>
|
||||||
{currentStoryIndex === index ? (
|
{currentStoryIndex === index ? (
|
||||||
<animated.div
|
<animated.div
|
||||||
|
@ -315,9 +345,9 @@ export const StoryViewer = ({
|
||||||
onReact={emoji => {
|
onReact={emoji => {
|
||||||
onReactToStory(emoji, visibleStory);
|
onReactToStory(emoji, visibleStory);
|
||||||
}}
|
}}
|
||||||
onReply={(message, mentions, timestamp) => {
|
onReply={(message, mentions, replyTimestamp) => {
|
||||||
setHasReplyModal(false);
|
setHasReplyModal(false);
|
||||||
onReplyToStory(message, mentions, timestamp, visibleStory);
|
onReplyToStory(message, mentions, replyTimestamp, visibleStory);
|
||||||
}}
|
}}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onSetSkinTone={onSetSkinTone}
|
||||||
onTextTooLong={onTextTooLong}
|
onTextTooLong={onTextTooLong}
|
||||||
|
@ -327,7 +357,7 @@ export const StoryViewer = ({
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
replies={[]}
|
replies={[]}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
storyPreviewAttachment={visibleStory.attachment}
|
storyPreviewAttachment={attachment}
|
||||||
views={[]}
|
views={[]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Spinner } from '../Spinner';
|
||||||
|
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import {
|
import {
|
||||||
hasNotDownloaded,
|
hasNotResolved,
|
||||||
getImageDimensions,
|
getImageDimensions,
|
||||||
defaultBlurHash,
|
defaultBlurHash,
|
||||||
} from '../../types/Attachment';
|
} from '../../types/Attachment';
|
||||||
|
@ -166,10 +166,10 @@ export const GIF: React.FC<Props> = props => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = Boolean(attachment.pending);
|
const isPending = Boolean(attachment.pending);
|
||||||
const isNotDownloaded = hasNotDownloaded(attachment) && !isPending;
|
const isNotResolved = hasNotResolved(attachment) && !isPending;
|
||||||
|
|
||||||
let fileSize: JSX.Element | undefined;
|
let fileSize: JSX.Element | undefined;
|
||||||
if (isNotDownloaded && attachment.fileSize) {
|
if (isNotResolved && attachment.fileSize) {
|
||||||
fileSize = (
|
fileSize = (
|
||||||
<div className="module-image--gif__filesize">
|
<div className="module-image--gif__filesize">
|
||||||
{attachment.fileSize} · GIF
|
{attachment.fileSize} · GIF
|
||||||
|
@ -178,7 +178,7 @@ export const GIF: React.FC<Props> = props => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let gif: JSX.Element | undefined;
|
let gif: JSX.Element | undefined;
|
||||||
if (isNotDownloaded || isPending) {
|
if (isNotResolved || isPending) {
|
||||||
gif = (
|
gif = (
|
||||||
<Blurhash
|
<Blurhash
|
||||||
hash={attachment.blurHash || defaultBlurHash(theme)}
|
hash={attachment.blurHash || defaultBlurHash(theme)}
|
||||||
|
@ -213,12 +213,12 @@ export const GIF: React.FC<Props> = props => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let overlay: JSX.Element | undefined;
|
let overlay: JSX.Element | undefined;
|
||||||
if ((tapToPlay && !isPlaying) || isNotDownloaded) {
|
if ((tapToPlay && !isPlaying) || isNotResolved) {
|
||||||
const className = classNames([
|
const className = classNames([
|
||||||
'module-image__border-overlay',
|
'module-image__border-overlay',
|
||||||
'module-image__border-overlay--with-click-handler',
|
'module-image__border-overlay--with-click-handler',
|
||||||
'module-image--soft-corners',
|
'module-image--soft-corners',
|
||||||
isNotDownloaded
|
isNotResolved
|
||||||
? 'module-image--not-downloaded'
|
? 'module-image--not-downloaded'
|
||||||
: 'module-image--tap-to-play',
|
: 'module-image--tap-to-play',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -8,7 +8,10 @@ import { Blurhash } from 'react-blurhash';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { hasNotDownloaded, defaultBlurHash } from '../../types/Attachment';
|
import {
|
||||||
|
isDownloaded as isDownloadedFunction,
|
||||||
|
defaultBlurHash,
|
||||||
|
} from '../../types/Attachment';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
alt: string;
|
alt: string;
|
||||||
|
@ -169,7 +172,7 @@ export class Image extends React.Component<Props> {
|
||||||
const canClick = this.canClick();
|
const canClick = this.canClick();
|
||||||
const imgNotDownloaded = isDownloaded
|
const imgNotDownloaded = isDownloaded
|
||||||
? false
|
? false
|
||||||
: hasNotDownloaded(attachment);
|
: !isDownloadedFunction(attachment);
|
||||||
|
|
||||||
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ import {
|
||||||
getGridDimensions,
|
getGridDimensions,
|
||||||
getImageDimensions,
|
getImageDimensions,
|
||||||
hasImage,
|
hasImage,
|
||||||
hasNotDownloaded,
|
isDownloaded,
|
||||||
hasVideoScreenshot,
|
hasVideoScreenshot,
|
||||||
isAudio,
|
isAudio,
|
||||||
isImage,
|
isImage,
|
||||||
|
@ -917,7 +917,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
onError={this.handleImageError}
|
onError={this.handleImageError}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
onClick={attachment => {
|
onClick={attachment => {
|
||||||
if (hasNotDownloaded(attachment)) {
|
if (!isDownloaded(attachment)) {
|
||||||
kickOffAttachmentDownload({ attachment, messageId: id });
|
kickOffAttachmentDownload({ attachment, messageId: id });
|
||||||
} else {
|
} else {
|
||||||
showVisualAttachment({ attachment, messageId: id });
|
showVisualAttachment({ attachment, messageId: id });
|
||||||
|
@ -1093,7 +1093,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const onPreviewImageClick = () => {
|
const onPreviewImageClick = () => {
|
||||||
if (first.image && hasNotDownloaded(first.image)) {
|
if (first.image && !isDownloaded(first.image)) {
|
||||||
kickOffAttachmentDownload({
|
kickOffAttachmentDownload({
|
||||||
attachment: first.image,
|
attachment: first.image,
|
||||||
messageId: id,
|
messageId: id,
|
||||||
|
@ -2388,7 +2388,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments && hasNotDownloaded(attachments[0])) {
|
if (attachments && !isDownloaded(attachments[0])) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
kickOffAttachmentDownload({
|
kickOffAttachmentDownload({
|
||||||
|
@ -2420,7 +2420,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
attachments.length > 0 &&
|
attachments.length > 0 &&
|
||||||
!isAttachmentPending &&
|
!isAttachmentPending &&
|
||||||
(isImage(attachments) || isVideo(attachments)) &&
|
(isImage(attachments) || isVideo(attachments)) &&
|
||||||
hasNotDownloaded(attachments[0])
|
!isDownloaded(attachments[0])
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -2511,7 +2511,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = attachments[0];
|
const attachment = attachments[0];
|
||||||
if (hasNotDownloaded(attachment)) {
|
if (!isDownloaded(attachment)) {
|
||||||
kickOffAttachmentDownload({
|
kickOffAttachmentDownload({
|
||||||
attachment,
|
attachment,
|
||||||
messageId: id,
|
messageId: id,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { noop } from 'lodash';
|
||||||
import { assert } from '../../util/assert';
|
import { assert } from '../../util/assert';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { hasNotDownloaded } from '../../types/Attachment';
|
import { isDownloaded } from '../../types/Attachment';
|
||||||
import type { DirectionType, MessageStatusType } from './Message';
|
import type { DirectionType, MessageStatusType } from './Message';
|
||||||
|
|
||||||
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
||||||
|
@ -203,7 +203,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
|
|
||||||
if (attachment.pending) {
|
if (attachment.pending) {
|
||||||
state = State.Pending;
|
state = State.Pending;
|
||||||
} else if (hasNotDownloaded(attachment)) {
|
} else if (!isDownloaded(attachment)) {
|
||||||
state = State.NotDownloaded;
|
state = State.NotDownloaded;
|
||||||
} else if (!hasPeaks) {
|
} else if (!hasPeaks) {
|
||||||
state = State.Computing;
|
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
|
// Shared fields
|
||||||
active_at?: number | null;
|
active_at?: number | null;
|
||||||
draft?: string | null;
|
draft?: string | null;
|
||||||
|
hasPostedStory?: boolean;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
lastMessage?: string | null;
|
lastMessage?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
|
@ -149,6 +149,8 @@ import { isConversationAccepted } from '../util/isConversationAccepted';
|
||||||
import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
|
import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
|
||||||
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
|
import { shouldDownloadStory } from '../util/shouldDownloadStory';
|
||||||
|
import { shouldShowStoriesView } from '../state/selectors/stories';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -233,7 +235,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
messageChanged(this.id, conversationId, { ...this.attributes });
|
messageChanged(this.id, conversationId, { ...this.attributes });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addStory } = window.reduxActions.stories;
|
const { storyChanged } = window.reduxActions.stories;
|
||||||
|
|
||||||
if (isStory(this.attributes)) {
|
if (isStory(this.attributes)) {
|
||||||
const ourConversationId =
|
const ourConversationId =
|
||||||
|
@ -247,17 +249,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO DESKTOP-3179
|
storyChanged(storyData);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2438,6 +2430,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStory(message.attributes)) {
|
||||||
|
attributes.hasPostedStory = true;
|
||||||
|
}
|
||||||
|
|
||||||
attributes.active_at = now;
|
attributes.active_at = now;
|
||||||
conversation.set(attributes);
|
conversation.set(attributes);
|
||||||
|
|
||||||
|
@ -2556,14 +2552,25 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
// outgoing message or we've accepted the conversation
|
// outgoing message or we've accepted the conversation
|
||||||
const reduxState = window.reduxStore.getState();
|
const reduxState = window.reduxStore.getState();
|
||||||
const attachments = this.get('attachments') || [];
|
const attachments = this.get('attachments') || [];
|
||||||
|
|
||||||
|
let queueStoryForDownload = false;
|
||||||
|
if (isStory(message.attributes)) {
|
||||||
|
const isShowingStories = shouldShowStoriesView(reduxState);
|
||||||
|
|
||||||
|
queueStoryForDownload =
|
||||||
|
isShowingStories ||
|
||||||
|
(await shouldDownloadStory(conversation.attributes));
|
||||||
|
}
|
||||||
|
|
||||||
const shouldHoldOffDownload =
|
const shouldHoldOffDownload =
|
||||||
(isImage(attachments) || isVideo(attachments)) &&
|
!queueStoryForDownload ||
|
||||||
isInCall(reduxState);
|
((isImage(attachments) || isVideo(attachments)) &&
|
||||||
|
isInCall(reduxState));
|
||||||
if (
|
if (
|
||||||
this.hasAttachmentDownloads() &&
|
this.hasAttachmentDownloads() &&
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
(this.getConversation()!.getAccepted() ||
|
(this.getConversation()!.getAccepted() ||
|
||||||
isStory(message.attributes) ||
|
queueStoryForDownload ||
|
||||||
isOutgoing(message.attributes)) &&
|
isOutgoing(message.attributes)) &&
|
||||||
!shouldHoldOffDownload
|
!shouldHoldOffDownload
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import type { StoryDataType } from '../state/ducks/stories';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import dataInterface from '../sql/Client';
|
import dataInterface from '../sql/Client';
|
||||||
import { getAttachmentsForMessage } from '../state/selectors/message';
|
import { getAttachmentsForMessage } from '../state/selectors/message';
|
||||||
import { hasNotDownloaded } from '../types/Attachment';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
|
|
||||||
|
@ -30,24 +29,9 @@ export function getStoryDataFromMessageAttributes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quickly determine if item hasn't been
|
const [attachment] = unresolvedAttachment.path
|
||||||
// downloaded before we run getAttachmentsForMessage which is cached.
|
? getAttachmentsForMessage(message)
|
||||||
if (!unresolvedAttachment.path) {
|
: [unresolvedAttachment];
|
||||||
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 selectedReaction = (
|
const selectedReaction = (
|
||||||
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
|
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
|
||||||
|
|
|
@ -212,6 +212,7 @@ const dataInterface: ClientInterface = {
|
||||||
searchMessagesInConversation,
|
searchMessagesInConversation,
|
||||||
|
|
||||||
getMessageCount,
|
getMessageCount,
|
||||||
|
getStoryCount,
|
||||||
saveMessage,
|
saveMessage,
|
||||||
saveMessages,
|
saveMessages,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
|
@ -295,6 +296,7 @@ const dataInterface: ClientInterface = {
|
||||||
_deleteAllStoryReads,
|
_deleteAllStoryReads,
|
||||||
addNewStoryRead,
|
addNewStoryRead,
|
||||||
getLastStoryReadsForAuthor,
|
getLastStoryReadsForAuthor,
|
||||||
|
countStoryReadsByConversation,
|
||||||
|
|
||||||
removeAll,
|
removeAll,
|
||||||
removeAllConfiguration,
|
removeAllConfiguration,
|
||||||
|
@ -1078,6 +1080,10 @@ async function getMessageCount(conversationId?: string) {
|
||||||
return channels.getMessageCount(conversationId);
|
return channels.getMessageCount(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStoryCount(conversationId: string) {
|
||||||
|
return channels.getStoryCount(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveMessage(
|
async function saveMessage(
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options: {
|
options: {
|
||||||
|
@ -1633,6 +1639,11 @@ async function getLastStoryReadsForAuthor(options: {
|
||||||
}): Promise<Array<StoryReadType>> {
|
}): Promise<Array<StoryReadType>> {
|
||||||
return channels.getLastStoryReadsForAuthor(options);
|
return channels.getLastStoryReadsForAuthor(options);
|
||||||
}
|
}
|
||||||
|
async function countStoryReadsByConversation(
|
||||||
|
conversationId: string
|
||||||
|
): Promise<number> {
|
||||||
|
return channels.countStoryReadsByConversation(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
|
||||||
|
|
|
@ -371,6 +371,7 @@ export type DataInterface = {
|
||||||
// searchMessagesInConversation is JSON on server, full message on Client
|
// searchMessagesInConversation is JSON on server, full message on Client
|
||||||
|
|
||||||
getMessageCount: (conversationId?: string) => Promise<number>;
|
getMessageCount: (conversationId?: string) => Promise<number>;
|
||||||
|
getStoryCount: (conversationId: string) => Promise<number>;
|
||||||
saveMessage: (
|
saveMessage: (
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options: {
|
options: {
|
||||||
|
@ -561,6 +562,7 @@ export type DataInterface = {
|
||||||
conversationId?: UUIDStringType;
|
conversationId?: UUIDStringType;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<Array<StoryReadType>>;
|
}): Promise<Array<StoryReadType>>;
|
||||||
|
countStoryReadsByConversation(conversationId: string): Promise<number>;
|
||||||
|
|
||||||
removeAll: () => Promise<void>;
|
removeAll: () => Promise<void>;
|
||||||
removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise<void>;
|
removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise<void>;
|
||||||
|
|
|
@ -208,6 +208,7 @@ const dataInterface: ServerInterface = {
|
||||||
searchMessagesInConversation,
|
searchMessagesInConversation,
|
||||||
|
|
||||||
getMessageCount,
|
getMessageCount,
|
||||||
|
getStoryCount,
|
||||||
saveMessage,
|
saveMessage,
|
||||||
saveMessages,
|
saveMessages,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
|
@ -291,6 +292,7 @@ const dataInterface: ServerInterface = {
|
||||||
_deleteAllStoryReads,
|
_deleteAllStoryReads,
|
||||||
addNewStoryRead,
|
addNewStoryRead,
|
||||||
getLastStoryReadsForAuthor,
|
getLastStoryReadsForAuthor,
|
||||||
|
countStoryReadsByConversation,
|
||||||
|
|
||||||
removeAll,
|
removeAll,
|
||||||
removeAllConfiguration,
|
removeAllConfiguration,
|
||||||
|
@ -1686,6 +1688,20 @@ function getMessageCountSync(
|
||||||
return count;
|
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> {
|
async function getMessageCount(conversationId?: string): Promise<number> {
|
||||||
return getMessageCountSync(conversationId);
|
return getMessageCountSync(conversationId);
|
||||||
}
|
}
|
||||||
|
@ -2392,7 +2408,7 @@ function getOlderMessagesByConversationSync(
|
||||||
|
|
||||||
async function getOlderStories({
|
async function getOlderStories({
|
||||||
conversationId,
|
conversationId,
|
||||||
limit = 10,
|
limit = 9999,
|
||||||
receivedAt = Number.MAX_VALUE,
|
receivedAt = Number.MAX_VALUE,
|
||||||
sentAt,
|
sentAt,
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
|
@ -2416,7 +2432,7 @@ async function getOlderStories({
|
||||||
(received_at < $receivedAt
|
(received_at < $receivedAt
|
||||||
OR (received_at IS $receivedAt AND sent_at < $sentAt)
|
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;
|
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
|
// All data in database
|
||||||
async function removeAll(): Promise<void> {
|
async function removeAll(): Promise<void> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
|
@ -12,12 +12,17 @@ import type { StateType as RootStateType } from '../reducer';
|
||||||
import type { StoryViewType } from '../../components/StoryListItem';
|
import type { StoryViewType } from '../../components/StoryListItem';
|
||||||
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
|
import dataInterface from '../../sql/Client';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
||||||
|
import { UUID } from '../../types/UUID';
|
||||||
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
||||||
import { getMessageById } from '../../messages/getMessageById';
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { markViewed } from '../../services/MessageUpdater';
|
import { markViewed } from '../../services/MessageUpdater';
|
||||||
|
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||||
|
import { replaceIndex } from '../../util/replaceIndex';
|
||||||
import { showToast } from '../../util/showToast';
|
import { showToast } from '../../util/showToast';
|
||||||
|
import { isDownloaded, isDownloading } from '../../types/Attachment';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
||||||
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
||||||
|
@ -40,13 +45,14 @@ export type StoriesStateType = {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const ADD_STORY = 'stories/ADD_STORY';
|
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
|
||||||
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
|
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
|
||||||
|
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||||
|
|
||||||
type AddStoryActionType = {
|
type MarkStoryReadActionType = {
|
||||||
type: typeof ADD_STORY;
|
type: typeof MARK_STORY_READ;
|
||||||
payload: StoryDataType;
|
payload: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReactToStoryActionType = {
|
type ReactToStoryActionType = {
|
||||||
|
@ -57,38 +63,38 @@ type ReactToStoryActionType = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StoryChangedActionType = {
|
||||||
|
type: typeof STORY_CHANGED;
|
||||||
|
payload: StoryDataType;
|
||||||
|
};
|
||||||
|
|
||||||
type ToggleViewActionType = {
|
type ToggleViewActionType = {
|
||||||
type: typeof TOGGLE_VIEW;
|
type: typeof TOGGLE_VIEW;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StoriesActionType =
|
export type StoriesActionType =
|
||||||
| AddStoryActionType
|
| MarkStoryReadActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| ReactToStoryActionType
|
| ReactToStoryActionType
|
||||||
|
| StoryChangedActionType
|
||||||
| ToggleViewActionType;
|
| ToggleViewActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
addStory,
|
|
||||||
markStoryRead,
|
markStoryRead,
|
||||||
|
queueStoryDownload,
|
||||||
reactToStory,
|
reactToStory,
|
||||||
replyToStory,
|
replyToStory,
|
||||||
|
storyChanged,
|
||||||
toggleStoriesView,
|
toggleStoriesView,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
|
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
|
||||||
|
|
||||||
function addStory(story: StoryDataType): AddStoryActionType {
|
|
||||||
return {
|
|
||||||
type: ADD_STORY,
|
|
||||||
payload: story,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function markStoryRead(
|
function markStoryRead(
|
||||||
messageId: string
|
messageId: string
|
||||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
): ThunkAction<void, RootStateType, unknown, MarkStoryReadActionType> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const { stories } = getState().stories;
|
const { stories } = getState().stories;
|
||||||
|
|
||||||
|
@ -109,7 +115,9 @@ function markStoryRead(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markViewed(message.attributes, Date.now());
|
const storyReadDate = Date.now();
|
||||||
|
|
||||||
|
markViewed(message.attributes, storyReadDate);
|
||||||
|
|
||||||
const viewedReceipt = {
|
const viewedReceipt = {
|
||||||
messageId,
|
messageId,
|
||||||
|
@ -125,6 +133,55 @@ function markStoryRead(
|
||||||
|
|
||||||
viewedReceiptsJobQueue.add({ viewedReceipt });
|
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({
|
dispatch({
|
||||||
type: 'NOOP',
|
type: 'NOOP',
|
||||||
payload: null,
|
payload: null,
|
||||||
|
@ -188,6 +245,13 @@ function replyToStory(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storyChanged(story: StoryDataType): StoryChangedActionType {
|
||||||
|
return {
|
||||||
|
type: STORY_CHANGED,
|
||||||
|
payload: story,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toggleStoriesView(): ToggleViewActionType {
|
function toggleStoriesView(): ToggleViewActionType {
|
||||||
return {
|
return {
|
||||||
type: TOGGLE_VIEW,
|
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, [
|
const newStory = pick(action.payload, [
|
||||||
'attachment',
|
'attachment',
|
||||||
'conversationId',
|
'conversationId',
|
||||||
|
@ -238,18 +302,35 @@ export function reducer(
|
||||||
'timestamp',
|
'timestamp',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// TODO DEKTOP-3179
|
// Stories don't really need to change except for when we don't have the
|
||||||
// ADD_STORY fires whenever the message model changes so we check if this
|
// attachment downloaded and we queue a download. Then the story's message
|
||||||
// story already exists in state -- if it does then we don't need to re-add.
|
// will have the new attachment information. This is an optimization so
|
||||||
const hasStory = state.stories.find(
|
// we don't needlessly re-render.
|
||||||
|
const prevStory = state.stories.find(
|
||||||
existingStory => existingStory.messageId === newStory.messageId
|
existingStory => existingStory.messageId === newStory.messageId
|
||||||
);
|
);
|
||||||
if (hasStory) {
|
if (prevStory) {
|
||||||
return state;
|
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) =>
|
const stories = [...state.stories, newStory].sort((a, b) =>
|
||||||
a.timestamp > b.timestamp ? -1 : 1
|
a.timestamp > b.timestamp ? 1 : -1
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,13 +24,22 @@ export const shouldShowStoriesView = createSelector(
|
||||||
export const getStories = createSelector(
|
export const getStories = createSelector(
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getStoriesState,
|
getStoriesState,
|
||||||
|
shouldShowStoriesView,
|
||||||
(
|
(
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
{ stories }: Readonly<StoriesStateType>
|
{ stories }: Readonly<StoriesStateType>,
|
||||||
|
isShowingStoriesView
|
||||||
): {
|
): {
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
} => {
|
} => {
|
||||||
|
if (!isShowingStoriesView) {
|
||||||
|
return {
|
||||||
|
hiddenStories: [],
|
||||||
|
stories: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const storiesById = new Map<string, ConversationStoryType>();
|
const storiesById = new Map<string, ConversationStoryType>();
|
||||||
const hiddenStoriesById = 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 {
|
return {
|
||||||
hiddenStories: Array.from(hiddenStoriesById.values()),
|
hiddenStories: Array.from(hiddenStoriesById.values()).reverse(),
|
||||||
stories: Array.from(storiesById.values()),
|
stories: Array.from(storiesById.values()).reverse(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -93,15 +93,15 @@ describe('sql/stories', () => {
|
||||||
});
|
});
|
||||||
assert.lengthOf(stories, 4, 'expect four total stories');
|
assert.lengthOf(stories, 4, 'expect four total stories');
|
||||||
|
|
||||||
// They are in DESC order
|
// They are in ASC order
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
stories[0].id,
|
stories[0].id,
|
||||||
story5.id,
|
story1.id,
|
||||||
'stories first should be story5'
|
'stories first should be story5'
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
stories[3].id,
|
stories[3].id,
|
||||||
story1.id,
|
story5.id,
|
||||||
'stories last should be story1'
|
'stories last should be story1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -115,15 +115,15 @@ describe('sql/stories', () => {
|
||||||
'expect two stories in conversaton'
|
'expect two stories in conversaton'
|
||||||
);
|
);
|
||||||
|
|
||||||
// They are in DESC order
|
// They are in ASC order
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
storiesInConversation[0].id,
|
storiesInConversation[0].id,
|
||||||
story4.id,
|
story1.id,
|
||||||
'storiesInConversation first should be story4'
|
'storiesInConversation first should be story4'
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
storiesInConversation[1].id,
|
storiesInConversation[1].id,
|
||||||
story1.id,
|
story4.id,
|
||||||
'storiesInConversation last should be story1'
|
'storiesInConversation last should be story1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -133,15 +133,15 @@ describe('sql/stories', () => {
|
||||||
});
|
});
|
||||||
assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author');
|
assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author');
|
||||||
|
|
||||||
// They are in DESC order
|
// They are in ASC order
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
storiesByAuthor[0].id,
|
storiesByAuthor[0].id,
|
||||||
story5.id,
|
story2.id,
|
||||||
'storiesByAuthor first should be story5'
|
'storiesByAuthor first should be story5'
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
storiesByAuthor[1].id,
|
storiesByAuthor[1].id,
|
||||||
story2.id,
|
story5.id,
|
||||||
'storiesByAuthor last should be story2'
|
'storiesByAuthor last should be story2'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -213,15 +213,15 @@ describe('sql/stories', () => {
|
||||||
});
|
});
|
||||||
assert.lengthOf(stories, 2, 'expect two stories');
|
assert.lengthOf(stories, 2, 'expect two stories');
|
||||||
|
|
||||||
// They are in DESC order
|
// They are in ASC order
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
stories[0].id,
|
stories[0].id,
|
||||||
story3.id,
|
story2.id,
|
||||||
'stories first should be story3'
|
'stories first should be story3'
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
stories[1].id,
|
stories[1].id,
|
||||||
story2.id,
|
story3.id,
|
||||||
'stories last should be story2'
|
'stories last should be story2'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -695,10 +695,18 @@ export function isGIF(attachments?: ReadonlyArray<AttachmentType>): boolean {
|
||||||
return hasFlag && isVideoAttachment(attachment);
|
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);
|
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 {
|
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {
|
||||||
const firstAttachment = attachments ? attachments[0] : null;
|
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