Adds playback bar to story viewer

This commit is contained in:
Josh Perez 2022-05-06 15:02:44 -04:00 committed by GitHub
parent 9817946afc
commit 85c8ff76dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 291 additions and 125 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',

View file

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

View file

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

View file

@ -140,6 +140,7 @@ export type StorageAccessType = {
subscriberId: Uint8Array;
subscriberCurrencyCode: string;
displayBadgesOnProfile: boolean;
hasAllStoriesMuted: boolean;
// Deprecated
senderCertificateWithUuid: never;

View file

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

View file

@ -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',