From 220963c7895e3ba3a5ba3f05c807f1e1c645cc5f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Wed, 16 Nov 2022 17:10:11 -0500 Subject: [PATCH] Sending/Failed state for stories --- _locales/en/messages.json | 24 +++ stylesheets/components/MyStories.scss | 23 +++ stylesheets/components/StoryViewer.scss | 30 +++ ts/components/MyStories.stories.tsx | 30 ++- ts/components/MyStories.tsx | 262 ++++++++++++++++-------- ts/components/MyStoryButton.stories.tsx | 80 +++++--- ts/components/MyStoryButton.tsx | 68 +++--- ts/components/Stories.stories.tsx | 2 +- ts/components/Stories.tsx | 5 +- ts/components/StoriesPane.tsx | 17 +- ts/components/StoryViewer.stories.tsx | 30 +++ ts/components/StoryViewer.tsx | 80 +++++++- ts/hooks/useRetryStorySend.tsx | 52 +++++ ts/jobs/helpers/sendStory.ts | 4 + ts/models/messages.ts | 25 ++- ts/state/selectors/stories.ts | 25 ++- ts/state/smart/Stories.tsx | 2 + ts/state/smart/StoryViewer.tsx | 6 +- ts/test-both/helpers/getFakeStory.tsx | 6 +- ts/types/Stories.ts | 8 + ts/util/resolveStorySendStatus.ts | 81 ++++++++ ts/util/retryMessageSend.ts | 6 +- 22 files changed, 676 insertions(+), 190 deletions(-) create mode 100644 ts/hooks/useRetryStorySend.tsx create mode 100644 ts/util/resolveStorySendStatus.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 21fb652da78..3b3f4551c44 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5543,6 +5543,14 @@ "message": "Send failed", "description": "Error text for story failed to send in list view" }, + "Stories__list--partially-sent": { + "message": "Partially sent", + "description": "Error text for story failed partially to send" + }, + "Stories__list--retry-send": { + "message": "Click to retry", + "description": "Actionable link to retry a send" + }, "Stories__placeholder--text": { "message": "Click to view a story", "description": "Placeholder label for the story view" @@ -5563,6 +5571,10 @@ "message": "This story has no sound", "description": "Toast message" }, + "Stories__failed-send": { + "message": "This story could not be sent to some people. Check your connection and try again.", + "description": "Alert error message when unable to send a story" + }, "StoriesSettings__title": { "message": "Story privacy", "description": "Title for the story settings modal" @@ -5867,6 +5879,18 @@ "message": "Views off", "description": "(deleted 2022/10/13) When the user has read receipts turned off" }, + "StoryViewer__sending": { + "message": "Sending...", + "description": "Label for when a story is sending" + }, + "StoryViewer__failed": { + "message": "Send failed. Click to retry", + "description": "Label for when a send failed" + }, + "StoryViewer__partial-fail": { + "message": "Partially sent. Click to retry", + "description": "Label for when a send partially failed" + }, "StoryDetailsModal__sent-time": { "message": "Sent $time$", "description": "Sent timestamp" diff --git a/stylesheets/components/MyStories.scss b/stylesheets/components/MyStories.scss index 39b195fe3b2..8653120024f 100644 --- a/stylesheets/components/MyStories.scss +++ b/stylesheets/components/MyStories.scss @@ -37,6 +37,29 @@ flex-direction: column; flex: 1; margin-left: 12px; + + &__failed { + align-items: center; + display: flex; + + &::before { + content: ''; + display: block; + height: 12px; + margin-right: 12px; + width: 12px; + @include color-svg( + '../images/icons/v2/error-outline-24.svg', + $color-accent-red + ); + } + + &__button { + @include button-reset; + @include font-subtitle; + color: $color-gray-25; + } + } } &__timestamp { diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss index 51ff0c4b1ff..c0342c312c1 100644 --- a/stylesheets/components/StoryViewer.scss +++ b/stylesheets/components/StoryViewer.scss @@ -141,6 +141,26 @@ display: flex; justify-content: center; min-height: 60px; + + &__failed { + @include button-reset; + @include font-body-1; + align-items: center; + color: $color-white; + display: flex; + + &::before { + content: ''; + display: block; + height: 18px; + margin-right: 12px; + width: 18px; + @include color-svg( + '../images/icons/v2/error-outline-24.svg', + $color-accent-red + ); + } + } } &__reply { @@ -358,4 +378,14 @@ height: 256px; } } + + &__sending { + align-items: center; + display: flex; + + &__spinner__container { + margin-left: 0; + margin-right: 12px; + } + } } diff --git a/ts/components/MyStories.stories.tsx b/ts/components/MyStories.stories.tsx index e2d3dc8123f..36945a4bb56 100644 --- a/ts/components/MyStories.stories.tsx +++ b/ts/components/MyStories.stories.tsx @@ -12,6 +12,7 @@ import type { PropsType } from './MyStories'; import enMessages from '../../_locales/en/messages.json'; import { MY_STORY_ID } from '../types/Stories'; import { MyStories } from './MyStories'; +import { SendStatus } from '../messages/MessageSendState'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getFakeMyStory } from '../test-both/helpers/getFakeStory'; import { setupI18n } from '../util/setupI18n'; @@ -48,7 +49,7 @@ export default { queueStoryDownload: { action: true, }, - renderStoryViewer: { + retrySend: { action: true, }, viewStory: { action: true }, @@ -108,3 +109,30 @@ MultiListStories.play = interactionTest; MultiListStories.story = { name: 'Multiple distribution lists', }; + +export const FailedSentStory = Template.bind({}); +{ + const myStory = getFakeMyStory(MY_STORY_ID); + FailedSentStory.args = { + myStories: [ + { + ...myStory, + stories: myStory.stories.map((story, index) => { + if (index === 0) { + return { + ...story, + sendState: [ + { + recipient: getDefaultConversation(), + status: SendStatus.Failed, + }, + ], + }; + } + return story; + }), + }, + getFakeMyStory(uuid(), 'Cool Peeps'), + ], + }; +} diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index afa10a3f0cb..7d42cf6bcf5 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -3,16 +3,21 @@ import React, { useState } from 'react'; import type { MyStoryType, StoryViewType } from '../types/Stories'; -import { StoryViewTargetType, StoryViewModeType } from '../types/Stories'; +import { + ResolvedSendStatus, + StoryViewTargetType, + StoryViewModeType, +} from '../types/Stories'; import type { LocalizerType } from '../types/Util'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; - import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryDistributionListName } from './StoryDistributionListName'; import { StoryImage } from './StoryImage'; import { Theme } from '../util/theme'; +import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; +import { useRetryStorySend } from '../hooks/useRetryStorySend'; export type PropsType = { i18n: LocalizerType; @@ -22,6 +27,7 @@ export type PropsType = { onForward: (storyId: string) => unknown; onSave: (story: StoryViewType) => unknown; queueStoryDownload: (storyId: string) => unknown; + retrySend: (messageId: string) => unknown; viewStory: ViewStoryActionCreatorType; hasViewReceiptSetting: boolean; }; @@ -34,6 +40,7 @@ export const MyStories = ({ onForward, onSave, queueStoryDownload, + retrySend, viewStory, hasViewReceiptSetting, }: PropsType): JSX.Element => { @@ -81,89 +88,18 @@ export const MyStories = ({ /> {list.stories.map(story => ( -
- - {story.attachment && - (story.attachment.path || story.attachment.data) && ( -
+ ))} ))} @@ -176,3 +112,159 @@ export const MyStories = ({ ); }; + +type StorySentPropsType = Pick< + PropsType, + | 'hasViewReceiptSetting' + | 'i18n' + | 'onForward' + | 'onSave' + | 'queueStoryDownload' + | 'retrySend' + | 'viewStory' +> & { + setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown; + story: StoryViewType; +}; + +function StorySent({ + hasViewReceiptSetting, + i18n, + onForward, + onSave, + queueStoryDownload, + retrySend, + setConfirmDeleteStory, + story, + viewStory, +}: StorySentPropsType): JSX.Element { + const sendStatus = resolveStorySendStatus(story.sendState ?? []); + const { renderAlert, setWasManuallyRetried, wasManuallyRetried } = + useRetryStorySend(i18n, sendStatus); + + return ( +
+ {renderAlert()} + + {story.attachment && (story.attachment.path || story.attachment.data) && ( +
+ ); +} diff --git a/ts/components/MyStoryButton.stories.tsx b/ts/components/MyStoryButton.stories.tsx index bdbad995e32..d18c4110d26 100644 --- a/ts/components/MyStoryButton.stories.tsx +++ b/ts/components/MyStoryButton.stories.tsx @@ -11,9 +11,10 @@ import type { PropsType } from './MyStoryButton'; import enMessages from '../../_locales/en/messages.json'; import { MyStoryButton } from './MyStoryButton'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; -import { getFakeStoryView } from '../test-both/helpers/getFakeStory'; +import { getFakeMyStory } from '../test-both/helpers/getFakeStory'; import { setupI18n } from '../util/setupI18n'; import { SendStatus } from '../messages/MessageSendState'; +import { ResolvedSendStatus } from '../types/Stories'; const i18n = setupI18n('en', enMessages); @@ -21,18 +22,14 @@ export default { title: 'Components/MyStoriesButton', component: MyStoryButton, argTypes: { - hasMultiple: { - control: 'checkbox', - defaultValue: false, - }, i18n: { defaultValue: i18n, }, me: { defaultValue: getDefaultConversation(), }, - newestStory: { - defaultValue: getFakeStoryView(), + myStories: { + defaultValue: [getFakeMyStory()], }, onAddStory: { action: true }, onClick: { action: true }, @@ -60,8 +57,7 @@ const interactionTest: PlayFunction = async ({ export const NoStory = Template.bind({}); NoStory.args = { - hasMultiple: false, - newestStory: undefined, + myStories: [], }; NoStory.story = { name: 'No Story', @@ -77,7 +73,7 @@ OneStory.play = interactionTest; export const ManyStories = Template.bind({}); ManyStories.args = { - hasMultiple: true, + myStories: [getFakeMyStory(), getFakeMyStory()], }; ManyStories.story = { name: 'Many Stories', @@ -88,32 +84,64 @@ export const SendingStory = Template.bind({}); SendingStory.story = { name: 'Sending Story', }; -SendingStory.args = { - newestStory: { - ...getFakeStoryView(), - sendState: [ +{ + const myStory = getFakeMyStory(); + SendingStory.args = { + myStories: [ { - status: SendStatus.Pending, - recipient: getDefaultConversation(), + ...myStory, + reducedSendStatus: ResolvedSendStatus.Sending, + stories: myStory.stories.map((story, index) => { + if (index === 0) { + return { + ...story, + sendState: [ + { + status: SendStatus.Pending, + recipient: getDefaultConversation(), + }, + ], + }; + } + + return story; + }), }, + getFakeMyStory(), ], - }, -}; + }; +} SendingStory.play = interactionTest; export const FailedSendStory = Template.bind({}); FailedSendStory.story = { name: 'Failed Send Story', }; -FailedSendStory.args = { - newestStory: { - ...getFakeStoryView(), - sendState: [ +{ + const myStory = getFakeMyStory(); + FailedSendStory.args = { + myStories: [ { - status: SendStatus.Failed, - recipient: getDefaultConversation(), + ...myStory, + reducedSendStatus: ResolvedSendStatus.Failed, + stories: myStory.stories.map((story, index) => { + if (index === 0) { + return { + ...story, + sendState: [ + { + status: SendStatus.Failed, + recipient: getDefaultConversation(), + }, + ], + }; + } + + return story; + }), }, + getFakeMyStory(), ], - }, -}; + }; +} FailedSendStory.play = interactionTest; diff --git a/ts/components/MyStoryButton.tsx b/ts/components/MyStoryButton.tsx index b3273c54be5..2858b5449b1 100644 --- a/ts/components/MyStoryButton.tsx +++ b/ts/components/MyStoryButton.tsx @@ -5,59 +5,34 @@ import React, { useState } from 'react'; import classNames from 'classnames'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; +import type { MyStoryType, StoryViewType } from '../types/Stories'; import type { ShowToastActionCreatorType } from '../state/ducks/toast'; -import type { StorySendStateType, StoryViewType } from '../types/Stories'; import { Avatar, AvatarSize } from './Avatar'; -import { HasStories } from '../types/Stories'; +import { HasStories, ResolvedSendStatus } from '../types/Stories'; +import { MessageTimestamp } from './conversation/MessageTimestamp'; +import { StoriesAddStoryButton } from './StoriesAddStoryButton'; import { StoryImage } from './StoryImage'; import { getAvatarColor } from '../types/Colors'; -import { MessageTimestamp } from './conversation/MessageTimestamp'; - -import { StoriesAddStoryButton } from './StoriesAddStoryButton'; -import { isFailed, isPending } from '../messages/MessageSendState'; +import { reduceStorySendStatus } from '../util/resolveStorySendStatus'; export type PropsType = { - hasMultiple: boolean; i18n: LocalizerType; me: ConversationType; - newestStory?: StoryViewType; + myStories: Array; onAddStory: () => unknown; onClick: () => unknown; queueStoryDownload: (storyId: string) => unknown; showToast: ShowToastActionCreatorType; }; -enum ResolvedSendStatus { - Failed, - Sending, - Sent, -} - -function resolveSendStatus( - sendStates: Array -): ResolvedSendStatus { - let anyPending = false; - for (const sendState of sendStates) { - if (isFailed(sendState.status)) { - // Immediately return if any send failed - return ResolvedSendStatus.Failed; - } - if (isPending(sendState.status)) { - // Don't return yet in case we have a failure - anyPending = true; - } - } - if (anyPending) { - return ResolvedSendStatus.Sending; - } - return ResolvedSendStatus.Sent; +function getNewestMyStory(story: MyStoryType): StoryViewType { + return story.stories[0]; } export const MyStoryButton = ({ - hasMultiple, i18n, me, - newestStory, + myStories, onAddStory, onClick, queueStoryDownload, @@ -65,6 +40,10 @@ export const MyStoryButton = ({ }: PropsType): JSX.Element => { const [active, setActive] = useState(false); + const newestStory = myStories.length + ? getNewestMyStory(myStories[0]) + : undefined; + const { acceptedMessageRequest, avatarPath, @@ -113,8 +92,14 @@ export const MyStoryButton = ({ ); } - const newStoryResolvedSendStatus = resolveSendStatus( - newestStory.sendState ?? [] + const hasMultiple = myStories.length + ? myStories[0].stories.length > 1 + : false; + + const reducedSendStatus: ResolvedSendStatus = myStories.reduce( + (acc: ResolvedSendStatus, myStory) => + reduceStorySendStatus(acc, myStory.reducedSendStatus), + ResolvedSendStatus.Sent ); return ( @@ -166,17 +151,22 @@ export const MyStoryButton = ({
{i18n('MyStories__list_item')}
- {newStoryResolvedSendStatus === ResolvedSendStatus.Sending && ( + {reducedSendStatus === ResolvedSendStatus.Sending && ( {i18n('Stories__list--sending')} )} - {newStoryResolvedSendStatus === ResolvedSendStatus.Failed && ( + {reducedSendStatus === ResolvedSendStatus.Failed && ( {i18n('Stories__list--send_failed')} )} - {newStoryResolvedSendStatus === ResolvedSendStatus.Sent && ( + {reducedSendStatus === ResolvedSendStatus.PartiallySent && ( + + {i18n('Stories__list--partially-sent')} + + )} + {reducedSendStatus === ResolvedSendStatus.Sent && ( unknown; renderStoryCreator: () => JSX.Element; + retrySend: (messageId: string) => unknown; setAddStoryData: (data: AddStoryData) => unknown; showConversation: ShowConversationType; showStoriesSettings: () => unknown; @@ -69,6 +70,7 @@ export const Stories = ({ preferredWidthFromStorage, queueStoryDownload, renderStoryCreator, + retrySend, setAddStoryData, showConversation, showStoriesSettings, @@ -101,6 +103,7 @@ export const Stories = ({
{isMyStories && myStories.length ? ( setIsMyStories(false)} @@ -108,8 +111,8 @@ export const Stories = ({ onForward={onForwardStory} onSave={onSaveStory} queueStoryDownload={queueStoryDownload} + retrySend={retrySend} viewStory={viewStory} - hasViewReceiptSetting={hasViewReceiptSetting} /> ) : ( result.item); } -function getNewestMyStory(story: MyStoryType): StoryViewType { - return story.stories[0]; -} - export type PropsType = { getPreferredBadge: PreferredBadgeSelectorType; hiddenStories: Array; @@ -161,14 +153,9 @@ export const StoriesPane = ({
<> 1 : false - } i18n={i18n} me={me} - newestStory={ - myStories.length ? getNewestMyStory(myStories[0]) : undefined - } + myStories={myStories} onAddStory={onAddStory} onClick={onMyStoriesClicked} queueStoryDownload={queueStoryDownload} diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 11b598b307b..3fad0c0cef2 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -56,6 +56,7 @@ export default { }, queueStoryDownload: { action: true }, renderEmojiPicker: { action: true }, + retrySend: { action: true }, showToast: { action: true }, skinTone: { defaultValue: 0, @@ -191,6 +192,35 @@ export const YourStory = Template.bind({}); YourStory.storyName = 'Your story'; } +export const YourStoryFailed = Template.bind({}); +{ + const storyView = getFakeStoryView( + '/fixtures/nathan-anderson-316188-unsplash.jpg' + ); + + YourStoryFailed.args = { + distributionList: { id: '123', name: 'Close Friends' }, + story: { + ...storyView, + sender: { + ...storyView.sender, + isMe: true, + }, + sendState: [ + { + recipient: getDefaultConversation(), + status: SendStatus.Viewed, + }, + { + recipient: getDefaultConversation(), + status: SendStatus.Failed, + }, + ], + }, + }; + YourStory.storyName = 'Your story'; +} + export const ReadReceiptsOff = Template.bind({}); { const storyView = getFakeStoryView( diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 7862d5bb63a..8d9726cd0f3 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -28,10 +28,12 @@ import { Emojify } from './conversation/Emojify'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { SendStatus } from '../messages/MessageSendState'; +import { Spinner } from './Spinner'; import { StoryDetailsModal } from './StoryDetailsModal'; import { StoryDistributionListName } from './StoryDistributionListName'; import { StoryImage } from './StoryImage'; import { + ResolvedSendStatus, StoryViewDirectionType, StoryViewModeType, StoryViewTargetType, @@ -45,10 +47,14 @@ import { getStoryDuration } from '../util/getStoryDuration'; import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import { isVideoAttachment } from '../types/Attachment'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; +import { useRetryStorySend } from '../hooks/useRetryStorySend'; +import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { strictAssert } from '../util/assert'; export type PropsType = { currentIndex: number; + deleteGroupStoryReply: (id: string) => void; + deleteGroupStoryReplyForEveryone: (id: string) => void; deleteStoryForEveryone: (story: StoryViewType) => unknown; distributionList?: { id: string; name: string }; getPreferredBadge: PreferredBadgeSelectorType; @@ -70,6 +76,7 @@ export type PropsType = { hasViewReceiptSetting: boolean; i18n: LocalizerType; isSignalConversation?: boolean; + isWindowActive: boolean; loadStoryReplies: (conversationId: string, messageId: string) => unknown; markStoryRead: (mId: string) => unknown; numStories: number; @@ -90,16 +97,14 @@ export type PropsType = { recentEmojis?: Array; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replyState?: ReplyStateType; - viewTarget?: StoryViewTargetType; + retrySend: (messageId: string) => unknown; + setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown; showToast: ShowToastActionCreatorType; skinTone?: number; story: StoryViewType; storyViewMode: StoryViewModeType; - setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown; viewStory: ViewStoryActionCreatorType; - isWindowActive: boolean; - deleteGroupStoryReply: (id: string) => void; - deleteGroupStoryReplyForEveryone: (id: string) => void; + viewTarget?: StoryViewTargetType; }; const CAPTION_BUFFER = 20; @@ -115,6 +120,8 @@ enum Arrow { export const StoryViewer = ({ currentIndex, + deleteGroupStoryReply, + deleteGroupStoryReplyForEveryone, deleteStoryForEveryone, distributionList, getPreferredBadge, @@ -124,6 +131,7 @@ export const StoryViewer = ({ hasViewReceiptSetting, i18n, isSignalConversation, + isWindowActive, loadStoryReplies, markStoryRead, numStories, @@ -139,16 +147,14 @@ export const StoryViewer = ({ recentEmojis, renderEmojiPicker, replyState, - viewTarget, + retrySend, + setHasAllStoriesUnmuted, showToast, skinTone, story, storyViewMode, - setHasAllStoriesUnmuted, viewStory, - isWindowActive, - deleteGroupStoryReply, - deleteGroupStoryReplyForEveryone, + viewTarget, }: PropsType): JSX.Element => { const [isShowingContextMenu, setIsShowingContextMenu] = useState(false); @@ -181,6 +187,10 @@ export const StoryViewer = ({ const conversationId = group?.id || story.sender.id; + const sendStatus = sendState ? resolveStorySendStatus(sendState) : undefined; + const { renderAlert, setWasManuallyRetried, wasManuallyRetried } = + useRetryStorySend(i18n, sendStatus); + const [currentViewTarget, setCurrentViewTarget] = useState( viewTarget ?? null ); @@ -316,7 +326,10 @@ export const StoryViewer = ({ } }, [isWindowActive]); + const alertElement = renderAlert(); + const shouldPauseViewing = + Boolean(alertElement) || Boolean(confirmDeleteStory) || currentViewTarget != null || hasActiveCall || @@ -528,9 +541,26 @@ export const StoryViewer = ({ ]; } + function doRetrySend() { + if (wasManuallyRetried) { + return; + } + + if ( + sendStatus !== ResolvedSendStatus.Failed && + sendStatus !== ResolvedSendStatus.PartiallySent + ) { + return; + } + + setWasManuallyRetried(true); + retrySend(messageId); + } + return (
+ {alertElement}
- {(canReply || isSent) && ( + {sendStatus === ResolvedSendStatus.Failed && !wasManuallyRetried && ( + + )} + {sendStatus === ResolvedSendStatus.PartiallySent && + !wasManuallyRetried && ( + + )} + {sendStatus === ResolvedSendStatus.Sending && ( +
+ + {i18n('StoryViewer__sending')} +
+ )} + {sendStatus === ResolvedSendStatus.Sent && (canReply || isSent) && (