Sending/Failed state for stories

This commit is contained in:
Josh Perez 2022-11-16 17:10:11 -05:00 committed by GitHub
parent 9bad2301fd
commit 220963c789
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 676 additions and 190 deletions

View file

@ -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"

View file

@ -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 {

View file

@ -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;
}
}
}

View file

@ -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'),
],
};
}

View file

@ -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 = ({
/>
</div>
{list.stories.map(story => (
<div className="MyStories__story" key={story.timestamp}>
<button
aria-label={i18n('MyStories__story')}
className="StoryListItem__button MyStories__story-button"
onClick={() =>
viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.MyStories,
})
}
type="button"
>
<div className="StoryListItem__previews">
<StoryImage
attachment={story.attachment}
firstName={i18n('you')}
i18n={i18n}
isMe
isThumbnail
label={i18n('MyStories__story')}
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
/>
</div>
<div className="MyStories__story__details">
{hasViewReceiptSetting
? i18n('icu:MyStories__views', {
views: story.views ?? 0,
})
: i18n('icu:MyStories__views-off')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="MyStories__story__timestamp"
timestamp={story.timestamp}
/>
</div>
</button>
{story.attachment &&
(story.attachment.path || story.attachment.data) && (
<button
aria-label={i18n('MyStories__download')}
className="MyStories__story__download"
onClick={() => {
onSave(story);
}}
type="button"
/>
)}
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'MyStories__icon--forward',
label: i18n('forward'),
onClick: () => {
onForward(story.messageId);
},
},
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => {
viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.MyStories,
viewTarget: StoryViewTargetType.Details,
});
},
},
{
icon: 'MyStories__icon--delete',
label: i18n('delete'),
onClick: () => {
setConfirmDeleteStory(story);
},
},
]}
moduleClassName="MyStories__story__more"
theme={Theme.Dark}
/>
</div>
<StorySent
hasViewReceiptSetting={hasViewReceiptSetting}
i18n={i18n}
key={story.messageId}
onForward={onForward}
onSave={onSave}
queueStoryDownload={queueStoryDownload}
retrySend={retrySend}
setConfirmDeleteStory={setConfirmDeleteStory}
story={story}
viewStory={viewStory}
/>
))}
</div>
))}
@ -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 (
<div className="MyStories__story" key={story.timestamp}>
{renderAlert()}
<button
aria-label={i18n('MyStories__story')}
className="StoryListItem__button MyStories__story-button"
onClick={() => {
if (
!wasManuallyRetried &&
(sendStatus === ResolvedSendStatus.Failed ||
sendStatus === ResolvedSendStatus.PartiallySent)
) {
setWasManuallyRetried(true);
retrySend(story.messageId);
return;
}
viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.MyStories,
});
}}
type="button"
>
<div className="StoryListItem__previews">
<StoryImage
attachment={story.attachment}
firstName={i18n('you')}
i18n={i18n}
isMe
isThumbnail
label={i18n('MyStories__story')}
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
/>
</div>
<div className="MyStories__story__details">
{sendStatus === ResolvedSendStatus.Sending &&
i18n('Stories__list--sending')}
{sendStatus === ResolvedSendStatus.Failed && (
<div className="MyStories__story__details__failed">
<div>
{i18n('Stories__list--send_failed')}
{!wasManuallyRetried && (
<div className="MyStories__story__details__failed__button">
{i18n('Stories__list--retry-send')}
</div>
)}
</div>
</div>
)}
{sendStatus === ResolvedSendStatus.PartiallySent && (
<div className="MyStories__story__details__failed">
<div>
{i18n('Stories__list--partially-sent')}
{!wasManuallyRetried && (
<div className="MyStories__story__details__failed__button">
{i18n('Stories__list--retry-send')}
</div>
)}
</div>
</div>
)}
{sendStatus === ResolvedSendStatus.Sent && (
<>
{hasViewReceiptSetting
? i18n('icu:MyStories__views', {
views: story.views ?? 0,
})
: i18n('icu:MyStories__views-off')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="MyStories__story__timestamp"
timestamp={story.timestamp}
/>
</>
)}
</div>
</button>
{story.attachment && (story.attachment.path || story.attachment.data) && (
<button
aria-label={i18n('MyStories__download')}
className="MyStories__story__download"
onClick={() => {
onSave(story);
}}
type="button"
/>
)}
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'MyStories__icon--forward',
label: i18n('forward'),
onClick: () => {
onForward(story.messageId);
},
},
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => {
viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.MyStories,
viewTarget: StoryViewTargetType.Details,
});
},
},
{
icon: 'MyStories__icon--delete',
label: i18n('delete'),
onClick: () => {
setConfirmDeleteStory(story);
},
},
]}
moduleClassName="MyStories__story__more"
theme={Theme.Dark}
/>
</div>
);
}

View file

@ -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<ReactFramework, PropsType> = 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;

View file

@ -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<MyStoryType>;
onAddStory: () => unknown;
onClick: () => unknown;
queueStoryDownload: (storyId: string) => unknown;
showToast: ShowToastActionCreatorType;
};
enum ResolvedSendStatus {
Failed,
Sending,
Sent,
}
function resolveSendStatus(
sendStates: Array<StorySendStateType>
): 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 = ({
<div className="StoryListItem__info--title StoryListItem__chevron">
{i18n('MyStories__list_item')}
</div>
{newStoryResolvedSendStatus === ResolvedSendStatus.Sending && (
{reducedSendStatus === ResolvedSendStatus.Sending && (
<span className="StoryListItem__info--sending">
{i18n('Stories__list--sending')}
</span>
)}
{newStoryResolvedSendStatus === ResolvedSendStatus.Failed && (
{reducedSendStatus === ResolvedSendStatus.Failed && (
<span className="StoryListItem__info--send_failed">
{i18n('Stories__list--send_failed')}
</span>
)}
{newStoryResolvedSendStatus === ResolvedSendStatus.Sent && (
{reducedSendStatus === ResolvedSendStatus.PartiallySent && (
<span className="StoryListItem__info--send_failed">
{i18n('Stories__list--partially-sent')}
</span>
)}
{reducedSendStatus === ResolvedSendStatus.Sent && (
<MessageTimestamp
i18n={i18n}
isRelativeTime

View file

@ -45,7 +45,7 @@ export default {
},
queueStoryDownload: { action: true },
renderStoryCreator: { action: true },
renderStoryViewer: { action: true },
retrySend: { action: true },
showConversation: { action: true },
showStoriesSettings: { action: true },
showToast: { action: true },

View file

@ -42,6 +42,7 @@ export type PropsType = {
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => 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 = ({
<div className="Stories__pane" style={{ width }}>
{isMyStories && myStories.length ? (
<MyStories
hasViewReceiptSetting={hasViewReceiptSetting}
i18n={i18n}
myStories={myStories}
onBack={() => setIsMyStories(false)}
@ -108,8 +111,8 @@ export const Stories = ({
onForward={onForwardStory}
onSave={onSaveStory}
queueStoryDownload={queueStoryDownload}
retrySend={retrySend}
viewStory={viewStory}
hasViewReceiptSetting={hasViewReceiptSetting}
/>
) : (
<StoriesPane

View file

@ -9,11 +9,7 @@ import type {
ConversationType,
ShowConversationType,
} from '../state/ducks/conversations';
import type {
ConversationStoryType,
MyStoryType,
StoryViewType,
} from '../types/Stories';
import type { ConversationStoryType, MyStoryType } from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
@ -59,10 +55,6 @@ function search(
.map(result => result.item);
}
function getNewestMyStory(story: MyStoryType): StoryViewType {
return story.stories[0];
}
export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
hiddenStories: Array<ConversationStoryType>;
@ -161,14 +153,9 @@ export const StoriesPane = ({
<div className="Stories__pane__list">
<>
<MyStoryButton
hasMultiple={
myStories.length ? myStories[0].stories.length > 1 : false
}
i18n={i18n}
me={me}
newestStory={
myStories.length ? getNewestMyStory(myStories[0]) : undefined
}
myStories={myStories}
onAddStory={onAddStory}
onClick={onMyStoriesClicked}
queueStoryDownload={queueStoryDownload}

View file

@ -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(

View file

@ -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<string>;
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<boolean>(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 (
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
<div className="StoryViewer">
{alertElement}
<div
className="StoryViewer__overlay"
style={{ background: getStoryBackground(attachment) }}
@ -753,7 +783,35 @@ export const StoryViewer = ({
))}
</div>
<div className="StoryViewer__actions">
{(canReply || isSent) && (
{sendStatus === ResolvedSendStatus.Failed && !wasManuallyRetried && (
<button
className="StoryViewer__actions__failed"
onClick={doRetrySend}
type="button"
>
{i18n('StoryViewer__failed')}
</button>
)}
{sendStatus === ResolvedSendStatus.PartiallySent &&
!wasManuallyRetried && (
<button
className="StoryViewer__actions__failed"
onClick={doRetrySend}
type="button"
>
{i18n('StoryViewer__partial-fail')}
</button>
)}
{sendStatus === ResolvedSendStatus.Sending && (
<div className="StoryViewer__sending">
<Spinner
moduleClassName="StoryViewer__sending__spinner"
svgSize="small"
/>
{i18n('StoryViewer__sending')}
</div>
)}
{sendStatus === ResolvedSendStatus.Sent && (canReply || isSent) && (
<button
className="StoryViewer__reply"
onClick={() =>

View file

@ -0,0 +1,52 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useEffect } from 'react';
import type { LocalizerType } from '../types/Util';
import { Alert } from '../components/Alert';
import { ResolvedSendStatus } from '../types/Stories';
import { usePrevious } from './usePrevious';
export function useRetryStorySend(
i18n: LocalizerType,
sendStatus: ResolvedSendStatus | undefined
): {
renderAlert: () => JSX.Element | null;
setWasManuallyRetried: (value: boolean) => unknown;
wasManuallyRetried: boolean;
} {
const [hasSendFailedAlert, setHasSendFailedAlert] = useState(false);
const [wasManuallyRetried, setWasManuallyRetried] = useState(false);
const previousSendStatus = usePrevious(sendStatus, sendStatus);
useEffect(() => {
if (!wasManuallyRetried) {
return;
}
if (previousSendStatus === sendStatus) {
return;
}
if (
sendStatus === ResolvedSendStatus.Failed ||
sendStatus === ResolvedSendStatus.PartiallySent
) {
setHasSendFailedAlert(true);
}
}, [previousSendStatus, sendStatus, wasManuallyRetried]);
function renderAlert(): JSX.Element | null {
return hasSendFailedAlert ? (
<Alert
body={i18n('Stories__failed-send')}
i18n={i18n}
onClose={() => setHasSendFailedAlert(false)}
/>
) : null;
}
return { renderAlert, setWasManuallyRetried, wasManuallyRetried };
}

View file

@ -488,6 +488,10 @@ export async function sendStory(
return acc;
}
if (isMe(recipient.attributes)) {
return acc;
}
if (recipient.isUnregistered()) {
if (!isSent(oldSendState.status)) {
// We should have filtered this out on initial send, but we'll drop them from

View file

@ -1415,8 +1415,29 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!;
const currentConversationRecipients =
conversation.getMemberConversationIds();
let currentConversationRecipients: Set<string> | undefined;
const { storyDistributionListId } = this.attributes;
if (storyDistributionListId) {
const storyDistribution =
await dataInterface.getStoryDistributionWithMembers(
storyDistributionListId
);
if (!storyDistribution) {
this.markFailed();
return;
}
currentConversationRecipients = new Set(
storyDistribution.members
.map(uuid => window.ConversationController.get(uuid)?.id)
.filter(isNotNil)
);
} else {
currentConversationRecipients = conversation.getMemberConversationIds();
}
// Determine retry recipients and get their most up-to-date addressing information
const oldSendStateByConversationId =

View file

@ -22,7 +22,11 @@ import type {
StoriesStateType,
AddStoryData,
} from '../ducks/stories';
import { HasStories, MY_STORY_ID } from '../../types/Stories';
import {
HasStories,
MY_STORY_ID,
ResolvedSendStatus,
} from '../../types/Stories';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState';
import { canReply } from './message';
@ -39,6 +43,10 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { getMessageIdForLogging } from '../../util/idForLogging';
import * as log from '../../logging/log';
import { SIGNAL_ACI } from '../../types/SignalConversation';
import {
reduceStorySendStatus,
resolveStorySendStatus,
} from '../../util/resolveStorySendStatus';
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
@ -406,12 +414,23 @@ export const getStories = createSelector(
story
);
const existingMyStory = myStoriesById.get(sentId) || { stories: [] };
const existingStorySent = myStoriesById.get(sentId) || {
// Default to "Sent" since it's the lowest form of SendStatus and all
// others take precedence over it.
reducedSendStatus: ResolvedSendStatus.Sent,
stories: [],
};
const sendStatus = resolveStorySendStatus(storyView.sendState ?? []);
myStoriesById.set(sentId, {
id: sentId,
name: sentName,
stories: [storyView, ...existingMyStory.stories],
reducedSendStatus: reduceStorySendStatus(
existingStorySent.reducedSendStatus,
sendStatus
),
stories: [storyView, ...existingStorySent.stories],
});
// If it's a group story we still want it to render as part of regular

View file

@ -21,6 +21,7 @@ import {
getStories,
shouldShowStoriesView,
} from '../selectors/stories';
import { retryMessageSend } from '../../util/retryMessageSend';
import { saveAttachment } from '../../util/saveAttachment';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
@ -84,6 +85,7 @@ export function SmartStories(): JSX.Element | null {
}}
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryCreator={renderStoryCreator}
retrySend={retryMessageSend}
showConversation={showConversation}
showStoriesSettings={showStoriesSettings}
showToast={showToast}

View file

@ -27,6 +27,7 @@ import {
import { isInFullScreenCall } from '../selectors/calling';
import { isSignalConversation } from '../../util/isSignalConversation';
import { renderEmojiPicker } from './renderEmojiPicker';
import { retryMessageSend } from '../../util/retryMessageSend';
import { strictAssert } from '../../util/assert';
import { useActions as useEmojisActions } from '../ducks/emojis';
import { useConversationsActions } from '../ducks/conversations';
@ -99,6 +100,7 @@ export function SmartStoryViewer(): JSX.Element | null {
isSignalConversation={isSignalConversation({
id: conversationStory.conversationId,
})}
isWindowActive={isWindowActive}
numStories={selectedStoryData.numStories}
onHideStory={toggleHideStories}
onGoToConversation={senderId => {
@ -125,12 +127,12 @@ export function SmartStoryViewer(): JSX.Element | null {
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
replyState={replyState}
viewTarget={selectedStoryData.viewTarget}
retrySend={retryMessageSend}
showToast={showToast}
skinTone={skinTone}
story={storyView}
storyViewMode={selectedStoryData.storyViewMode}
isWindowActive={isWindowActive}
viewTarget={selectedStoryData.viewTarget}
{...storiesActions}
/>
);

View file

@ -14,12 +14,13 @@ import * as durations from '../../util/durations';
import { UUID } from '../../types/UUID';
import { getDefaultConversation } from './getDefaultConversation';
import { fakeAttachment, fakeThumbnail } from './fakeAttachment';
import { MY_STORY_ID } from '../../types/Stories';
import { MY_STORY_ID, ResolvedSendStatus } from '../../types/Stories';
function getAttachmentWithThumbnail(url: string): AttachmentType {
return fakeAttachment({
url,
path: url,
thumbnail: fakeThumbnail(url),
url,
});
}
@ -29,6 +30,7 @@ export function getFakeMyStory(id?: string, name?: string): MyStoryType {
return {
id: id || UUID.generate().toString(),
name: name || id === MY_STORY_ID ? 'My Stories' : casual.catch_phrase,
reducedSendStatus: ResolvedSendStatus.Sent,
stories: Array.from(Array(storyCount), () => ({
...getFakeStoryView(),
sendState: [],

View file

@ -102,6 +102,7 @@ export type StoryViewType = {
export type MyStoryType = {
id: string;
name: string;
reducedSendStatus: ResolvedSendStatus;
stories: Array<StoryViewType>;
};
@ -158,3 +159,10 @@ export enum StorySendMode {
Always = 'Always',
Never = 'Never',
}
export enum ResolvedSendStatus {
Failed = 'Failed',
PartiallySent = 'PartiallySent',
Sending = 'Sending',
Sent = 'Sent',
}

View file

@ -0,0 +1,81 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { StorySendStateType } from '../types/Stories';
import { ResolvedSendStatus } from '../types/Stories';
import { isFailed, isPending, isSent } from '../messages/MessageSendState';
import { softAssert } from './assert';
export function resolveStorySendStatus(
sendStates: Array<StorySendStateType>
): ResolvedSendStatus {
let anyPending = false;
let anySent = false;
let anyFailed = false;
sendStates.forEach(({ status }) => {
if (isPending(status)) {
anyPending = true;
}
if (isSent(status)) {
anySent = true;
}
if (isFailed(status)) {
anyFailed = true;
}
});
if (anyPending) {
return ResolvedSendStatus.Sending;
}
if (anyFailed && anySent) {
return ResolvedSendStatus.PartiallySent;
}
if (!anyFailed && anySent) {
return ResolvedSendStatus.Sent;
}
if (anyFailed && !anySent) {
return ResolvedSendStatus.Failed;
}
// Shouldn't get to this case but if none have been sent and none have failed
// then let's assume that we've sent.
softAssert(
anySent && sendStates.length,
'resolveStorySendStatus no sends, no failures, nothing pending?'
);
return ResolvedSendStatus.Sent;
}
export function reduceStorySendStatus(
currentSendStatus: ResolvedSendStatus,
nextSendStatus: ResolvedSendStatus
): ResolvedSendStatus {
if (
currentSendStatus === ResolvedSendStatus.Sending ||
nextSendStatus === ResolvedSendStatus.Sending
) {
return ResolvedSendStatus.Sending;
}
if (
currentSendStatus === ResolvedSendStatus.Failed ||
nextSendStatus === ResolvedSendStatus.Failed
) {
return ResolvedSendStatus.Failed;
}
if (
currentSendStatus === ResolvedSendStatus.PartiallySent ||
nextSendStatus === ResolvedSendStatus.PartiallySent
) {
return ResolvedSendStatus.PartiallySent;
}
return ResolvedSendStatus.Sent;
}

View file

@ -1,8 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getMessageById } from '../messages/getMessageById';
export async function retryMessageSend(messageId: string): Promise<void> {
const message = window.MessageController.getById(messageId);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`retryMessageSend: Message ${messageId} missing!`);
}