diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 96ddc8d30..2e377a37f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7315,6 +7315,10 @@ "message": "No longer available", "description": "Label for when a story is not found" }, + "ContextMenu--button": { + "message": "Context menu", + "description": "Default aria-label for the context menu buttons" + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 8b5b34887..bd1ab0b2f 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -335,6 +335,7 @@ message StoryMessage { AttachmentPointer fileAttachment = 3; TextAttachment textAttachment = 4; } + optional bool allowsReplies = 5; } message TextAttachment { @@ -386,6 +387,12 @@ message SyncMessage { optional bool unidentified = 2; } + message StoryMessageRecipient { + optional string destinationUuid = 1; + repeated string distributionListIds = 2; + optional bool isAllowedToReply = 3; + } + optional string destination = 1; optional string destinationUuid = 7; optional uint64 timestamp = 2; @@ -393,6 +400,8 @@ message SyncMessage { optional uint64 expirationStartTimestamp = 4; repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5; optional bool isRecipientUpdate = 6 [default = false]; + optional StoryMessage storyMessage = 8; + repeated StoryMessageRecipient storyMessageRecipients = 9; } message Contacts { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 122cb7bc4..205a1476e 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -39,6 +39,7 @@ message ManifestRecord { GROUPV1 = 2; GROUPV2 = 3; ACCOUNT = 4; + STORY_DISTRIBUTION_LIST = 5; } optional bytes raw = 1; @@ -57,6 +58,7 @@ message StorageRecord { GroupV1Record groupV1 = 2; GroupV2Record groupV2 = 3; AccountRecord account = 4; + StoryDistributionListRecord storyDistributionList = 5; } } @@ -147,3 +149,12 @@ message AccountRecord { optional bool displayBadgesOnProfile = 23; optional bool keepMutedChatsArchived = 25; } + +message StoryDistributionListRecord { + optional bytes identifier = 1; + optional string name = 2; + repeated string recipientUuids = 3; + optional uint64 deletedAtTimestamp = 4; + optional bool allowsReplies = 5; + optional bool isBlockList = 6; +} diff --git a/stylesheets/components/ContextMenu.scss b/stylesheets/components/ContextMenu.scss index 7dbbb8947..6d7fdc5fa 100644 --- a/stylesheets/components/ContextMenu.scss +++ b/stylesheets/components/ContextMenu.scss @@ -73,7 +73,8 @@ border-radius: 6px; display: flex; justify-content: space-between; - padding: 6px 8px; + padding: 6px; + margin: 0 2px; min-width: 150px; &--container { diff --git a/stylesheets/components/MyStories.scss b/stylesheets/components/MyStories.scss index 3b8696c98..0a7b74c4f 100644 --- a/stylesheets/components/MyStories.scss +++ b/stylesheets/components/MyStories.scss @@ -25,15 +25,7 @@ &__preview { @include button-reset; - - align-items: center; - background-color: $color-gray-60; - background-size: cover; - border-radius: 8px; - height: 72px; margin-right: 12px; - overflow: hidden; - width: 46px; } &__timestamp { diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index 19e01977b..649ca5295 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -164,4 +164,8 @@ } } } + + &__my-stories { + padding: 0 10px; + } } diff --git a/ts/background.ts b/ts/background.ts index 8fe15f910..b6846f5cb 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -44,6 +44,10 @@ import { IdleDetector } from './IdleDetector'; import { expiringMessagesDeletionService } from './services/expiringMessagesDeletion'; import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService'; import { getStoriesForRedux, loadStories } from './services/storyLoader'; +import { + getDistributionListsForRedux, + loadDistributionLists, +} from './services/distributionListLoader'; import { senderCertificateService } from './services/senderCertificate'; import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher'; import * as KeyboardLayout from './services/keyboardLayout'; @@ -977,6 +981,7 @@ export async function startApp(): Promise { loadRecentEmojis(), loadInitialBadgesState(), loadStories(), + loadDistributionLists(), window.textsecure.storage.protocol.hydrateCaches(), (async () => { mainWindowStats = await window.SignalContext.getMainWindowStats(); @@ -1021,9 +1026,10 @@ export async function startApp(): Promise { const convoCollection = window.getConversations(); const initialState = getInitialState({ badges: initialBadgesState, - stories: getStoriesForRedux(), mainWindowStats, menuOptions, + stories: getStoriesForRedux(), + storyDistributionLists: getDistributionListsForRedux(), }); const store = window.Signal.State.createStore(initialState); @@ -1072,6 +1078,10 @@ export async function startApp(): Promise { search: bindActionCreators(actionCreators.search, store.dispatch), stickers: bindActionCreators(actionCreators.stickers, store.dispatch), stories: bindActionCreators(actionCreators.stories, store.dispatch), + storyDistributionLists: bindActionCreators( + actionCreators.storyDistributionLists, + store.dispatch + ), updates: bindActionCreators(actionCreators.updates, store.dispatch), user: bindActionCreators(actionCreators.user, store.dispatch), }; @@ -3091,7 +3101,7 @@ export async function startApp(): Promise { unidentifiedStatus.reduce( ( result: SendStateByConversationId, - { destinationUuid, destination } + { destinationUuid, destination, isAllowedToReplyToStory } ) => { const conversationId = window.ConversationController.ensureContactIds( { @@ -3106,6 +3116,7 @@ export async function startApp(): Promise { return { ...result, [conversationId]: { + isAllowedToReplyToStory, status: SendStatus.Sent, updatedAt: timestamp, }, @@ -3130,6 +3141,9 @@ export async function startApp(): Promise { } return new window.Whisper.Message({ + canReplyToStory: data.message.isStory + ? data.message.canReplyToStory + : undefined, conversationId: descriptor.id, expirationStartTimestamp: Math.min( data.expirationStartTimestamp || timestamp, @@ -3146,7 +3160,8 @@ export async function startApp(): Promise { sourceDevice: data.device, sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), timestamp, - type: 'outgoing', + type: data.message.isStory ? 'story' : 'outgoing', + storyDistributionListId: data.storyDistributionListId, unidentifiedDeliveries, } as Partial as WhatIsThis); } @@ -3384,20 +3399,23 @@ export async function startApp(): Promise { `Did not receive receivedAtCounter for message: ${data.timestamp}` ); return new window.Whisper.Message({ - source: data.source, - sourceUuid: data.sourceUuid, - sourceDevice: data.sourceDevice, + canReplyToStory: data.message.isStory + ? data.message.canReplyToStory + : undefined, + conversationId: descriptor.id, + readStatus: ReadStatus.Unread, + received_at: data.receivedAtCounter, + received_at_ms: data.receivedAtDate, + seenStatus: SeenStatus.Unseen, sent_at: data.timestamp, serverGuid: data.serverGuid, serverTimestamp: data.serverTimestamp, - received_at: data.receivedAtCounter, - received_at_ms: data.receivedAtDate, - conversationId: descriptor.id, - unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, - type: data.message.isStory ? 'story' : 'incoming', - readStatus: ReadStatus.Unread, - seenStatus: SeenStatus.Unseen, + source: data.source, + sourceDevice: data.sourceDevice, + sourceUuid: data.sourceUuid, timestamp: data.timestamp, + type: data.message.isStory ? 'story' : 'incoming', + unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, } as Partial as WhatIsThis); } diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 635d062ca..6818b4219 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -86,55 +86,63 @@ export function ContextMenuPopper({ } return ( -
-
- {title &&
{title}
} - {menuOptions.map((option, index) => ( - - ))} + {typeof value !== 'undefined' && + typeof option.value !== 'undefined' && + value === option.value ? ( +
+ ) : null} + + ))} +
-
+ ); } @@ -214,22 +222,16 @@ export function ContextMenu({ type="button" /> {menuShowing && ( - - setMenuShowing(false)} - popperOptions={popperOptions} - referenceElement={referenceElement} - title={title} - value={value} - /> - + setMenuShowing(false)} + popperOptions={popperOptions} + referenceElement={referenceElement} + title={title} + value={value} + /> )} ); diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 4c0d31878..66c28dc8d 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { ContactModalStateType, + ForwardMessagePropsType, UserNotFoundModalStateType, } from '../state/ducks/globalModals'; import type { LocalizerType } from '../types/Util'; @@ -18,6 +19,9 @@ type PropsType = { // ContactModal contactModalState?: ContactModalStateType; renderContactModal: () => JSX.Element; + // ForwardMessageModal + forwardMessageProps?: ForwardMessagePropsType; + renderForwardMessageModal: () => JSX.Element; // ProfileEditor isProfileEditorVisible: boolean; renderProfileEditor: () => JSX.Element; @@ -37,6 +41,9 @@ export const GlobalModalContainer = ({ // ContactModal contactModalState, renderContactModal, + // ForwardMessageModal + forwardMessageProps, + renderForwardMessageModal, // ProfileEditor isProfileEditorVisible, renderProfileEditor, @@ -94,5 +101,9 @@ export const GlobalModalContainer = ({ return ; } + if (forwardMessageProps) { + return renderForwardMessageModal(); + } + return null; }; diff --git a/ts/components/MyStories.stories.tsx b/ts/components/MyStories.stories.tsx new file mode 100644 index 000000000..2ac6b0d6b --- /dev/null +++ b/ts/components/MyStories.stories.tsx @@ -0,0 +1,105 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Meta, ReactFramework, Story } from '@storybook/react'; +import type { PlayFunction } from '@storybook/csf'; +import React from 'react'; +import { expect } from '@storybook/jest'; +import { v4 as uuid } from 'uuid'; +import { within, userEvent } from '@storybook/testing-library'; + +import type { PropsType } from './MyStories'; +import enMessages from '../../_locales/en/messages.json'; +import { MY_STORIES_ID } from '../types/Stories'; +import { MyStories } from './MyStories'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { getFakeMyStory } from '../test-both/helpers/getFakeStory'; +import { setupI18n } from '../util/setupI18n'; +import { sleep } from '../util/sleep'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/MyStories', + component: MyStories, + argTypes: { + i18n: { + defaultValue: i18n, + }, + onBack: { + action: true, + }, + onDelete: { + action: true, + }, + onForward: { + action: true, + }, + onSave: { + action: true, + }, + ourConversationId: { + defaultValue: getDefaultConversation().id, + }, + queueStoryDownload: { + action: true, + }, + renderStoryViewer: { + action: true, + }, + }, +} as Meta; + +const Template: Story = args => ; + +export const NoStories = Template.bind({}); +NoStories.args = { + myStories: [], +}; +NoStories.story = { + name: 'No Stories', +}; + +const interactionTest: PlayFunction = async ({ + args, + canvasElement, +}) => { + const canvas = within(canvasElement); + const [btnDownload] = canvas.getAllByLabelText('Download story'); + await userEvent.click(btnDownload); + await expect(args.onSave).toHaveBeenCalled(); + + const [btnBack] = canvas.getAllByLabelText('Back'); + await userEvent.click(btnBack); + await expect(args.onBack).toHaveBeenCalled(); + + const [btnCtxMenu] = canvas.getAllByLabelText('Context menu'); + + await userEvent.click(btnCtxMenu); + await sleep(300); + const [btnFwd] = canvas.getAllByLabelText('Forward'); + await userEvent.click(btnFwd); + await expect(args.onForward).toHaveBeenCalled(); +}; + +export const SingleListStories = Template.bind({}); +SingleListStories.args = { + myStories: [getFakeMyStory(MY_STORIES_ID)], +}; +SingleListStories.play = interactionTest; +SingleListStories.story = { + name: 'One distribution list', +}; + +export const MultiListStories = Template.bind({}); +MultiListStories.args = { + myStories: [ + getFakeMyStory(MY_STORIES_ID), + getFakeMyStory(uuid(), 'Cool Peeps'), + getFakeMyStory(uuid(), 'Family'), + ], +}; +MultiListStories.play = interactionTest; +MultiListStories.story = { + name: 'Multiple distribution lists', +}; diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx new file mode 100644 index 000000000..033f18978 --- /dev/null +++ b/ts/components/MyStories.tsx @@ -0,0 +1,167 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import type { MyStoryType, StoryViewType } from '../types/Stories'; +import type { LocalizerType } from '../types/Util'; +import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { ContextMenu } from './ContextMenu'; +import { MY_STORIES_ID } from '../types/Stories'; +import { MessageTimestamp } from './conversation/MessageTimestamp'; +import { StoryImage } from './StoryImage'; +import { Theme } from '../util/theme'; + +export type PropsType = { + i18n: LocalizerType; + myStories: Array; + onBack: () => unknown; + onDelete: (story: StoryViewType) => unknown; + onForward: (storyId: string) => unknown; + onSave: (story: StoryViewType) => unknown; + ourConversationId: string; + queueStoryDownload: (storyId: string) => unknown; + renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element; +}; + +export const MyStories = ({ + i18n, + myStories, + onBack, + onDelete, + onForward, + onSave, + ourConversationId, + queueStoryDownload, + renderStoryViewer, +}: PropsType): JSX.Element => { + const [confirmDeleteStory, setConfirmDeleteStory] = useState< + StoryViewType | undefined + >(); + + const [storyToView, setStoryToView] = useState(); + + return ( + <> + {confirmDeleteStory && ( + onDelete(confirmDeleteStory), + style: 'negative', + }, + ]} + i18n={i18n} + onClose={() => setConfirmDeleteStory(undefined)} + > + {i18n('MyStories__delete')} + + )} + {storyToView && + renderStoryViewer({ + conversationId: ourConversationId, + onClose: () => setStoryToView(undefined), + storyToView, + })} +
+
+
+ {myStories.map(list => ( +
+
+ {list.distributionId === MY_STORIES_ID + ? i18n('Stories__mine') + : list.distributionName} +
+ {list.stories.map(story => ( +
+ {story.attachment && ( + + )} +
+ {story.views === 1 + ? i18n('MyStories__views--singular', [String(story.views)]) + : i18n('MyStories__views--plural', [ + String(story.views || 0), + ])} + +
+ +
+ ))} +
+ ))} +
+ {!myStories.length && ( +
+ {i18n('Stories__list-empty')} +
+ )} + + ); +}; diff --git a/ts/components/MyStoriesButton.stories.tsx b/ts/components/MyStoriesButton.stories.tsx new file mode 100644 index 000000000..fc30695a3 --- /dev/null +++ b/ts/components/MyStoriesButton.stories.tsx @@ -0,0 +1,81 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Meta, ReactFramework, Story } from '@storybook/react'; +import type { PlayFunction } from '@storybook/csf'; +import React from 'react'; +import { expect } from '@storybook/jest'; +import { within, userEvent } from '@storybook/testing-library'; + +import type { PropsType } from './MyStoriesButton'; +import enMessages from '../../_locales/en/messages.json'; +import { MyStoriesButton } from './MyStoriesButton'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { getFakeStoryView } from '../test-both/helpers/getFakeStory'; +import { setupI18n } from '../util/setupI18n'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/MyStoriesButton', + component: MyStoriesButton, + argTypes: { + hasMultiple: { + control: 'checkbox', + defaultValue: false, + }, + i18n: { + defaultValue: i18n, + }, + me: { + defaultValue: getDefaultConversation(), + }, + newestStory: { + defaultValue: getFakeStoryView(), + }, + onClick: { + action: true, + }, + queueStoryDownload: { + action: true, + }, + }, +} as Meta; + +const Template: Story = args => ; + +const interactionTest: PlayFunction = async ({ + args, + canvasElement, +}) => { + const canvas = within(canvasElement); + const [btnStory] = canvas.getAllByLabelText('Story'); + await userEvent.click(btnStory); + await expect(args.onClick).toHaveBeenCalled(); +}; + +export const NoStory = Template.bind({}); +NoStory.args = { + hasMultiple: false, + newestStory: undefined, +}; +NoStory.story = { + name: 'No Story', +}; +NoStory.play = interactionTest; + +export const OneStory = Template.bind({}); +OneStory.args = {}; +OneStory.story = { + name: 'One Story', +}; +OneStory.play = interactionTest; + +export const ManyStories = Template.bind({}); +ManyStories.args = { + hasMultiple: true, +}; +ManyStories.story = { + name: 'Many Stories', +}; +ManyStories.play = interactionTest; diff --git a/ts/components/MyStoriesButton.tsx b/ts/components/MyStoriesButton.tsx new file mode 100644 index 000000000..cf67a1566 --- /dev/null +++ b/ts/components/MyStoriesButton.tsx @@ -0,0 +1,103 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; +import type { ConversationType } from '../state/ducks/conversations'; +import type { LocalizerType } from '../types/Util'; +import type { StoryViewType } from '../types/Stories'; +import { Avatar, AvatarSize } from './Avatar'; +import { StoryImage } from './StoryImage'; +import { getAvatarColor } from '../types/Colors'; + +export type PropsType = { + hasMultiple: boolean; + i18n: LocalizerType; + me: ConversationType; + newestStory?: StoryViewType; + onClick: () => unknown; + queueStoryDownload: (storyId: string) => unknown; +}; + +export const MyStoriesButton = ({ + hasMultiple, + i18n, + me, + newestStory, + onClick, + queueStoryDownload, +}: PropsType): JSX.Element => { + const { + acceptedMessageRequest, + avatarPath, + color, + isMe, + name, + profileName, + sharedGroupNames, + title, + } = me; + + return ( +
+ +
+ ); +}; diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index e815f64ac..7422e7b56 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -3,20 +3,16 @@ import type { Meta, Story } from '@storybook/react'; import React from 'react'; -import { v4 as uuid } from 'uuid'; -import { action } from '@storybook/addon-actions'; -import type { AttachmentType } from '../types/Attachment'; -import type { ConversationType } from '../state/ducks/conversations'; import type { PropsType } from './Stories'; import { Stories } from './Stories'; import enMessages from '../../_locales/en/messages.json'; import { setupI18n } from '../util/setupI18n'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { - fakeAttachment, - fakeThumbnail, -} from '../test-both/helpers/fakeAttachment'; + getFakeMyStory, + getFakeStory, +} from '../test-both/helpers/getFakeStory'; import * as durations from '../util/durations'; const i18n = setupI18n('en', enMessages); @@ -24,119 +20,136 @@ const i18n = setupI18n('en', enMessages); export default { title: 'Components/Stories', component: Stories, + argTypes: { + deleteStoryForEveryone: { action: true }, + hiddenStories: { + defaultValue: [], + }, + i18n: { + defaultValue: i18n, + }, + myStories: { + defaultValue: [], + }, + onForwardStory: { action: true }, + onSaveStory: { action: true }, + ourConversationId: { + defaultValue: getDefaultConversation().id, + }, + preferredWidthFromStorage: { + defaultValue: 380, + }, + queueStoryDownload: { action: true }, + renderStoryCreator: { action: true }, + renderStoryViewer: { action: true }, + showConversation: { action: true }, + stories: { + defaultValue: [], + }, + toggleHideStories: { action: true }, + toggleStoriesView: { action: true }, + }, } as Meta; -function createStory({ - attachment, - group, - timestamp, -}: { - attachment?: AttachmentType; - group?: Pick< - ConversationType, - | 'acceptedMessageRequest' - | 'avatarPath' - | 'color' - | 'id' - | 'name' - | 'profileName' - | 'sharedGroupNames' - | 'title' - >; - timestamp: number; -}) { - const replies = Math.random() > 0.5; - let hasReplies = false; - let hasRepliesFromSelf = false; - if (replies) { - hasReplies = true; - hasRepliesFromSelf = Math.random() > 0.5; - } - - const sender = getDefaultConversation(); - - return { - conversationId: sender.id, - group, - stories: [ - { - attachment, - hasReplies, - hasRepliesFromSelf, - isMe: false, - isUnread: Math.random() > 0.5, - messageId: uuid(), - sender, - timestamp, - }, - ], - }; -} - -function getAttachmentWithThumbnail(url: string): AttachmentType { - return fakeAttachment({ - url, - thumbnail: fakeThumbnail(url), - }); -} - -const getDefaultProps = (): PropsType => ({ - hiddenStories: [], - i18n, - preferredWidthFromStorage: 380, - queueStoryDownload: action('queueStoryDownload'), - renderStoryCreator: () =>
, - renderStoryViewer: () =>
, - showConversation: action('showConversation'), - stories: [ - createStory({ - attachment: getAttachmentWithThumbnail( - '/fixtures/tina-rolf-269345-unsplash.jpg' - ), - timestamp: Date.now() - 2 * durations.MINUTE, - }), - createStory({ - attachment: getAttachmentWithThumbnail( - '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg' - ), - timestamp: Date.now() - 5 * durations.MINUTE, - }), - createStory({ - group: getDefaultConversation({ title: 'BBQ in the park' }), - attachment: getAttachmentWithThumbnail( - '/fixtures/nathan-anderson-316188-unsplash.jpg' - ), - timestamp: Date.now() - 65 * durations.MINUTE, - }), - createStory({ - attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'), - timestamp: Date.now() - 92 * durations.MINUTE, - }), - createStory({ - attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'), - timestamp: Date.now() - 164 * durations.MINUTE, - }), - createStory({ - group: getDefaultConversation({ title: 'Breaking Signal for Science' }), - attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'), - timestamp: Date.now() - 380 * durations.MINUTE, - }), - createStory({ - attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'), - timestamp: Date.now() - 421 * durations.MINUTE, - }), - ], - toggleHideStories: action('toggleHideStories'), - toggleStoriesView: action('toggleStoriesView'), -}); - const Template: Story = args => ; export const Blank = Template.bind({}); -Blank.args = { - ...getDefaultProps(), - stories: [], -}; +Blank.args = {}; export const Many = Template.bind({}); -Many.args = getDefaultProps(); +Many.args = { + stories: [ + getFakeStory({ + attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg', + timestamp: Date.now() - 2 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg', + timestamp: Date.now() - 5 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg', + group: getDefaultConversation({ title: 'BBQ in the park' }), + timestamp: Date.now() - 65 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/snow.jpg', + timestamp: Date.now() - 92 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-1-64-64.jpg', + timestamp: Date.now() - 164 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-2-64-64.jpg', + group: getDefaultConversation({ title: 'Breaking Signal for Science' }), + timestamp: Date.now() - 380 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-3-64-64.jpg', + timestamp: Date.now() - 421 * durations.MINUTE, + }), + ], +}; + +export const HiddenStories = Template.bind({}); +HiddenStories.args = { + hiddenStories: [ + getFakeStory({ + attachmentUrl: '/fixtures/kitten-1-64-64.jpg', + timestamp: Date.now() - 164 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-2-64-64.jpg', + group: getDefaultConversation({ title: 'Breaking Signal for Science' }), + timestamp: Date.now() - 380 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-3-64-64.jpg', + timestamp: Date.now() - 421 * durations.MINUTE, + }), + ], + stories: [ + getFakeStory({ + attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg', + timestamp: Date.now() - 2 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/snow.jpg', + timestamp: Date.now() - 92 * durations.MINUTE, + }), + ], +}; + +export const MyStories = Template.bind({}); +MyStories.args = { + myStories: [ + getFakeMyStory(undefined, 'BFF'), + getFakeMyStory(undefined, 'The Fun Group'), + ], + hiddenStories: [ + getFakeStory({ + attachmentUrl: '/fixtures/kitten-1-64-64.jpg', + timestamp: Date.now() - 164 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-2-64-64.jpg', + group: getDefaultConversation({ title: 'Breaking Signal for Science' }), + timestamp: Date.now() - 380 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/kitten-3-64-64.jpg', + timestamp: Date.now() - 421 * durations.MINUTE, + }), + ], + stories: [ + getFakeStory({ + attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg', + timestamp: Date.now() - 2 * durations.MINUTE, + }), + getFakeStory({ + attachmentUrl: '/fixtures/snow.jpg', + timestamp: Date.now() - 92 * durations.MINUTE, + }), + ], +}; diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index e0baccdaa..e36f81b4c 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -4,19 +4,33 @@ import FocusTrap from 'focus-trap-react'; import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; -import type { ConversationStoryType } from './StoryListItem'; +import type { + ConversationType, + ShowConversationType, +} from '../state/ducks/conversations'; +import type { + ConversationStoryType, + MyStoryType, + StoryViewType, +} from '../types/Stories'; import type { LocalizerType } from '../types/Util'; import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator'; import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer'; -import type { ShowConversationType } from '../state/ducks/conversations'; +import * as log from '../logging/log'; +import { MyStories } from './MyStories'; import { StoriesPane } from './StoriesPane'; import { Theme, themeClassName } from '../util/theme'; import { getWidthFromPreferredWidth } from '../util/leftPaneWidth'; -import * as log from '../logging/log'; export type PropsType = { + deleteStoryForEveryone: (story: StoryViewType) => unknown; hiddenStories: Array; i18n: LocalizerType; + me: ConversationType; + myStories: Array; + onForwardStory: (storyId: string) => unknown; + onSaveStory: (story: StoryViewType) => unknown; + ourConversationId: string; preferredWidthFromStorage: number; queueStoryDownload: (storyId: string) => unknown; renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element; @@ -28,8 +42,14 @@ export type PropsType = { }; export const Stories = ({ + deleteStoryForEveryone, hiddenStories, i18n, + me, + myStories, + onForwardStory, + onSaveStory, + ourConversationId, preferredWidthFromStorage, queueStoryDownload, renderStoryCreator, @@ -100,6 +120,7 @@ export const Stories = ({ }, [conversationIdToView, stories]); const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false); + const [isMyStories, setIsMyStories] = useState(false); return (
@@ -116,26 +137,49 @@ export const Stories = ({ })}
- setIsShowingStoryCreator(true)} - onStoryClicked={clickedIdToView => { - const storyIndex = stories.findIndex( - x => x.conversationId === clickedIdToView - ); - log.info('stories.onStoryClicked', { - storyIndex, - length: stories.length, - }); - setConversationIdToView(clickedIdToView); - }} - queueStoryDownload={queueStoryDownload} - showConversation={showConversation} - stories={stories} - toggleHideStories={toggleHideStories} - toggleStoriesView={toggleStoriesView} - /> + {isMyStories && myStories.length ? ( + setIsMyStories(false)} + onDelete={deleteStoryForEveryone} + onForward={onForwardStory} + onSave={onSaveStory} + ourConversationId={ourConversationId} + queueStoryDownload={queueStoryDownload} + renderStoryViewer={renderStoryViewer} + /> + ) : ( + setIsShowingStoryCreator(true)} + onMyStoriesClicked={() => { + if (myStories.length) { + setIsMyStories(true); + } else { + setIsShowingStoryCreator(true); + } + }} + onStoryClicked={clickedIdToView => { + const storyIndex = stories.findIndex( + x => x.conversationId === clickedIdToView + ); + log.info('stories.onStoryClicked[StoriesPane]', { + storyIndex, + length: stories.length, + }); + setConversationIdToView(clickedIdToView); + }} + queueStoryDownload={queueStoryDownload} + showConversation={showConversation} + stories={stories} + toggleHideStories={toggleHideStories} + toggleStoriesView={toggleStoriesView} + /> + )}
diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 98e222b5b..893220959 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -5,9 +5,17 @@ import Fuse from 'fuse.js'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; -import type { ConversationStoryType, StoryViewType } from './StoryListItem'; +import type { + ConversationType, + ShowConversationType, +} from '../state/ducks/conversations'; +import type { + ConversationStoryType, + MyStoryType, + StoryViewType, +} from '../types/Stories'; import type { LocalizerType } from '../types/Util'; -import type { ShowConversationType } from '../state/ducks/conversations'; +import { MyStoriesButton } from './MyStoriesButton'; import { SearchInput } from './SearchInput'; import { StoryListItem } from './StoryListItem'; import { isNotNil } from '../util/isNotNil'; @@ -47,14 +55,19 @@ function search( .map(result => result.item); } -function getNewestStory(story: ConversationStoryType): StoryViewType { +function getNewestStory( + story: ConversationStoryType | MyStoryType +): StoryViewType { return story.stories[story.stories.length - 1]; } export type PropsType = { hiddenStories: Array; i18n: LocalizerType; + me: ConversationType; + myStories: Array; onAddStory: () => unknown; + onMyStoriesClicked: () => unknown; onStoryClicked: (conversationId: string) => unknown; queueStoryDownload: (storyId: string) => unknown; showConversation: ShowConversationType; @@ -66,7 +79,10 @@ export type PropsType = { export const StoriesPane = ({ hiddenStories, i18n, + me, + myStories, onAddStory, + onMyStoriesClicked, onStoryClicked, queueStoryDownload, showConversation, @@ -116,6 +132,16 @@ export const StoriesPane = ({ placeholder={i18n('search')} value={searchTerm} /> + 1 : false} + i18n={i18n} + me={me} + newestStory={ + myStories.length ? getNewestStory(myStories[0]) : undefined + } + onClick={onMyStoriesClicked} + queueStoryDownload={queueStoryDownload} + />
( - -); +const Template: Story = args => ; -export const MyStoryMany = (): JSX.Element => ( - -); - -MyStoryMany.story = { - name: 'My Story (many)', +export const SomeonesStory = Template.bind({}); +SomeonesStory.args = { + group: getDefaultConversation({ title: 'Sports Group' }), + story: { + attachment: fakeAttachment({ + thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'), + }), + hasReplies: true, + isUnread: true, + messageId: '123', + sender: getDefaultConversation(), + timestamp: Date.now(), + }, }; - -export const SomeonesStory = (): JSX.Element => ( - -); - SomeonesStory.story = { name: "Someone's story", }; diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx index bb945ee18..6561aaec6 100644 --- a/ts/components/StoryListItem.tsx +++ b/ts/components/StoryListItem.tsx @@ -3,9 +3,8 @@ import React, { useState } from 'react'; import classNames from 'classnames'; -import type { AttachmentType } from '../types/Attachment'; import type { LocalizerType } from '../types/Util'; -import type { ConversationType } from '../state/ducks/conversations'; +import type { ConversationStoryType, StoryViewType } from '../types/Stories'; import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenuPopper } from './ContextMenu'; @@ -13,53 +12,7 @@ import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; import { getAvatarColor } from '../types/Colors'; -export type ConversationStoryType = { - conversationId: string; - group?: Pick< - ConversationType, - | 'acceptedMessageRequest' - | 'avatarPath' - | 'color' - | 'id' - | 'name' - | 'profileName' - | 'sharedGroupNames' - | 'title' - >; - hasMultiple?: boolean; - isHidden?: boolean; - searchNames?: string; // This is just here to satisfy Fuse's types - stories: Array; -}; - -export type StoryViewType = { - attachment?: AttachmentType; - canReply?: boolean; - hasReplies?: boolean; - hasRepliesFromSelf?: boolean; - isHidden?: boolean; - isUnread?: boolean; - messageId: string; - sender: Pick< - ConversationType, - | 'acceptedMessageRequest' - | 'avatarPath' - | 'color' - | 'firstName' - | 'id' - | 'isMe' - | 'name' - | 'profileName' - | 'sharedGroupNames' - | 'title' - >; - timestamp: number; -}; - -export type PropsType = Pick< - ConversationStoryType, - 'group' | 'hasMultiple' | 'isHidden' -> & { +export type PropsType = Pick & { i18n: LocalizerType; onClick: () => unknown; onGoToConversation: (conversationId: string) => unknown; @@ -70,7 +23,6 @@ export type PropsType = Pick< export const StoryListItem = ({ group, - hasMultiple, i18n, isHidden, onClick, @@ -129,9 +81,7 @@ export const StoryListItem = ({ ev.preventDefault(); ev.stopPropagation(); - if (!isMe) { - setIsShowingContextMenu(true); - } + setIsShowingContextMenu(true); }} ref={setReferenceElement} tabIndex={0} @@ -153,49 +103,25 @@ export const StoryListItem = ({ title={title} />
- {isMe ? ( - <> -
- {i18n('Stories__mine')} -
- {!attachment && ( -
- {i18n('Stories__add')} -
- )} - - ) : ( - <> -
- {group - ? i18n('Stories__from-to-group', { - name: title, - group: group.title, - }) - : title} -
- - - )} + <> +
+ {group + ? i18n('Stories__from-to-group', { + name: title, + group: group.title, + }) + : title} +
+ + {repliesElement}
-
- {!attachment && isMe && ( -
- )} - {hasMultiple &&
} +
unknown; onGoToConversation: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown; - onNextUserStories: () => unknown; - onPrevUserStories: () => unknown; + onNextUserStories?: () => unknown; + onPrevUserStories?: () => unknown; onSetSkinTone: (tone: number) => unknown; onTextTooLong: () => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown; @@ -76,7 +76,6 @@ export type PropsType = { skinTone?: number; stories: Array; toggleHasAllStoriesMuted: () => unknown; - views?: Array; }; const CAPTION_BUFFER = 20; @@ -116,7 +115,6 @@ export const StoryViewer = ({ skinTone, stories, toggleHasAllStoriesMuted, - views, }: PropsType): JSX.Element => { const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [storyDuration, setStoryDuration] = useState(); @@ -128,7 +126,8 @@ export const StoryViewer = ({ const visibleStory = stories[currentStoryIndex]; - const { attachment, canReply, isHidden, messageId, timestamp } = visibleStory; + const { attachment, canReply, isHidden, messageId, sendState, timestamp } = + visibleStory; const { acceptedMessageRequest, avatarPath, @@ -202,7 +201,7 @@ export const StoryViewer = ({ setCurrentStoryIndex(currentStoryIndex + 1); } else { setCurrentStoryIndex(0); - onNextUserStories(); + onNextUserStories?.(); } }, [currentStoryIndex, onNextUserStories, stories.length]); @@ -210,7 +209,7 @@ export const StoryViewer = ({ // for the prior user's stories. const showPrevStory = useCallback(() => { if (currentStoryIndex === 0) { - onPrevUserStories(); + onPrevUserStories?.(); } else { setCurrentStoryIndex(currentStoryIndex - 1); } @@ -378,9 +377,13 @@ export const StoryViewer = ({ const replies = replyState && replyState.messageId === messageId ? replyState.replies : []; - - const viewCount = (views || []).length; + const views = sendState + ? sendState.filter(({ status }) => status === SendStatus.Viewed) + : []; const replyCount = replies.length; + const viewCount = views.length; + + const shouldShowContextMenu = !sendState; return ( @@ -390,18 +393,20 @@ export const StoryViewer = ({ style={{ background: getStoryBackground(attachment) }} />
-
@@ -619,18 +626,20 @@ export const StoryViewer = ({ )}
-