Adds playback bar to story viewer
This commit is contained in:
parent
9817946afc
commit
85c8ff76dc
17 changed files with 291 additions and 125 deletions
|
@ -49,6 +49,7 @@ export function ContextMenuPopper<T>({
|
|||
onClose,
|
||||
referenceElement,
|
||||
title,
|
||||
theme,
|
||||
value,
|
||||
}: ContextMenuPropsType<T>): JSX.Element | null {
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
||||
|
@ -84,49 +85,54 @@ export function ContextMenuPopper<T>({
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ContextMenu__popper"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className="ContextMenu__title">{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames({
|
||||
ContextMenu__option: true,
|
||||
'ContextMenu__option--focused': focusedIndex === index,
|
||||
})}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
option.onClick(option.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="ContextMenu__option--container">
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames('ContextMenu__option--icon', option.icon)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="ContextMenu__option--title">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="ContextMenu__option--description">
|
||||
{option.description}
|
||||
</div>
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<div
|
||||
className="ContextMenu__popper"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className="ContextMenu__title">{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames({
|
||||
ContextMenu__option: true,
|
||||
'ContextMenu__option--focused': focusedIndex === index,
|
||||
})}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
option.onClick(option.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="ContextMenu__option--container">
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
'ContextMenu__option--icon',
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="ContextMenu__option--title">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="ContextMenu__option--description">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className="ContextMenu__option--selected" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className="ContextMenu__option--selected" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ export type PropsType = {
|
|||
readonly attachment?: AttachmentType;
|
||||
readonly children?: ReactNode;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly isMuted?: boolean;
|
||||
readonly isPaused?: boolean;
|
||||
readonly isThumbnail?: boolean;
|
||||
readonly label: string;
|
||||
|
@ -37,6 +38,7 @@ export const StoryImage = ({
|
|||
attachment,
|
||||
children,
|
||||
i18n,
|
||||
isMuted,
|
||||
isPaused,
|
||||
isThumbnail,
|
||||
label,
|
||||
|
@ -106,6 +108,7 @@ export const StoryImage = ({
|
|||
controls={false}
|
||||
key={attachment.url}
|
||||
loop={shouldLoop}
|
||||
muted={isMuted}
|
||||
ref={videoRef}
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
|
|
|
@ -23,6 +23,7 @@ function getDefaultProps(): PropsType {
|
|||
conversationId: sender.id,
|
||||
getPreferredBadge: () => undefined,
|
||||
group: undefined,
|
||||
hasAllStoriesMuted: false,
|
||||
i18n,
|
||||
loadStoryReplies: action('loadStoryReplies'),
|
||||
markStoryRead: action('markStoryRead'),
|
||||
|
@ -51,6 +52,7 @@ function getDefaultProps(): PropsType {
|
|||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
toggleHasAllStoriesMuted: action('toggleHasAllStoriesMuted'),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -153,6 +155,7 @@ story.add('Caption', () => (
|
|||
story.add('Long Caption', () => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
hasAllStoriesMuted
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
|
|
|
@ -26,6 +26,7 @@ import { Intl } from './Intl';
|
|||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { Theme } from '../util/theme';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryBackground } from '../util/getStoryBackground';
|
||||
import { getStoryDuration } from '../util/getStoryDuration';
|
||||
|
@ -47,6 +48,7 @@ export type PropsType = {
|
|||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
>;
|
||||
hasAllStoriesMuted: boolean;
|
||||
i18n: LocalizerType;
|
||||
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
|
||||
markStoryRead: (mId: string) => unknown;
|
||||
|
@ -72,6 +74,7 @@ export type PropsType = {
|
|||
replyState?: ReplyStateType;
|
||||
skinTone?: number;
|
||||
stories: Array<StoryViewType>;
|
||||
toggleHasAllStoriesMuted: () => unknown;
|
||||
views?: Array<string>;
|
||||
};
|
||||
|
||||
|
@ -90,6 +93,7 @@ export const StoryViewer = ({
|
|||
conversationId,
|
||||
getPreferredBadge,
|
||||
group,
|
||||
hasAllStoriesMuted,
|
||||
i18n,
|
||||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
|
@ -110,6 +114,7 @@ export const StoryViewer = ({
|
|||
replyState,
|
||||
skinTone,
|
||||
stories,
|
||||
toggleHasAllStoriesMuted,
|
||||
views,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||
|
@ -261,11 +266,14 @@ export const StoryViewer = ({
|
|||
};
|
||||
}, [currentStoryIndex, spring, storyDuration]);
|
||||
|
||||
const [pauseStory, setPauseStory] = useState(false);
|
||||
|
||||
const shouldPauseViewing =
|
||||
hasConfirmHideStory ||
|
||||
hasExpandedCaption ||
|
||||
hasReplyModal ||
|
||||
isShowingContextMenu ||
|
||||
pauseStory ||
|
||||
Boolean(reactionEmoji);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -388,6 +396,7 @@ export const StoryViewer = ({
|
|||
attachment={attachment}
|
||||
i18n={i18n}
|
||||
isPaused={shouldPauseViewing}
|
||||
isMuted={hasAllStoriesMuted}
|
||||
label={i18n('lightboxImageAlt')}
|
||||
moduleClassName="StoryViewer__story"
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
|
@ -436,50 +445,90 @@ export const StoryViewer = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(isMe)}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={title}
|
||||
/>
|
||||
{group && (
|
||||
<Avatar
|
||||
acceptedMessageRequest={group.acceptedMessageRequest}
|
||||
avatarPath={group.avatarPath}
|
||||
badge={undefined}
|
||||
className="StoryViewer__meta--group-avatar"
|
||||
color={getAvatarColor(group.color)}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
name={group.name}
|
||||
profileName={group.profileName}
|
||||
sharedGroupNames={group.sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={group.title}
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__meta--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
<div className="StoryViewer__meta__playback-bar">
|
||||
<div>
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(isMe)}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={title}
|
||||
/>
|
||||
{group && (
|
||||
<Avatar
|
||||
acceptedMessageRequest={group.acceptedMessageRequest}
|
||||
avatarPath={group.avatarPath}
|
||||
badge={undefined}
|
||||
className="StoryViewer__meta--group-avatar"
|
||||
color={getAvatarColor(group.color)}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
name={group.name}
|
||||
profileName={group.profileName}
|
||||
sharedGroupNames={group.sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={group.title}
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__meta--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
isRelativeTime
|
||||
module="StoryViewer__meta--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
</div>
|
||||
<div className="StoryViewer__meta__playback-controls">
|
||||
<button
|
||||
aria-label={
|
||||
pauseStory
|
||||
? i18n('StoryViewer__play')
|
||||
: i18n('StoryViewer__pause')
|
||||
}
|
||||
className={
|
||||
pauseStory ? 'StoryViewer__play' : 'StoryViewer__pause'
|
||||
}
|
||||
onClick={() => setPauseStory(!pauseStory)}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
hasAllStoriesMuted
|
||||
? i18n('StoryViewer__unmute')
|
||||
: i18n('StoryViewer__mute')
|
||||
}
|
||||
className={
|
||||
hasAllStoriesMuted
|
||||
? 'StoryViewer__unmute'
|
||||
: 'StoryViewer__mute'
|
||||
}
|
||||
onClick={toggleHasAllStoriesMuted}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MyStories__more')}
|
||||
className="StoryViewer__more"
|
||||
onClick={() => setIsShowingContextMenu(true)}
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewer__meta--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<div className="StoryViewer__progress">
|
||||
{stories.map((story, index) => (
|
||||
<div
|
||||
|
@ -571,14 +620,6 @@ export const StoryViewer = ({
|
|||
type="button"
|
||||
/>
|
||||
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
|
||||
<button
|
||||
aria-label={i18n('MyStories__more')}
|
||||
className="StoryViewer__more"
|
||||
onClick={() => setIsShowingContextMenu(true)}
|
||||
ref={setReferenceElement}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
className="StoryViewer__close-button"
|
||||
|
@ -612,11 +653,8 @@ export const StoryViewer = ({
|
|||
},
|
||||
]}
|
||||
onClose={() => setIsShowingContextMenu(false)}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
referenceElement={referenceElement}
|
||||
theme={Theme.Dark}
|
||||
/>
|
||||
{hasReplyModal && canReply && (
|
||||
<StoryViewsNRepliesModal
|
||||
|
|
|
@ -15,6 +15,7 @@ export type Props = {
|
|||
deletedForEveryone?: boolean;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
i18n: LocalizerType;
|
||||
isRelativeTime?: boolean;
|
||||
module?: string;
|
||||
timestamp: number;
|
||||
withImageNoCaption?: boolean;
|
||||
|
@ -26,6 +27,7 @@ export function MessageTimestamp({
|
|||
deletedForEveryone,
|
||||
direction,
|
||||
i18n,
|
||||
isRelativeTime,
|
||||
module,
|
||||
timestamp,
|
||||
withImageNoCaption,
|
||||
|
@ -49,7 +51,7 @@ export function MessageTimestamp({
|
|||
)}
|
||||
timestamp={timestamp}
|
||||
>
|
||||
{formatTime(i18n, timestamp, now)}
|
||||
{formatTime(i18n, timestamp, now, isRelativeTime)}
|
||||
</Time>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -91,6 +91,7 @@ export const actions = {
|
|||
removeItem,
|
||||
removeItemExternal,
|
||||
resetItems,
|
||||
toggleHasAllStoriesMuted,
|
||||
};
|
||||
|
||||
export const useActions = (): typeof actions => useBoundActions(actions);
|
||||
|
@ -111,6 +112,19 @@ function onSetSkinTone(tone: number): ItemPutAction {
|
|||
return putItem('skinTone', tone);
|
||||
}
|
||||
|
||||
function toggleHasAllStoriesMuted(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
ItemPutAction
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
const hasAllStoriesMuted = Boolean(getState().items.hasAllStoriesMuted);
|
||||
|
||||
dispatch(putItem('hasAllStoriesMuted', !hasAllStoriesMuted));
|
||||
};
|
||||
}
|
||||
|
||||
function putItemExternal(key: string, value: unknown): ItemPutExternalAction {
|
||||
return {
|
||||
type: 'items/PUT_EXTERNAL',
|
||||
|
|
|
@ -20,6 +20,11 @@ const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
|||
|
||||
export const getItems = (state: StateType): ItemsStateType => state.items;
|
||||
|
||||
export const getHasAllStoriesMuted = createSelector(
|
||||
getItems,
|
||||
({ hasAllStoriesMuted }): boolean => Boolean(hasAllStoriesMuted)
|
||||
);
|
||||
|
||||
export const getAreWeASubscriber = createSelector(
|
||||
getItems,
|
||||
({ areWeASubscriber }: Readonly<ItemsStateType>): boolean =>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { StoryViewer } from '../../components/StoryViewer';
|
|||
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
|
||||
import {
|
||||
getEmojiSkinTone,
|
||||
getHasAllStoriesMuted,
|
||||
getPreferredReactionEmoji,
|
||||
} from '../selectors/items';
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
@ -38,7 +39,7 @@ export function SmartStoryViewer({
|
|||
onPrevUserStories,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const storiesActions = useStoriesActions();
|
||||
const { onSetSkinTone } = useItemsActions();
|
||||
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
|
||||
const { onUseEmoji } = useEmojisActions();
|
||||
const { openConversationInternal, toggleHideStories } =
|
||||
useConversationsActions();
|
||||
|
@ -59,12 +60,16 @@ export function SmartStoryViewer({
|
|||
const recentEmojis = useRecentEmojis();
|
||||
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);
|
||||
const replyState = useSelector(getStoryReplies);
|
||||
const hasAllStoriesMuted = useSelector<StateType, boolean>(
|
||||
getHasAllStoriesMuted
|
||||
);
|
||||
|
||||
return (
|
||||
<StoryViewer
|
||||
conversationId={conversationId}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
group={group}
|
||||
hasAllStoriesMuted={hasAllStoriesMuted}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
onHideStory={toggleHideStories}
|
||||
|
@ -96,6 +101,7 @@ export function SmartStoryViewer({
|
|||
replyState={replyState}
|
||||
stories={stories}
|
||||
skinTone={skinTone}
|
||||
toggleHasAllStoriesMuted={toggleHasAllStoriesMuted}
|
||||
{...storiesActions}
|
||||
/>
|
||||
);
|
||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -140,6 +140,7 @@ export type StorageAccessType = {
|
|||
subscriberId: Uint8Array;
|
||||
subscriberCurrencyCode: string;
|
||||
displayBadgesOnProfile: boolean;
|
||||
hasAllStoriesMuted: boolean;
|
||||
|
||||
// Deprecated
|
||||
senderCertificateWithUuid: never;
|
||||
|
|
|
@ -11,23 +11,24 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
|
|||
'badge-count-muted-conversations',
|
||||
'call-ringtone-notification',
|
||||
'call-system-notification',
|
||||
'customColors',
|
||||
'defaultConversationColor',
|
||||
'hasAllStoriesMuted',
|
||||
'hide-menu-bar',
|
||||
'system-tray-setting',
|
||||
'incoming-call-notification',
|
||||
'notification-draw-attention',
|
||||
'notification-setting',
|
||||
'spell-check',
|
||||
'theme-setting',
|
||||
'defaultConversationColor',
|
||||
'customColors',
|
||||
'showStickerPickerHint',
|
||||
'showStickersIntroduction',
|
||||
'preferred-video-input-device',
|
||||
'preferred-audio-input-device',
|
||||
'preferred-audio-output-device',
|
||||
'preferred-video-input-device',
|
||||
'preferredLeftPaneWidth',
|
||||
'preferredReactionEmoji',
|
||||
'previousAudioDeviceModule',
|
||||
'showStickerPickerHint',
|
||||
'showStickersIntroduction',
|
||||
'skinTone',
|
||||
'spell-check',
|
||||
'system-tray-setting',
|
||||
'theme-setting',
|
||||
'zoomFactor',
|
||||
];
|
||||
|
|
|
@ -103,7 +103,8 @@ export function formatDateTimeLong(
|
|||
export function formatTime(
|
||||
i18n: LocalizerType,
|
||||
rawTimestamp: RawTimestamp,
|
||||
now: RawTimestamp
|
||||
now: RawTimestamp,
|
||||
isRelativeTime?: boolean
|
||||
): string {
|
||||
const timestamp = rawTimestamp.valueOf();
|
||||
const diff = now.valueOf() - timestamp;
|
||||
|
@ -116,6 +117,10 @@ export function formatTime(
|
|||
return i18n('minutesAgo', [Math.floor(diff / MINUTE).toString()]);
|
||||
}
|
||||
|
||||
if (isRelativeTime) {
|
||||
return i18n('hoursAgo', [Math.floor(diff / HOUR).toString()]);
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue