diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1dde50b0a..75ff1cdbb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6992,6 +6992,10 @@ } } }, + "StoryImage__error": { + "message": "Error displaying image", + "description": "aria-label for image errors" + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/stylesheets/components/StoryImage.scss b/stylesheets/components/StoryImage.scss new file mode 100644 index 000000000..d2e8333f3 --- /dev/null +++ b/stylesheets/components/StoryImage.scss @@ -0,0 +1,69 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.StoryImage { + align-items: center; + border-radius: 8px; + display: flex; + height: 100%; + justify-content: center; + overflow: hidden; + position: relative; + width: 100%; + z-index: $z-index-base; + + &--thumbnail { + height: 72px; + width: 46px; + } + + &__image { + height: 100%; + object-fit: cover; + width: 100%; + } + + &__error { + height: 100%; + max-width: 140px; + width: 100%; + + @include color-svg( + '../images/full-screen-flow/alert-outline.svg', + $color-gray-25 + ); + } + + &__spinner-container { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + + &__spinner-bubble { + align-items: center; + background-color: $color-gray-75; + border-radius: 32px; + display: flex; + height: 32px; + justify-content: center; + width: 32px; + } + + &__spinner { + @include dark-theme { + &__circle { + background-color: $color-white; + } + + &__arc { + background-color: $color-gray-75; + } + } + } +} diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss index e4aef1f83..baef66b98 100644 --- a/stylesheets/components/StoryListItem.scss +++ b/stylesheets/components/StoryListItem.scss @@ -22,6 +22,7 @@ &--title { @include font-body-1-bold; + color: $color-gray-05; } &--timestamp { diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss index e8809c8b5..36d6006df 100644 --- a/stylesheets/components/StoryViewer.scss +++ b/stylesheets/components/StoryViewer.scss @@ -64,6 +64,7 @@ position: absolute; transform: translateX(-50%); width: 284px; + z-index: $z-index-above-base; &--group-avatar { margin-left: -8px; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 3f44deb8b..fd51d1b42 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -100,6 +100,7 @@ @import './components/Select.scss'; @import './components/Slider.scss'; @import './components/Stories.scss'; +@import './components/StoryImage.scss'; @import './components/StoryListItem.scss'; @import './components/StoryReplyQuote.scss'; @import './components/StoryViewsNRepliesModal.scss'; diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index 38fcd2cd8..62b50c650 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -59,59 +59,55 @@ function createStory({ }; } +function getAttachmentWithThumbnail(url: string): AttachmentType { + return fakeAttachment({ + url, + thumbnail: fakeThumbnail(url), + }); +} + const getDefaultProps = (): PropsType => ({ hiddenStories: [], i18n, openConversationInternal: action('openConversationInternal'), preferredWidthFromStorage: 380, + queueStoryDownload: action('queueStoryDownload'), renderStoryViewer: () =>
, stories: [ createStory({ - attachment: fakeAttachment({ - thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'), - }), + attachment: getAttachmentWithThumbnail( + '/fixtures/tina-rolf-269345-unsplash.jpg' + ), timestamp: Date.now() - 2 * durations.MINUTE, }), createStory({ - attachment: fakeAttachment({ - thumbnail: fakeThumbnail( - '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg' - ), - }), + attachment: getAttachmentWithThumbnail( + '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg' + ), timestamp: Date.now() - 5 * durations.MINUTE, }), createStory({ group: { title: 'BBQ in the park' }, - attachment: fakeAttachment({ - thumbnail: fakeThumbnail( - '/fixtures/nathan-anderson-316188-unsplash.jpg' - ), - }), + attachment: getAttachmentWithThumbnail( + '/fixtures/nathan-anderson-316188-unsplash.jpg' + ), timestamp: Date.now() - 65 * durations.MINUTE, }), createStory({ - attachment: fakeAttachment({ - thumbnail: fakeThumbnail('/fixtures/snow.jpg'), - }), + attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'), timestamp: Date.now() - 92 * durations.MINUTE, }), createStory({ - attachment: fakeAttachment({ - thumbnail: fakeThumbnail('/fixtures/kitten-1-64-64.jpg'), - }), + attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'), timestamp: Date.now() - 164 * durations.MINUTE, }), createStory({ group: { title: 'Breaking Signal for Science' }, - attachment: fakeAttachment({ - thumbnail: fakeThumbnail('/fixtures/kitten-2-64-64.jpg'), - }), + attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'), timestamp: Date.now() - 380 * durations.MINUTE, }), createStory({ - attachment: fakeAttachment({ - thumbnail: fakeThumbnail('/fixtures/kitten-3-64-64.jpg'), - }), + attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'), timestamp: Date.now() - 421 * durations.MINUTE, }), ], diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index 1539fdcc8..8c8e8dca1 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -16,6 +16,7 @@ export type PropsType = { preferredWidthFromStorage: number; openConversationInternal: (_: { conversationId: string }) => unknown; renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element; + queueStoryDownload: (storyId: string) => unknown; stories: Array; toggleHideStories: (conversationId: string) => unknown; toggleStoriesView: () => unknown; @@ -31,6 +32,7 @@ export const Stories = ({ i18n, openConversationInternal, preferredWidthFromStorage, + queueStoryDownload, renderStoryViewer, stories, toggleHideStories, @@ -99,6 +101,7 @@ export const Stories = ({ } }} openConversationInternal={openConversationInternal} + queueStoryDownload={queueStoryDownload} stories={stories} toggleHideStories={toggleHideStories} /> diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 94b6aa5f8..b7293db46 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -44,12 +44,17 @@ function search( ); } +function getNewestStory(story: ConversationStoryType): StoryViewType { + return story.stories[story.stories.length - 1]; +} + export type PropsType = { hiddenStories: Array; i18n: LocalizerType; onBack: () => unknown; onStoryClicked: (conversationId: string) => unknown; openConversationInternal: (_: { conversationId: string }) => unknown; + queueStoryDownload: (storyId: string) => unknown; stories: Array; toggleHideStories: (conversationId: string) => unknown; }; @@ -59,6 +64,7 @@ export const StoriesPane = ({ onBack, onStoryClicked, openConversationInternal, + queueStoryDownload, stories, toggleHideStories, }: PropsType): JSX.Element => { @@ -103,18 +109,19 @@ export const StoriesPane = ({ > {renderedStories.map(story => ( { onStoryClicked(story.conversationId); }} onHideStory={() => { - toggleHideStories(story.stories[0].sender.id); + toggleHideStories(getNewestStory(story).sender.id); }} onGoToConversation={conversationId => { openConversationInternal({ conversationId }); }} - story={story.stories[0]} + queueStoryDownload={queueStoryDownload} + story={getNewestStory(story)} /> ))} {!stories.length && i18n('Stories__list-empty')} diff --git a/ts/components/StoryImage.stories.tsx b/ts/components/StoryImage.stories.tsx new file mode 100644 index 000000000..b527affaf --- /dev/null +++ b/ts/components/StoryImage.stories.tsx @@ -0,0 +1,89 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { v4 as uuid } from 'uuid'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import type { PropsType } from './StoryImage'; +import { StoryImage } from './StoryImage'; +import enMessages from '../../_locales/en/messages.json'; +import { setupI18n } from '../util/setupI18n'; +import { + fakeAttachment, + fakeThumbnail, +} from '../test-both/helpers/fakeAttachment'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/StoryImage', module); + +function getDefaultProps(): PropsType { + return { + attachment: fakeAttachment({ + url: '/fixtures/nathan-anderson-316188-unsplash.jpg', + thumbnail: fakeThumbnail('/fixtures/nathan-anderson-316188-unsplash.jpg'), + }), + i18n, + label: 'A story', + queueStoryDownload: action('queueStoryDownload'), + storyId: uuid(), + }; +} + +story.add('Good story', () => ); + +story.add('Good story (thumbnail)', () => ( + +)); + +story.add('Not downloaded', () => ( + +)); + +story.add('Not downloaded (thumbnail)', () => ( + +)); + +story.add('Pending download', () => ( + +)); + +story.add('Pending download (thumbnail)', () => ( + +)); + +story.add('Broken Image', () => ( + +)); + +story.add('Broken Image (thumbnail)', () => ( + +)); diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx new file mode 100644 index 000000000..296dd7b92 --- /dev/null +++ b/ts/components/StoryImage.tsx @@ -0,0 +1,112 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { Blurhash } from 'react-blurhash'; + +import type { AttachmentType } from '../types/Attachment'; +import type { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; +import { ThemeType } from '../types/Util'; +import { + defaultBlurHash, + isDownloaded, + hasNotResolved, + isDownloading, +} from '../types/Attachment'; +import { getClassNamesFor } from '../util/getClassNamesFor'; + +export type PropsType = { + readonly attachment?: AttachmentType; + i18n: LocalizerType; + readonly isThumbnail?: boolean; + readonly label: string; + readonly moduleClassName?: string; + readonly queueStoryDownload: (storyId: string) => unknown; + readonly storyId: string; +}; + +export const StoryImage = ({ + attachment, + i18n, + isThumbnail, + label, + moduleClassName, + queueStoryDownload, + storyId, +}: PropsType): JSX.Element | null => { + const [attachmentBroken, setAttachmentBroken] = useState(false); + + const shouldDownloadAttachment = + !isDownloaded(attachment) && !isDownloading(attachment); + + useEffect(() => { + if (shouldDownloadAttachment) { + queueStoryDownload(storyId); + } + }, [queueStoryDownload, shouldDownloadAttachment, storyId]); + + if (!attachment) { + return null; + } + + const isPending = Boolean(attachment.pending); + const isNotReadyToShow = hasNotResolved(attachment) || isPending; + + const getClassName = getClassNamesFor('StoryImage', moduleClassName); + + let storyElement: JSX.Element; + if (isNotReadyToShow) { + storyElement = ( + + ); + } else if (attachmentBroken) { + storyElement = ( +
+ ); + } else { + storyElement = ( + {label} setAttachmentBroken(true)} + src={ + isThumbnail && attachment.thumbnail + ? attachment.thumbnail.url + : attachment.url + } + /> + ); + } + + let spinner: JSX.Element | undefined; + if (isPending) { + spinner = ( +
+
+ +
+
+ ); + } + + return ( +
+ {storyElement} + {spinner} +
+ ); +}; diff --git a/ts/components/StoryListItem.stories.tsx b/ts/components/StoryListItem.stories.tsx index df918d678..eff359e24 100644 --- a/ts/components/StoryListItem.stories.tsx +++ b/ts/components/StoryListItem.stories.tsx @@ -23,6 +23,7 @@ function getDefaultProps(): PropsType { return { i18n, onClick: action('onClick'), + queueStoryDownload: action('queueStoryDownload'), story: { messageId: '123', sender: getDefaultConversation(), diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx index 992e4b94c..28a238e89 100644 --- a/ts/components/StoryListItem.tsx +++ b/ts/components/StoryListItem.tsx @@ -9,8 +9,9 @@ import type { ConversationType } from '../state/ducks/conversations'; import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenuPopper } from './ContextMenu'; -import { getAvatarColor } from '../types/Colors'; import { MessageTimestamp } from './conversation/MessageTimestamp'; +import { StoryImage } from './StoryImage'; +import { getAvatarColor } from '../types/Colors'; export type ConversationStoryType = { conversationId: string; @@ -53,6 +54,7 @@ export type PropsType = Pick< onClick: () => unknown; onGoToConversation?: (conversationId: string) => unknown; onHideStory?: (conversationId: string) => unknown; + queueStoryDownload: (storyId: string) => unknown; story: StoryViewType; }; @@ -64,6 +66,7 @@ export const StoryListItem = ({ onClick, onGoToConversation, onHideStory, + queueStoryDownload, story, }: PropsType): JSX.Element => { const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); @@ -182,14 +185,15 @@ export const StoryListItem = ({ /> )} {hasMultiple &&
} - {attachment && ( -
- )} +
, replies: Math.floor(Math.random() * 20), stories: [ diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index de8863d7b..8763a181e 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -1,7 +1,7 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSpring, animated, to } from '@react-spring/web'; import type { BodyRangeType, LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; @@ -12,7 +12,9 @@ import type { StoryViewType } from './StoryListItem'; import { Avatar, AvatarSize } from './Avatar'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; +import { StoryImage } from './StoryImage'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; +import { isDownloaded, isDownloading } from '../types/Attachment'; import { getAvatarColor } from '../types/Colors'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; @@ -37,6 +39,7 @@ export type PropsType = { ) => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; preferredReactionEmoji: Array; + queueStoryDownload: (storyId: string) => unknown; recentEmojis?: Array; replies?: number; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; @@ -59,6 +62,7 @@ export const StoryViewer = ({ onTextTooLong, onUseEmoji, preferredReactionEmoji, + queueStoryDownload, recentEmojis, renderEmojiPicker, replies, @@ -70,6 +74,7 @@ export const StoryViewer = ({ const visibleStory = stories[currentStoryIndex]; + const { attachment, messageId, timestamp } = visibleStory; const { acceptedMessageRequest, avatarPath, @@ -93,19 +98,20 @@ export const StoryViewer = ({ useEscapeHandling(onEscape); + // Either we show the next story in the current user's stories or we ask + // for the next user's stories. const showNextStory = useCallback(() => { - // Either we show the next story in the current user's stories or we ask - // for the next user's stories. if (currentStoryIndex < stories.length - 1) { setCurrentStoryIndex(currentStoryIndex + 1); } else { + setCurrentStoryIndex(0); onNextUserStories(); } }, [currentStoryIndex, onNextUserStories, stories.length]); + // Either we show the previous story in the current user's stories or we ask + // for the prior user's stories. const showPrevStory = useCallback(() => { - // Either we show the previous story in the current user's stories or we ask - // for the prior user's stories. if (currentStoryIndex === 0) { onPrevUserStories(); } else { @@ -128,8 +134,18 @@ export const StoryViewer = ({ spring.start({ from: { width: 0 }, to: { width: 100 }, - onRest: showNextStory, + onRest: { + width: ({ value }) => { + if (value === 100) { + showNextStory(); + } + }, + }, }); + + return () => { + spring.stop(); + }; }, [currentStoryIndex, showNextStory, spring]); useEffect(() => { @@ -141,8 +157,21 @@ export const StoryViewer = ({ }, [hasReplyModal, spring]); useEffect(() => { - markStoryRead(visibleStory.messageId); - }, [markStoryRead, visibleStory.messageId]); + markStoryRead(messageId); + }, [markStoryRead, messageId]); + + // Queue all undownloaded stories once we're viewing someone's stories + const storiesToDownload = useMemo(() => { + return stories + .filter( + story => + !isDownloaded(story.attachment) && !isDownloading(story.attachment) + ) + .map(story => story.messageId); + }, [stories]); + useEffect(() => { + storiesToDownload.forEach(id => queueStoryDownload(id)); + }, [queueStoryDownload, storiesToDownload]); const navigateStories = useCallback( (ev: KeyboardEvent) => { @@ -183,13 +212,14 @@ export const StoryViewer = ({ type="button" />
- {visibleStory.attachment && ( - {i18n('lightboxImageAlt')} - )} +
{stories.map((story, index) => (
{currentStoryIndex === index ? ( { onReactToStory(emoji, visibleStory); }} - onReply={(message, mentions, timestamp) => { + onReply={(message, mentions, replyTimestamp) => { setHasReplyModal(false); - onReplyToStory(message, mentions, timestamp, visibleStory); + onReplyToStory(message, mentions, replyTimestamp, visibleStory); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} @@ -327,7 +357,7 @@ export const StoryViewer = ({ renderEmojiPicker={renderEmojiPicker} replies={[]} skinTone={skinTone} - storyPreviewAttachment={visibleStory.attachment} + storyPreviewAttachment={attachment} views={[]} /> )} diff --git a/ts/components/conversation/GIF.tsx b/ts/components/conversation/GIF.tsx index cab70bffb..1bd4b63ca 100644 --- a/ts/components/conversation/GIF.tsx +++ b/ts/components/conversation/GIF.tsx @@ -10,7 +10,7 @@ import { Spinner } from '../Spinner'; import type { AttachmentType } from '../../types/Attachment'; import { - hasNotDownloaded, + hasNotResolved, getImageDimensions, defaultBlurHash, } from '../../types/Attachment'; @@ -166,10 +166,10 @@ export const GIF: React.FC = props => { }; const isPending = Boolean(attachment.pending); - const isNotDownloaded = hasNotDownloaded(attachment) && !isPending; + const isNotResolved = hasNotResolved(attachment) && !isPending; let fileSize: JSX.Element | undefined; - if (isNotDownloaded && attachment.fileSize) { + if (isNotResolved && attachment.fileSize) { fileSize = (
{attachment.fileSize} ยท GIF @@ -178,7 +178,7 @@ export const GIF: React.FC = props => { } let gif: JSX.Element | undefined; - if (isNotDownloaded || isPending) { + if (isNotResolved || isPending) { gif = ( = props => { } let overlay: JSX.Element | undefined; - if ((tapToPlay && !isPlaying) || isNotDownloaded) { + if ((tapToPlay && !isPlaying) || isNotResolved) { const className = classNames([ 'module-image__border-overlay', 'module-image__border-overlay--with-click-handler', 'module-image--soft-corners', - isNotDownloaded + isNotResolved ? 'module-image--not-downloaded' : 'module-image--tap-to-play', ]); diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index dcdbdc538..0d62045d0 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -8,7 +8,10 @@ import { Blurhash } from 'react-blurhash'; import { Spinner } from '../Spinner'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { AttachmentType } from '../../types/Attachment'; -import { hasNotDownloaded, defaultBlurHash } from '../../types/Attachment'; +import { + isDownloaded as isDownloadedFunction, + defaultBlurHash, +} from '../../types/Attachment'; export type Props = { alt: string; @@ -169,7 +172,7 @@ export class Image extends React.Component { const canClick = this.canClick(); const imgNotDownloaded = isDownloaded ? false - : hasNotDownloaded(attachment); + : !isDownloadedFunction(attachment); const resolvedBlurHash = blurHash || defaultBlurHash(theme); diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 24c5d600a..374012282 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -50,7 +50,7 @@ import { getGridDimensions, getImageDimensions, hasImage, - hasNotDownloaded, + isDownloaded, hasVideoScreenshot, isAudio, isImage, @@ -917,7 +917,7 @@ export class Message extends React.PureComponent { onError={this.handleImageError} tabIndex={tabIndex} onClick={attachment => { - if (hasNotDownloaded(attachment)) { + if (!isDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id }); } else { showVisualAttachment({ attachment, messageId: id }); @@ -1093,7 +1093,7 @@ export class Message extends React.PureComponent { } ); const onPreviewImageClick = () => { - if (first.image && hasNotDownloaded(first.image)) { + if (first.image && !isDownloaded(first.image)) { kickOffAttachmentDownload({ attachment: first.image, messageId: id, @@ -2388,7 +2388,7 @@ export class Message extends React.PureComponent { return; } - if (attachments && hasNotDownloaded(attachments[0])) { + if (attachments && !isDownloaded(attachments[0])) { event.preventDefault(); event.stopPropagation(); kickOffAttachmentDownload({ @@ -2420,7 +2420,7 @@ export class Message extends React.PureComponent { attachments.length > 0 && !isAttachmentPending && (isImage(attachments) || isVideo(attachments)) && - hasNotDownloaded(attachments[0]) + !isDownloaded(attachments[0]) ) { event.preventDefault(); event.stopPropagation(); @@ -2511,7 +2511,7 @@ export class Message extends React.PureComponent { } const attachment = attachments[0]; - if (hasNotDownloaded(attachment)) { + if (!isDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id, diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 69e1653d2..ac861921f 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -8,7 +8,7 @@ import { noop } from 'lodash'; import { assert } from '../../util/assert'; import type { LocalizerType } from '../../types/Util'; import type { AttachmentType } from '../../types/Attachment'; -import { hasNotDownloaded } from '../../types/Attachment'; +import { isDownloaded } from '../../types/Attachment'; import type { DirectionType, MessageStatusType } from './Message'; import type { ComputePeaksResult } from '../GlobalAudioContext'; @@ -203,7 +203,7 @@ export const MessageAudio: React.FC = (props: Props) => { if (attachment.pending) { state = State.Pending; - } else if (hasNotDownloaded(attachment)) { + } else if (!isDownloaded(attachment)) { state = State.NotDownloaded; } else if (!hasPeaks) { state = State.Computing; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 812c8c47b..72187fc42 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -287,6 +287,7 @@ export type ConversationAttributesType = { // Shared fields active_at?: number | null; draft?: string | null; + hasPostedStory?: boolean; isArchived?: boolean; lastMessage?: string | null; name?: string; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index a2bf28056..f16d8513d 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -149,6 +149,8 @@ import { isConversationAccepted } from '../util/isConversationAccepted'; import { getStoryDataFromMessageAttributes } from '../services/storyLoader'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import { getMessageById } from '../messages/getMessageById'; +import { shouldDownloadStory } from '../util/shouldDownloadStory'; +import { shouldShowStoriesView } from '../state/selectors/stories'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -233,7 +235,7 @@ export class MessageModel extends window.Backbone.Model { messageChanged(this.id, conversationId, { ...this.attributes }); } - const { addStory } = window.reduxActions.stories; + const { storyChanged } = window.reduxActions.stories; if (isStory(this.attributes)) { const ourConversationId = @@ -247,17 +249,7 @@ export class MessageModel extends window.Backbone.Model { return; } - // TODO DESKTOP-3179 - // Only add stories to redux if we've downloaded them. This should work - // because once we download a story we'll receive another change event - // which kicks off this function again. - if (Attachment.hasNotDownloaded(storyData.attachment)) { - return; - } - - // This is fine to call multiple times since the addStory action only - // adds new stories. - addStory(storyData); + storyChanged(storyData); } } @@ -2438,6 +2430,10 @@ export class MessageModel extends window.Backbone.Model { return; } + if (isStory(message.attributes)) { + attributes.hasPostedStory = true; + } + attributes.active_at = now; conversation.set(attributes); @@ -2556,14 +2552,25 @@ export class MessageModel extends window.Backbone.Model { // outgoing message or we've accepted the conversation const reduxState = window.reduxStore.getState(); const attachments = this.get('attachments') || []; + + let queueStoryForDownload = false; + if (isStory(message.attributes)) { + const isShowingStories = shouldShowStoriesView(reduxState); + + queueStoryForDownload = + isShowingStories || + (await shouldDownloadStory(conversation.attributes)); + } + const shouldHoldOffDownload = - (isImage(attachments) || isVideo(attachments)) && - isInCall(reduxState); + !queueStoryForDownload || + ((isImage(attachments) || isVideo(attachments)) && + isInCall(reduxState)); if ( this.hasAttachmentDownloads() && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion (this.getConversation()!.getAccepted() || - isStory(message.attributes) || + queueStoryForDownload || isOutgoing(message.attributes)) && !shouldHoldOffDownload ) { diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index f78effbae..4264c2620 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -7,7 +7,6 @@ import type { StoryDataType } from '../state/ducks/stories'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; import { getAttachmentsForMessage } from '../state/selectors/message'; -import { hasNotDownloaded } from '../types/Attachment'; import { isNotNil } from '../util/isNotNil'; import { strictAssert } from '../util/assert'; @@ -30,24 +29,9 @@ export function getStoryDataFromMessageAttributes( return; } - // Quickly determine if item hasn't been - // downloaded before we run getAttachmentsForMessage which is cached. - if (!unresolvedAttachment.path) { - log.warn( - `getStoryDataFromMessageAttributes: ${message.id} not downloaded (no path)` - ); - return; - } - - const [attachment] = getAttachmentsForMessage(message); - - // TODO DESKTOP-3179 - if (hasNotDownloaded(attachment)) { - log.warn( - `getStoryDataFromMessageAttributes: ${message.id} not downloaded (no url)` - ); - return; - } + const [attachment] = unresolvedAttachment.path + ? getAttachmentsForMessage(message) + : [unresolvedAttachment]; const selectedReaction = ( (message.reactions || []).find(re => re.fromId === ourConversationId) || {} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index b1086a008..2fa2eae1e 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -212,6 +212,7 @@ const dataInterface: ClientInterface = { searchMessagesInConversation, getMessageCount, + getStoryCount, saveMessage, saveMessages, removeMessage, @@ -295,6 +296,7 @@ const dataInterface: ClientInterface = { _deleteAllStoryReads, addNewStoryRead, getLastStoryReadsForAuthor, + countStoryReadsByConversation, removeAll, removeAllConfiguration, @@ -1078,6 +1080,10 @@ async function getMessageCount(conversationId?: string) { return channels.getMessageCount(conversationId); } +async function getStoryCount(conversationId: string) { + return channels.getStoryCount(conversationId); +} + async function saveMessage( data: MessageType, options: { @@ -1633,6 +1639,11 @@ async function getLastStoryReadsForAuthor(options: { }): Promise> { return channels.getLastStoryReadsForAuthor(options); } +async function countStoryReadsByConversation( + conversationId: string +): Promise { + return channels.countStoryReadsByConversation(conversationId); +} // Other diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 9a5442937..fd14e219e 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -371,6 +371,7 @@ export type DataInterface = { // searchMessagesInConversation is JSON on server, full message on Client getMessageCount: (conversationId?: string) => Promise; + getStoryCount: (conversationId: string) => Promise; saveMessage: ( data: MessageType, options: { @@ -561,6 +562,7 @@ export type DataInterface = { conversationId?: UUIDStringType; limit?: number; }): Promise>; + countStoryReadsByConversation(conversationId: string): Promise; removeAll: () => Promise; removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 56b122208..330fb7d9f 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -208,6 +208,7 @@ const dataInterface: ServerInterface = { searchMessagesInConversation, getMessageCount, + getStoryCount, saveMessage, saveMessages, removeMessage, @@ -291,6 +292,7 @@ const dataInterface: ServerInterface = { _deleteAllStoryReads, addNewStoryRead, getLastStoryReadsForAuthor, + countStoryReadsByConversation, removeAll, removeAllConfiguration, @@ -1686,6 +1688,20 @@ function getMessageCountSync( return count; } +async function getStoryCount(conversationId: string): Promise { + const db = getInstance(); + return db + .prepare( + ` + SELECT count(*) + FROM messages + WHERE conversationId = $conversationId AND isStory = 1; + ` + ) + .pluck() + .get({ conversationId }); +} + async function getMessageCount(conversationId?: string): Promise { return getMessageCountSync(conversationId); } @@ -2392,7 +2408,7 @@ function getOlderMessagesByConversationSync( async function getOlderStories({ conversationId, - limit = 10, + limit = 9999, receivedAt = Number.MAX_VALUE, sentAt, sourceUuid, @@ -2416,7 +2432,7 @@ async function getOlderStories({ (received_at < $receivedAt OR (received_at IS $receivedAt AND sent_at < $sentAt) ) - ORDER BY received_at DESC, sent_at DESC + ORDER BY received_at ASC, sent_at ASC LIMIT $limit; ` ) @@ -4157,6 +4173,21 @@ async function getLastStoryReadsForAuthor({ }); } +async function countStoryReadsByConversation( + conversationId: string +): Promise { + const db = getInstance(); + return db + .prepare( + ` + SELECT COUNT(storyId) FROM storyReads + WHERE conversationId = $conversationId; + ` + ) + .pluck() + .get({ conversationId }); +} + // All data in database async function removeAll(): Promise { const db = getInstance(); diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 1cacd2f96..58f705fbc 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -12,12 +12,17 @@ import type { StateType as RootStateType } from '../reducer'; import type { StoryViewType } from '../../components/StoryListItem'; import type { SyncType } from '../../jobs/helpers/syncHelpers'; import * as log from '../../logging/log'; +import dataInterface from '../../sql/Client'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { ToastReactionFailed } from '../../components/ToastReactionFailed'; +import { UUID } from '../../types/UUID'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { getMessageById } from '../../messages/getMessageById'; import { markViewed } from '../../services/MessageUpdater'; +import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; +import { replaceIndex } from '../../util/replaceIndex'; import { showToast } from '../../util/showToast'; +import { isDownloaded, isDownloading } from '../../types/Attachment'; import { useBoundActions } from '../../hooks/useBoundActions'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; @@ -40,13 +45,14 @@ export type StoriesStateType = { // Actions -const ADD_STORY = 'stories/ADD_STORY'; +const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const REACT_TO_STORY = 'stories/REACT_TO_STORY'; +const STORY_CHANGED = 'stories/STORY_CHANGED'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; -type AddStoryActionType = { - type: typeof ADD_STORY; - payload: StoryDataType; +type MarkStoryReadActionType = { + type: typeof MARK_STORY_READ; + payload: string; }; type ReactToStoryActionType = { @@ -57,38 +63,38 @@ type ReactToStoryActionType = { }; }; +type StoryChangedActionType = { + type: typeof STORY_CHANGED; + payload: StoryDataType; +}; + type ToggleViewActionType = { type: typeof TOGGLE_VIEW; }; export type StoriesActionType = - | AddStoryActionType + | MarkStoryReadActionType | MessageDeletedActionType | ReactToStoryActionType + | StoryChangedActionType | ToggleViewActionType; // Action Creators export const actions = { - addStory, markStoryRead, + queueStoryDownload, reactToStory, replyToStory, + storyChanged, toggleStoriesView, }; export const useStoriesActions = (): typeof actions => useBoundActions(actions); -function addStory(story: StoryDataType): AddStoryActionType { - return { - type: ADD_STORY, - payload: story, - }; -} - function markStoryRead( messageId: string -): ThunkAction { +): ThunkAction { return async (dispatch, getState) => { const { stories } = getState().stories; @@ -109,7 +115,9 @@ function markStoryRead( return; } - markViewed(message.attributes, Date.now()); + const storyReadDate = Date.now(); + + markViewed(message.attributes, storyReadDate); const viewedReceipt = { messageId, @@ -125,6 +133,55 @@ function markStoryRead( viewedReceiptsJobQueue.add({ viewedReceipt }); + await dataInterface.addNewStoryRead({ + authorId: message.attributes.sourceUuid, + conversationId: message.attributes.conversationId, + storyId: new UUID(messageId).toString(), + storyReadDate, + }); + + dispatch({ + type: MARK_STORY_READ, + payload: messageId, + }); + }; +} + +function queueStoryDownload( + storyId: string +): ThunkAction { + return async dispatch => { + const story = await getMessageById(storyId); + + if (!story) { + return; + } + + const storyAttributes: MessageAttributesType = story.attributes; + const { attachments } = storyAttributes; + const attachment = attachments && attachments[0]; + + if (!attachment) { + log.warn('queueStoryDownload: No attachment found for story', { + storyId, + }); + return; + } + + if (isDownloaded(attachment)) { + return; + } + + if (isDownloading(attachment)) { + return; + } + + // We want to ensure that we re-hydrate the story reply context with the + // completed attachment download. + story.set({ storyReplyContext: undefined }); + + await queueAttachmentDownloads(story.attributes); + dispatch({ type: 'NOOP', payload: null, @@ -188,6 +245,13 @@ function replyToStory( }; } +function storyChanged(story: StoryDataType): StoryChangedActionType { + return { + type: STORY_CHANGED, + payload: story, + }; +} + function toggleStoriesView(): ToggleViewActionType { return { type: TOGGLE_VIEW, @@ -226,7 +290,7 @@ export function reducer( }; } - if (action.type === ADD_STORY) { + if (action.type === STORY_CHANGED) { const newStory = pick(action.payload, [ 'attachment', 'conversationId', @@ -238,18 +302,35 @@ export function reducer( 'timestamp', ]); - // TODO DEKTOP-3179 - // ADD_STORY fires whenever the message model changes so we check if this - // story already exists in state -- if it does then we don't need to re-add. - const hasStory = state.stories.find( + // Stories don't really need to change except for when we don't have the + // attachment downloaded and we queue a download. Then the story's message + // will have the new attachment information. This is an optimization so + // we don't needlessly re-render. + const prevStory = state.stories.find( existingStory => existingStory.messageId === newStory.messageId ); - if (hasStory) { - return state; + if (prevStory) { + const shouldReplace = + (!isDownloaded(prevStory.attachment) && + isDownloaded(newStory.attachment)) || + isDownloading(newStory.attachment); + + if (!shouldReplace) { + return state; + } + + const storyIndex = state.stories.findIndex( + existingStory => existingStory.messageId === newStory.messageId + ); + + return { + ...state, + stories: replaceIndex(state.stories, storyIndex, newStory), + }; } - const stories = [newStory, ...state.stories].sort((a, b) => - a.timestamp > b.timestamp ? -1 : 1 + const stories = [...state.stories, newStory].sort((a, b) => + a.timestamp > b.timestamp ? 1 : -1 ); return { @@ -274,5 +355,21 @@ export function reducer( }; } + if (action.type === MARK_STORY_READ) { + return { + ...state, + stories: state.stories.map(story => { + if (story.messageId === action.payload) { + return { + ...story, + readStatus: ReadStatus.Viewed, + }; + } + + return story; + }), + }; + } + return state; } diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index abcf583f3..7a6660aa8 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -24,13 +24,22 @@ export const shouldShowStoriesView = createSelector( export const getStories = createSelector( getConversationSelector, getStoriesState, + shouldShowStoriesView, ( conversationSelector, - { stories }: Readonly + { stories }: Readonly, + isShowingStoriesView ): { hiddenStories: Array; stories: Array; } => { + if (!isShowingStoriesView) { + return { + hiddenStories: [], + stories: [], + }; + } + const storiesById = new Map(); const hiddenStoriesById = new Map(); @@ -90,9 +99,10 @@ export const getStories = createSelector( }); }); + // Reversing so that the story list is in DESC order return { - hiddenStories: Array.from(hiddenStoriesById.values()), - stories: Array.from(storiesById.values()), + hiddenStories: Array.from(hiddenStoriesById.values()).reverse(), + stories: Array.from(storiesById.values()).reverse(), }; } ); diff --git a/ts/test-electron/sql/stories_test.ts b/ts/test-electron/sql/stories_test.ts index 8120c54c6..f73e29171 100644 --- a/ts/test-electron/sql/stories_test.ts +++ b/ts/test-electron/sql/stories_test.ts @@ -93,15 +93,15 @@ describe('sql/stories', () => { }); assert.lengthOf(stories, 4, 'expect four total stories'); - // They are in DESC order + // They are in ASC order assert.strictEqual( stories[0].id, - story5.id, + story1.id, 'stories first should be story5' ); assert.strictEqual( stories[3].id, - story1.id, + story5.id, 'stories last should be story1' ); @@ -115,15 +115,15 @@ describe('sql/stories', () => { 'expect two stories in conversaton' ); - // They are in DESC order + // They are in ASC order assert.strictEqual( storiesInConversation[0].id, - story4.id, + story1.id, 'storiesInConversation first should be story4' ); assert.strictEqual( storiesInConversation[1].id, - story1.id, + story4.id, 'storiesInConversation last should be story1' ); @@ -133,15 +133,15 @@ describe('sql/stories', () => { }); assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author'); - // They are in DESC order + // They are in ASC order assert.strictEqual( storiesByAuthor[0].id, - story5.id, + story2.id, 'storiesByAuthor first should be story5' ); assert.strictEqual( storiesByAuthor[1].id, - story2.id, + story5.id, 'storiesByAuthor last should be story2' ); }); @@ -213,15 +213,15 @@ describe('sql/stories', () => { }); assert.lengthOf(stories, 2, 'expect two stories'); - // They are in DESC order + // They are in ASC order assert.strictEqual( stories[0].id, - story3.id, + story2.id, 'stories first should be story3' ); assert.strictEqual( stories[1].id, - story2.id, + story3.id, 'stories last should be story2' ); }); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 885dbda91..6595a8923 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -695,10 +695,18 @@ export function isGIF(attachments?: ReadonlyArray): boolean { return hasFlag && isVideoAttachment(attachment); } -export function hasNotDownloaded(attachment?: AttachmentType): boolean { +export function isDownloaded(attachment?: AttachmentType): boolean { + return Boolean(attachment && attachment.path); +} + +export function hasNotResolved(attachment?: AttachmentType): boolean { return Boolean(attachment && !attachment.url); } +export function isDownloading(attachment?: AttachmentType): boolean { + return Boolean(attachment && attachment.downloadJobId && attachment.pending); +} + export function hasVideoBlurHash(attachments?: Array): boolean { const firstAttachment = attachments ? attachments[0] : null; diff --git a/ts/util/shouldDownloadStory.ts b/ts/util/shouldDownloadStory.ts new file mode 100644 index 000000000..54ad4bc2b --- /dev/null +++ b/ts/util/shouldDownloadStory.ts @@ -0,0 +1,23 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationAttributesType } from '../model-types.d'; + +import dataInterface from '../sql/Client'; + +const MAX_NUM_STORIES_TO_PREFETCH = 5; + +export async function shouldDownloadStory( + conversation: ConversationAttributesType +): Promise { + if (!conversation.hasPostedStory) { + return true; + } + + const [storyReads, storyCounts] = await Promise.all([ + dataInterface.countStoryReadsByConversation(conversation.id), + dataInterface.getStoryCount(conversation.id), + ]); + + return storyReads > 0 && storyCounts <= MAX_NUM_STORIES_TO_PREFETCH; +}