Process incoming story messages

This commit is contained in:
Josh Perez 2022-03-04 16:14:52 -05:00 committed by GitHub
parent df7cdfacc7
commit eb91eb6fec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 4382 additions and 652 deletions

View file

@ -16,15 +16,17 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = {
appView: AppViewType;
openInbox: () => void;
registerSingleDevice: (number: string, code: string) => Promise<void>;
renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element;
openInbox: () => void;
isShowingStoriesView: boolean;
renderStories: () => JSX.Element;
requestVerification: (
type: 'sms' | 'voice',
number: string,
token: string
) => Promise<void>;
registerSingleDevice: (number: string, code: string) => Promise<void>;
theme: ThemeType;
} & ComponentProps<typeof Inbox>;
@ -36,11 +38,13 @@ export const App = ({
getPreferredBadge,
i18n,
isCustomizingPreferredReactions,
isShowingStoriesView,
renderCallManager,
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer,
renderSafetyNumber,
openInbox,
renderStories,
requestVerification,
registerSingleDevice,
theme,
@ -118,6 +122,7 @@ export const App = ({
>
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories()}
{contents}
</div>
);

View file

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -9,7 +9,7 @@ import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import type { Props } from './Avatar';
import { Avatar, AvatarBlur } from './Avatar';
import { Avatar, AvatarBlur, AvatarStoryRing } from './Avatar';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { AvatarColorType } from '../types/Colors';
@ -236,3 +236,23 @@ story.add('Blurred with "click to view"', () => {
return <Avatar {...props} size={112} />;
});
story.add('Story: unread', () => (
<Avatar
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
storyRing={AvatarStoryRing.Unread}
size={112}
/>
));
story.add('Story: read', () => (
<Avatar
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
storyRing={AvatarStoryRing.Read}
size={112}
/>
));

View file

@ -45,6 +45,11 @@ export enum AvatarSize {
ONE_HUNDRED_TWELVE = 112,
}
export enum AvatarStoryRing {
Unread = 'Unread',
Read = 'Read',
}
type BadgePlacementType = { bottom: number; right: number };
export type Props = {
@ -65,6 +70,7 @@ export type Props = {
title: string;
unblurredAvatarPath?: string;
searchResult?: boolean;
storyRing?: AvatarStoryRing;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -118,6 +124,7 @@ export const Avatar: FunctionComponent<Props> = ({
title,
unblurredAvatarPath,
searchResult,
storyRing,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
@ -301,6 +308,9 @@ export const Avatar: FunctionComponent<Props> = ({
className={classNames(
'module-Avatar',
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
storyRing && 'module-Avatar--with-story',
storyRing === AvatarStoryRing.Unread &&
'module-Avatar--with-story--unread',
className
)}
style={{

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MutableRefObject } from 'react';
@ -623,7 +623,7 @@ export const CompositionArea = ({
// This one is for redux...
setQuotedMessage(undefined);
// and this is for conversation_view.
clearQuotedMessage();
clearQuotedMessage?.();
}}
/>
</div>

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -61,6 +61,7 @@ export type InputApi = {
};
export type Props = {
children?: React.ReactNode;
readonly i18n: LocalizerType;
readonly disabled?: boolean;
readonly getPreferredBadge: PreferredBadgeSelectorType;
@ -71,6 +72,7 @@ export type Props = {
readonly draftBodyRanges?: Array<BodyRangeType>;
readonly moduleClassName?: string;
readonly theme: ThemeType;
readonly placeholder?: string;
sortedGroupMembers?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
@ -85,8 +87,8 @@ export type Props = {
mentions: Array<BodyRangeType>,
timestamp: number
): unknown;
getQuotedMessage(): unknown;
clearQuotedMessage(): unknown;
getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown;
};
const MAX_LENGTH = 64 * 1024;
@ -94,6 +96,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
export function CompositionInput(props: Props): React.ReactElement {
const {
children,
i18n,
disabled,
large,
@ -101,6 +104,7 @@ export function CompositionInput(props: Props): React.ReactElement {
moduleClassName,
onPickEmoji,
onSubmit,
placeholder,
skinTone,
draftText,
draftBodyRanges,
@ -341,8 +345,8 @@ export function CompositionInput(props: Props): React.ReactElement {
}
}
if (getQuotedMessage()) {
clearQuotedMessage();
if (getQuotedMessage?.()) {
clearQuotedMessage?.();
return false;
}
@ -561,7 +565,7 @@ export function CompositionInput(props: Props): React.ReactElement {
},
}}
formats={['emoji', 'mention']}
placeholder={i18n('sendMessage')}
placeholder={placeholder || i18n('sendMessage')}
readOnly={disabled}
ref={element => {
if (element) {
@ -635,9 +639,11 @@ export function CompositionInput(props: Props): React.ReactElement {
onClick={focus}
className={classNames(
getClassName('__input__scroller'),
large ? getClassName('__input__scroller--large') : null
large ? getClassName('__input__scroller--large') : null,
children ? getClassName('__input--with-children') : null
)}
>
{children}
{reactQuill}
{emojiCompletionElement}
{mentionCompletionElement}

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -19,21 +19,20 @@ const getDefaultProps = (): PropsType<number> => ({
menuOptions: [
{
label: '1',
value: 1,
onClick: action('1'),
},
{
label: '2',
value: 2,
onClick: action('2'),
},
{
label: '3',
value: 3,
onClick: action('3'),
},
],
onChange: action('onChange'),
value: 1,
});
// TODO DESKTOP-3184
story.add('Default', () => {
return <ContextMenu {...getDefaultProps()} />;
});

View file

@ -1,8 +1,10 @@
// Copyright 2018-2021 Signal Messenger, LLC
// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { Options } from '@popperjs/core';
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import { noop } from 'lodash';
@ -12,27 +14,128 @@ import type { LocalizerType } from '../types/Util';
import { themeClassName } from '../util/theme';
type OptionType<T> = {
readonly description?: string;
readonly icon?: string;
readonly label: string;
readonly description?: string;
readonly value: T;
readonly onClick: (value?: T) => unknown;
readonly value?: T;
};
export type ContextMenuPropsType<T> = {
readonly focusedIndex?: number;
readonly isMenuShowing: boolean;
readonly menuOptions: ReadonlyArray<OptionType<T>>;
readonly onClose: () => unknown;
readonly popperOptions?: Pick<Options, 'placement' | 'strategy'>;
readonly referenceElement: HTMLElement | null;
readonly theme?: Theme;
readonly title?: string;
readonly value?: T;
};
export type PropsType<T> = {
readonly buttonClassName?: string;
readonly i18n: LocalizerType;
readonly menuOptions: ReadonlyArray<OptionType<T>>;
readonly onChange: (value: T) => unknown;
readonly theme?: Theme;
readonly title?: string;
readonly value: T;
};
} & Pick<
ContextMenuPropsType<T>,
'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value'
>;
export function ContextMenuPopper<T>({
menuOptions,
focusedIndex,
isMenuShowing,
popperOptions,
onClose,
referenceElement,
title,
value,
}: ContextMenuPropsType<T>): JSX.Element | null {
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
...popperOptions,
});
useEffect(() => {
if (!isMenuShowing) {
return noop;
}
const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) {
onClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.removeEventListener('click', handleOutsideClick);
};
}, [isMenuShowing, onClose, referenceElement]);
if (!isMenuShowing) {
return null;
}
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>
</div>
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
</div>
);
}
export function ContextMenu<T>({
buttonClassName,
i18n,
menuOptions,
onChange,
popperOptions,
theme,
title,
value,
@ -42,13 +145,6 @@ export function ContextMenu<T>({
undefined
);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const handleKeyDown = (ev: KeyboardEvent) => {
if (!menuShowing) {
if (ev.key === 'Enter') {
@ -77,7 +173,8 @@ export function ContextMenu<T>({
if (ev.key === 'Enter') {
if (focusedIndex !== undefined) {
onChange(menuOptions[focusedIndex].value);
const focusedOption = menuOptions[focusedIndex];
focusedOption.onClick(focusedOption.value);
}
setMenuShowing(false);
ev.stopPropagation();
@ -85,39 +182,15 @@ export function ContextMenu<T>({
}
};
const handleClose = useCallback(() => {
setMenuShowing(false);
setFocusedIndex(undefined);
}, [setMenuShowing]);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
});
useEffect(() => {
if (!menuShowing) {
return noop;
}
const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.removeEventListener('click', handleOutsideClick);
};
}, [menuShowing, handleClose, referenceElement]);
return (
<div className={theme ? themeClassName(theme) : undefined}>
@ -132,55 +205,22 @@ export function ContextMenu<T>({
ref={setReferenceElement}
type="button"
/>
{menuShowing && (
<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={() => {
onChange(option.value);
setMenuShowing(false);
}}
>
<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>
{value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
</div>
)}
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
</div>
);
}

View file

@ -28,10 +28,15 @@ import { ScrollBehavior } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError';
import { strictAssert } from '../util/assert';
import { isSorted } from '../util/isSorted';
import type { WidthBreakpoint } from './_util';
import { getConversationListWidthBreakpoint } from './_util';
import {
MIN_WIDTH,
SNAP_WIDTH,
MIN_FULL_WIDTH,
MAX_WIDTH,
getWidthFromPreferredWidth,
} from '../util/leftPaneWidth';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
@ -42,15 +47,6 @@ import type {
SaveAvatarToDiskActionType,
} from '../types/Avatar';
const MIN_WIDTH = 97;
const SNAP_WIDTH = 200;
const MIN_FULL_WIDTH = 280;
const MAX_WIDTH = 380;
strictAssert(
isSorted([MIN_WIDTH, SNAP_WIDTH, MIN_FULL_WIDTH, MAX_WIDTH]),
'Expected widths to be in the right order'
);
export enum LeftPaneMode {
Inbox,
Search,
@ -499,13 +495,6 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId
);
let width: number;
if (requiresFullWidth || preferredWidth >= SNAP_WIDTH) {
width = Math.max(preferredWidth, MIN_FULL_WIDTH);
} else {
width = MIN_WIDTH;
}
const isScrollable = helper.isScrollable();
let rowIndexToScrollTo: undefined | number;
@ -527,6 +516,10 @@ export const LeftPane: React.FC<PropsType> = ({
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
const width = getWidthFromPreferredWidth(preferredWidth, {
requiresFullWidth,
});
const widthBreakpoint = getConversationListWidthBreakpoint(width);
// We disable this lint rule because we're trying to capture bubbled events. See [the

View file

@ -22,6 +22,7 @@ const optionalText = (name: string, value: string | undefined) =>
text(name, value || '') || undefined;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areStoriesEnabled: false,
theme: ThemeType.light,
phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber),
@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
showArchivedConversations: action('showArchivedConversations'),
startComposing: action('startComposing'),
toggleProfileEditor: action('toggleProfileEditor'),
toggleStoriesView: action('toggleStoriesView'),
});
story.add('Basic', () => {
@ -68,3 +70,7 @@ story.add('Update Available', () => {
return <MainHeader {...props} />;
});
story.add('Stories', () => (
<MainHeader {...createProps({})} areStoriesEnabled />
));

View file

@ -13,6 +13,7 @@ import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types';
export type PropsType = {
areStoriesEnabled: boolean;
avatarPath?: string;
badge?: BadgeType;
color?: AvatarColorType;
@ -30,6 +31,7 @@ export type PropsType = {
startComposing: () => void;
startUpdate: () => unknown;
toggleProfileEditor: () => void;
toggleStoriesView: () => unknown;
};
type StateType = {
@ -111,6 +113,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
public override render(): JSX.Element {
const {
areStoriesEnabled,
avatarPath,
badge,
color,
@ -125,6 +128,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
theme,
title,
toggleProfileEditor,
toggleStoriesView,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
@ -204,13 +208,24 @@ export class MainHeader extends React.Component<PropsType, StateType> {
)
: null}
</Manager>
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
<div className="module-main-header__icon-container">
{areStoriesEnabled && (
<button
aria-label={i18n('stories')}
className="module-main-header__stories-icon"
onClick={toggleStoriesView}
title={i18n('stories')}
type="button"
/>
)}
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
@ -582,20 +582,22 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--text-regular',
label: i18n('MediaEditor__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'MediaEditor__icon--text-highlight',
label: i18n('MediaEditor__text--highlight'),
onClick: () => setTextStyle(TextStyle.Highlight),
value: TextStyle.Highlight,
},
{
icon: 'MediaEditor__icon--text-outline',
label: i18n('MediaEditor__text--outline'),
onClick: () => setTextStyle(TextStyle.Outline),
value: TextStyle.Outline,
},
]}
onChange={value => setTextStyle(value)}
theme={Theme.Dark}
value={textStyle}
/>
@ -636,15 +638,16 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--draw-pen',
label: i18n('MediaEditor__draw--pen'),
onClick: () => setDrawTool(DrawTool.Pen),
value: DrawTool.Pen,
},
{
icon: 'MediaEditor__icon--draw-highlighter',
label: i18n('MediaEditor__draw--highlighter'),
onClick: () => setDrawTool(DrawTool.Highlighter),
value: DrawTool.Highlighter,
},
]}
onChange={value => setDrawTool(value)}
theme={Theme.Dark}
value={drawTool}
/>
@ -664,25 +667,28 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--width-thin',
label: i18n('MediaEditor__draw--thin'),
onClick: () => setDrawWidth(DrawWidth.Thin),
value: DrawWidth.Thin,
},
{
icon: 'MediaEditor__icon--width-regular',
label: i18n('MediaEditor__draw--regular'),
onClick: () => setDrawWidth(DrawWidth.Regular),
value: DrawWidth.Regular,
},
{
icon: 'MediaEditor__icon--width-medium',
label: i18n('MediaEditor__draw--medium'),
onClick: () => setDrawWidth(DrawWidth.Medium),
value: DrawWidth.Medium,
},
{
icon: 'MediaEditor__icon--width-heavy',
label: i18n('MediaEditor__draw--heavy'),
onClick: () => setDrawWidth(DrawWidth.Heavy),
value: DrawWidth.Heavy,
},
]}
onChange={value => setDrawWidth(value)}
theme={Theme.Dark}
value={drawWidth}
/>

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement, ReactNode } from 'react';
@ -25,6 +25,7 @@ type PropsType = {
moduleClassName?: string;
onClose?: () => void;
title?: ReactNode;
useFocusTrap?: boolean;
};
type ModalPropsType = PropsType & {
@ -44,6 +45,7 @@ export function Modal({
onClose = noop,
title,
theme,
useFocusTrap,
}: Readonly<ModalPropsType>): ReactElement {
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
@ -55,10 +57,12 @@ export function Modal({
return (
<ModalHost
moduleClassName={moduleClassName}
noMouseClose={noMouseClose}
onClose={close}
overlayStyles={overlayStyles}
theme={theme}
useFocusTrap={useFocusTrap}
>
<animated.div style={modalStyles}>
<ModalWindow

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react';
@ -10,28 +10,33 @@ import classNames from 'classnames';
import type { ModalConfigType } from '../hooks/useAnimated';
import type { Theme } from '../util/theme';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { themeClassName } from '../util/theme';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = Readonly<{
children: React.ReactElement;
moduleClassName?: string;
noMouseClose?: boolean;
onClose: () => unknown;
onEscape?: () => unknown;
onTopOfEverything?: boolean;
overlayStyles?: SpringValues<ModalConfigType>;
theme?: Theme;
onTopOfEverything?: boolean;
useFocusTrap?: boolean;
}>;
export const ModalHost = React.memo(
({
children,
moduleClassName,
noMouseClose,
onClose,
onEscape,
theme,
overlayStyles,
onTopOfEverything,
overlayStyles,
theme,
useFocusTrap = true,
}: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [isMouseDown, setIsMouseDown] = React.useState(false);
@ -74,26 +79,35 @@ export const ModalHost = React.memo(
theme ? themeClassName(theme) : undefined,
onTopOfEverything ? 'module-modal-host--on-top-of-everything' : undefined,
]);
const getClassName = getClassNamesFor('module-modal-host', moduleClassName);
const modalContent = (
<div className={className}>
<animated.div
role="presentation"
className={getClassName('__overlay')}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className={getClassName('__overlay-container')}>{children}</div>
</div>
);
return root
? createPortal(
<FocusTrap
focusTrapOptions={{
// This is alright because the overlay covers the entire screen
allowOutsideClick: false,
}}
>
<div className={className}>
<animated.div
role="presentation"
className="module-modal-host__overlay"
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className="module-modal-host__container">{children}</div>
</div>
</FocusTrap>,
useFocusTrap ? (
<FocusTrap
focusTrapOptions={{
// This is alright because the overlay covers the entire screen
allowOutsideClick: false,
}}
>
{modalContent}
</FocusTrap>
) : (
modalContent
),
root
)
: null;

View file

@ -0,0 +1,124 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { v4 as uuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../types/Attachment';
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';
import * as durations from '../util/durations';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Stories', module);
function createStory({
attachment,
group,
timestamp,
}: {
attachment?: AttachmentType;
group?: { title: string };
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,
},
],
};
}
const getDefaultProps = (): PropsType => ({
hiddenStories: [],
i18n,
openConversationInternal: action('openConversationInternal'),
preferredWidthFromStorage: 380,
renderStoryViewer: () => <div />,
stories: [
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
timestamp: Date.now() - 2 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
),
}),
timestamp: Date.now() - 5 * durations.MINUTE,
}),
createStory({
group: { title: 'BBQ in the park' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
timestamp: Date.now() - 65 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
timestamp: Date.now() - 92 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-1-64-64.jpg'),
}),
timestamp: Date.now() - 164 * durations.MINUTE,
}),
createStory({
group: { title: 'Breaking Signal for Science' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-2-64-64.jpg'),
}),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-3-64-64.jpg'),
}),
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
toggleHideStories: action('toggleHideStories'),
toggleStoriesView: action('toggleStoriesView'),
});
story.add('Blank', () => <Stories {...getDefaultProps()} stories={[]} />);
story.add('Many', () => <Stories {...getDefaultProps()} />);

112
ts/components/Stories.tsx Normal file
View file

@ -0,0 +1,112 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme';
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
preferredWidthFromStorage: number;
openConversationInternal: (_: { conversationId: string }) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
};
type ViewingStoryType = {
conversationId: string;
stories: Array<StoryViewType>;
};
export const Stories = ({
hiddenStories,
i18n,
openConversationInternal,
preferredWidthFromStorage,
renderStoryViewer,
stories,
toggleHideStories,
toggleStoriesView,
}: PropsType): JSX.Element => {
const [storiesToView, setStoriesToView] = useState<
undefined | ViewingStoryType
>();
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
requiresFullWidth: true,
});
return (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
{storiesToView &&
renderStoryViewer({
conversationId: storiesToView.conversationId,
onClose: () => setStoriesToView(undefined),
onNextUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
);
if (storyIndex >= stories.length - 1) {
setStoriesToView(undefined);
return;
}
const nextStory = stories[storyIndex + 1];
setStoriesToView({
conversationId: nextStory.conversationId,
stories: nextStory.stories,
});
},
onPrevUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
);
if (storyIndex === 0) {
setStoriesToView(undefined);
return;
}
const prevStory = stories[storyIndex - 1];
setStoriesToView({
conversationId: prevStory.conversationId,
stories: prevStory.stories,
});
},
stories: storiesToView.stories,
})}
<div className="Stories__pane" style={{ width }}>
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
onBack={toggleStoriesView}
onStoryClicked={conversationId => {
const storyIndex = stories.findIndex(
x => x.conversationId === conversationId
);
const foundStory = stories[storyIndex];
if (foundStory) {
setStoriesToView({
conversationId,
stories: foundStory.stories,
});
}
}}
openConversationInternal={openConversationInternal}
stories={stories}
toggleHideStories={toggleHideStories}
/>
</div>
<div className="Stories__placeholder">
<div className="Stories__placeholder__stories" />
{i18n('Stories__placeholder--text')}
</div>
</div>
);
};

View file

@ -0,0 +1,124 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FuseOptions } from 'fuse.js';
import Fuse from 'fuse.js';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
const FUSE_OPTIONS: FuseOptions<ConversationStoryType> = {
getFn: (obj, path) => {
if (path === 'searchNames') {
return obj.stories.flatMap((story: StoryViewType) => [
story.sender.title,
story.sender.name,
]);
}
return obj.group?.title;
},
keys: [
{
name: 'searchNames',
weight: 1,
},
{
name: 'group',
weight: 1,
},
],
threshold: 0.1,
tokenize: true,
};
function search(
stories: ReadonlyArray<ConversationStoryType>,
searchTerm: string
): Array<ConversationStoryType> {
return new Fuse<ConversationStoryType>(stories, FUSE_OPTIONS).search(
searchTerm
);
}
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
onBack: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
openConversationInternal: (_: { conversationId: string }) => unknown;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
};
export const StoriesPane = ({
i18n,
onBack,
onStoryClicked,
openConversationInternal,
stories,
toggleHideStories,
}: PropsType): JSX.Element => {
const [searchTerm, setSearchTerm] = useState('');
const [renderedStories, setRenderedStories] =
useState<Array<ConversationStoryType>>(stories);
useEffect(() => {
if (searchTerm) {
setRenderedStories(search(stories, searchTerm));
} else {
setRenderedStories(stories);
}
}, [searchTerm, stories]);
return (
<>
<div className="Stories__pane__header">
<button
aria-label={i18n('back')}
className="Stories__pane__header--back"
onClick={onBack}
type="button"
/>
<div className="Stories__pane__header--title">
{i18n('Stories__title')}
</div>
</div>
<SearchInput
i18n={i18n}
moduleClassName="Stories__search"
onChange={event => {
setSearchTerm(event.target.value);
}}
placeholder={i18n('search')}
value={searchTerm}
/>
<div
className={classNames('Stories__pane__list', {
'Stories__pane__list--empty': !stories.length,
})}
>
{renderedStories.map(story => (
<StoryListItem
key={story.stories[0].timestamp}
i18n={i18n}
onClick={() => {
onStoryClicked(story.conversationId);
}}
onHideStory={() => {
toggleHideStories(story.stories[0].sender.id);
}}
onGoToConversation={conversationId => {
openConversationInternal({ conversationId });
}}
story={story.stories[0]}
/>
))}
{!stories.length && i18n('Stories__list-empty')}
</div>
</>
);
};

View file

@ -0,0 +1,77 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryListItem';
import { StoryListItem } from './StoryListItem';
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';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryListItem', module);
function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
story: {
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
};
}
story.add('My Story', () => (
<StoryListItem
{...getDefaultProps()}
story={{
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
/>
));
story.add('My Story (many)', () => (
<StoryListItem
{...getDefaultProps()}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
hasMultiple
/>
));
story.add("Someone's story", () => (
<StoryListItem
{...getDefaultProps()}
group={{ 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(),
}}
/>
));

View file

@ -0,0 +1,240 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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 { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu';
import { getAvatarColor } from '../types/Colors';
import { MessageTimestamp } from './conversation/MessageTimestamp';
export type ConversationStoryType = {
conversationId: string;
group?: Pick<ConversationType, 'title'>;
hasMultiple?: boolean;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>;
};
export type StoryViewType = {
attachment?: AttachmentType;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean;
isUnread?: boolean;
messageId: string;
selectedReaction?: string;
sender: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'firstName'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
timestamp: number;
};
export type PropsType = Pick<
ConversationStoryType,
'group' | 'hasMultiple' | 'isHidden'
> & {
i18n: LocalizerType;
onClick: () => unknown;
onGoToConversation?: (conversationId: string) => unknown;
onHideStory?: (conversationId: string) => unknown;
story: StoryViewType;
};
export const StoryListItem = ({
group,
hasMultiple,
i18n,
isHidden,
onClick,
onGoToConversation,
onHideStory,
story,
}: PropsType): JSX.Element => {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const {
attachment,
hasReplies,
hasRepliesFromSelf,
isUnread,
sender,
timestamp,
} = story;
const {
acceptedMessageRequest,
avatarPath,
color,
firstName,
isMe,
name,
profileName,
sharedGroupNames,
title,
} = sender;
let avatarStoryRing: AvatarStoryRing | undefined;
if (attachment) {
avatarStoryRing = isUnread ? AvatarStoryRing.Unread : AvatarStoryRing.Read;
}
let repliesElement: JSX.Element | undefined;
if (hasRepliesFromSelf) {
repliesElement = <div className="StoryListItem__info--replies--self" />;
} else if (hasReplies) {
repliesElement = <div className="StoryListItem__info--replies--others" />;
}
return (
<>
<button
aria-label={i18n('StoryListItem__label')}
className={classNames('StoryListItem', {
'StoryListItem--hidden': isHidden,
})}
onClick={onClick}
onContextMenu={ev => {
ev.preventDefault();
ev.stopPropagation();
if (!isMe) {
setIsShowingContextMenu(true);
}
}}
ref={setReferenceElement}
type="button"
>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
sharedGroupNames={sharedGroupNames}
avatarPath={avatarPath}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
name={name}
profileName={profileName}
size={AvatarSize.FORTY_EIGHT}
storyRing={avatarStoryRing}
title={title}
/>
<div className="StoryListItem__info">
{isMe ? (
<>
<div className="StoryListItem__info--title">
{i18n('Stories__mine')}
</div>
{!attachment && (
<div className="StoryListItem__info--timestamp">
{i18n('Stories__add')}
</div>
)}
</>
) : (
<>
<div className="StoryListItem__info--title">
{group
? i18n('Stories__from-to-group', {
name: title,
group: group.title,
})
: title}
</div>
<MessageTimestamp
i18n={i18n}
module="StoryListItem__info--timestamp"
now={Date.now()}
timestamp={timestamp}
/>
</>
)}
{repliesElement}
</div>
<div
className={classNames('StoryListItem__previews', {
'StoryListItem__previews--multiple': hasMultiple,
})}
>
{!attachment && isMe && (
<div
aria-label={i18n('Stories__add')}
className="StoryListItem__previews--add StoryListItem__previews--image"
/>
)}
{hasMultiple && <div className="StoryListItem__previews--more" />}
{attachment && (
<div
className="StoryListItem__previews--image"
style={{
backgroundImage: `url("${attachment.thumbnail?.url}")`,
}}
/>
)}
</div>
</button>
<ContextMenuPopper
isMenuShowing={isShowingContextMenu}
menuOptions={[
{
icon: 'StoryListItem__icon--hide',
label: i18n('StoryListItem__hide'),
onClick: () => {
setHasConfirmHideStory(true);
},
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => {
onGoToConversation?.(sender.id);
},
},
]}
onClose={() => setIsShowingContextMenu(false)}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
referenceElement={referenceElement}
/>
{hasConfirmHideStory && (
<ConfirmationDialog
actions={[
{
action: () => onHideStory?.(sender.id),
style: 'affirmative',
text: i18n('StoryListItem__hide-modal--confirm'),
},
]}
i18n={i18n}
onClose={() => {
setHasConfirmHideStory(false);
}}
>
{i18n('StoryListItem__hide-modal--body', [String(firstName)])}
</ConfirmationDialog>
)}
</>
);
};

View file

@ -0,0 +1,121 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewer';
import { StoryViewer } from './StoryViewer';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryViewer', module);
function getDefaultProps(): PropsType {
return {
getPreferredBadge: () => undefined,
group: undefined,
i18n,
markStoryRead: action('markStoryRead'),
onClose: action('onClose'),
onNextUserStories: action('onNextUserStories'),
onPrevUserStories: action('onPrevUserStories'),
onReactToStory: action('onReactToStory'),
onReplyToStory: action('onReplyToStory'),
onSetSkinTone: action('onSetSkinTone'),
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
renderEmojiPicker: () => <div />,
replies: Math.floor(Math.random() * 20),
stories: [
{
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
],
views: Math.floor(Math.random() * 20),
};
}
story.add("Someone's story", () => <StoryViewer {...getDefaultProps()} />);
story.add('Wide story', () => (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
));
story.add('In a group', () => (
<StoryViewer
{...getDefaultProps()}
group={getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
title: 'Family Group',
type: 'group',
})}
/>
));
story.add('Multi story', () => {
const sender = getDefaultConversation();
return (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender,
timestamp: Date.now(),
},
{
attachment: fakeAttachment({
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
messageId: '456',
sender,
timestamp: Date.now() - 3600,
},
]}
/>
);
});
story.add('So many stories', () => {
const sender = getDefaultConversation();
return (
<StoryViewer
{...getDefaultProps()}
stories={Array(20).fill({
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender,
timestamp: Date.now(),
})}
/>
);
});

View file

@ -0,0 +1,337 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import { useSpring, animated, to } from '@react-spring/web';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { StoryViewType } from './StoryListItem';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { getAvatarColor } from '../types/Colors';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
const STORY_DURATION = 5000;
export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
group?: ConversationType;
i18n: LocalizerType;
markStoryRead: (mId: string) => unknown;
onClose: () => unknown;
onNextUserStories: () => unknown;
onPrevUserStories: () => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: (
message: string,
mentions: Array<BodyRangeType>,
timestamp: number,
story: StoryViewType
) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
recentEmojis?: Array<string>;
replies?: number;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
skinTone?: number;
stories: Array<StoryViewType>;
views?: number;
};
export const StoryViewer = ({
getPreferredBadge,
group,
i18n,
markStoryRead,
onClose,
onNextUserStories,
onPrevUserStories,
onReactToStory,
onReplyToStory,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
recentEmojis,
renderEmojiPicker,
replies,
skinTone,
stories,
views,
}: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const visibleStory = stories[currentStoryIndex];
const {
acceptedMessageRequest,
avatarPath,
color,
isMe,
name,
profileName,
sharedGroupNames,
title,
} = visibleStory.sender;
const [hasReplyModal, setHasReplyModal] = useState(false);
const onEscape = useCallback(() => {
if (hasReplyModal) {
setHasReplyModal(false);
} else {
onClose();
}
}, [hasReplyModal, onClose]);
useEscapeHandling(onEscape);
const showNextStory = useCallback(() => {
// Either we show the next story in the current user's stories or we ask
// for the next user's stories.
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(currentStoryIndex + 1);
} else {
onNextUserStories();
}
}, [currentStoryIndex, onNextUserStories, stories.length]);
const showPrevStory = useCallback(() => {
// Either we show the previous story in the current user's stories or we ask
// for the prior user's stories.
if (currentStoryIndex === 0) {
onPrevUserStories();
} else {
setCurrentStoryIndex(currentStoryIndex - 1);
}
}, [currentStoryIndex, onPrevUserStories]);
const [styles, spring] = useSpring(() => ({
config: {
duration: STORY_DURATION,
},
from: { width: 0 },
to: { width: 100 },
loop: true,
}));
// Adding "currentStoryIndex" to the dependency list here to explcitly signal
// that this useEffect should run whenever the story changes.
useEffect(() => {
spring.start({
from: { width: 0 },
to: { width: 100 },
onRest: showNextStory,
});
}, [currentStoryIndex, showNextStory, spring]);
useEffect(() => {
if (hasReplyModal) {
spring.pause();
} else {
spring.resume();
}
}, [hasReplyModal, spring]);
useEffect(() => {
markStoryRead(visibleStory.messageId);
}, [markStoryRead, visibleStory.messageId]);
const navigateStories = useCallback(
(ev: KeyboardEvent) => {
if (ev.key === 'ArrowRight') {
showNextStory();
ev.preventDefault();
ev.stopPropagation();
} else if (ev.key === 'ArrowLeft') {
showPrevStory();
ev.preventDefault();
ev.stopPropagation();
}
},
[showPrevStory, showNextStory]
);
useEffect(() => {
document.addEventListener('keydown', navigateStories);
return () => {
document.removeEventListener('keydown', navigateStories);
};
}, [navigateStories]);
return (
<div className="StoryViewer">
<div className="StoryViewer__overlay" />
<div className="StoryViewer__content">
<button
aria-label={i18n('MyStories__more')}
className="StoryViewer__more"
type="button"
/>
<button
aria-label={i18n('close')}
className="StoryViewer__close-button"
onClick={onClose}
type="button"
/>
<div className="StoryViewer__container">
{visibleStory.attachment && (
<img
alt={i18n('lightboxImageAlt')}
className="StoryViewer__story"
src={visibleStory.attachment.url}
/>
)}
<div className="StoryViewer__meta">
<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}
module="StoryViewer__meta--timestamp"
now={Date.now()}
timestamp={visibleStory.timestamp}
/>
<div className="StoryViewer__progress">
{stories.map((story, index) => (
<div
className="StoryViewer__progress--container"
key={story.timestamp}
>
{currentStoryIndex === index ? (
<animated.div
className="StoryViewer__progress--bar"
style={{
width: to([styles.width], width => `${width}%`),
}}
/>
) : (
<div
className="StoryViewer__progress--bar"
style={{
width: currentStoryIndex < index ? '0%' : '100%',
}}
/>
)}
</div>
))}
</div>
</div>
</div>
<div className="StoryViewer__actions">
{isMe ? (
<>
{views &&
(views === 1 ? (
<Intl
i18n={i18n}
id="MyStories__views--singular"
components={[<strong>{views}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__views--plural"
components={[<strong>{views}</strong>]}
/>
))}
{views && replies && ' '}
{replies &&
(replies === 1 ? (
<Intl
i18n={i18n}
id="MyStories__replies--singular"
components={[<strong>{replies}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__replies--plural"
components={[<strong>{replies}</strong>]}
/>
))}
</>
) : (
<button
className="StoryViewer__reply"
onClick={() => setHasReplyModal(true)}
type="button"
>
{i18n('StoryViewer__reply')}
</button>
)}
</div>
</div>
{hasReplyModal && (
<StoryViewsNRepliesModal
authorTitle={title}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isMyStory={isMe}
onClose={() => setHasReplyModal(false)}
onReact={emoji => {
onReactToStory(emoji, visibleStory);
}}
onReply={(message, mentions, timestamp) => {
setHasReplyModal(false);
onReplyToStory(message, mentions, timestamp, visibleStory);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong}
onUseEmoji={onUseEmoji}
preferredReactionEmoji={preferredReactionEmoji}
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
replies={[]}
skinTone={skinTone}
storyPreviewAttachment={visibleStory.attachment}
views={[]}
/>
)}
</div>
);
};

View file

@ -0,0 +1,125 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewsNRepliesModal';
import * as durations from '../util/durations';
import enMessages from '../../_locales/en/messages.json';
import { IMAGE_JPEG } from '../types/MIME';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryViewsNRepliesModal', module);
function getDefaultProps(): PropsType {
return {
authorTitle: getDefaultConversation().title,
getPreferredBadge: () => undefined,
i18n,
isMyStory: false,
onClose: action('onClose'),
onSetSkinTone: action('onSetSkinTone'),
onReact: action('onReact'),
onReply: action('onReply'),
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
renderEmojiPicker: () => <div />,
replies: [],
storyPreviewAttachment: fakeAttachment({
thumbnail: {
contentType: IMAGE_JPEG,
height: 64,
objectUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg',
path: '',
width: 40,
},
}),
views: [],
};
}
function getViewsAndReplies() {
const p1 = getDefaultConversation();
const p2 = getDefaultConversation();
const p3 = getDefaultConversation();
const p4 = getDefaultConversation();
const p5 = getDefaultConversation();
const views = [
{
...p1,
timestamp: Date.now() - 20 * durations.MINUTE,
},
{
...p2,
timestamp: Date.now() - 25 * durations.MINUTE,
},
{
...p3,
timestamp: Date.now() - 15 * durations.MINUTE,
},
{
...p4,
timestamp: Date.now() - 5 * durations.MINUTE,
},
{
...p5,
timestamp: Date.now() - 30 * durations.MINUTE,
},
];
const replies = [
{
...p2,
body: 'So cute ❤️',
timestamp: Date.now() - 24 * durations.MINUTE,
},
{
...p3,
body: "That's awesome",
timestamp: Date.now() - 13 * durations.MINUTE,
},
{
...p4,
reactionEmoji: '❤️',
timestamp: Date.now() - 5 * durations.MINUTE,
},
];
return {
views,
replies,
};
}
story.add('Can reply', () => (
<StoryViewsNRepliesModal {...getDefaultProps()} />
));
story.add('Views only', () => (
<StoryViewsNRepliesModal
{...getDefaultProps()}
isMyStory
views={getViewsAndReplies().views}
/>
));
story.add('In a group', () => {
const { views, replies } = getViewsAndReplies();
return (
<StoryViewsNRepliesModal
{...getDefaultProps()}
replies={replies}
views={views}
/>
);
});

View file

@ -0,0 +1,388 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import type { AttachmentType } from '../types/Attachment';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ContactNameColorType } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import { Avatar, AvatarSize } from './Avatar';
import { CompositionInput } from './CompositionInput';
import { ContactName } from './conversation/ContactName';
import { EmojiButton } from './emoji/EmojiButton';
import { Emojify } from './conversation/Emojify';
import { MessageBody } from './conversation/MessageBody';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { Modal } from './Modal';
import { Quote } from './conversation/Quote';
import { ReactionPicker } from './conversation/ReactionPicker';
import { Tabs } from './Tabs';
import { ThemeType } from '../types/Util';
import { getAvatarColor } from '../types/Colors';
type ReplyType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
body?: string;
contactNameColor?: ContactNameColorType;
reactionEmoji?: string;
timestamp: number;
};
type ViewType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
contactNameColor?: ContactNameColorType;
timestamp: number;
};
enum Tab {
Replies = 'Replies',
Views = 'Views',
}
export type PropsType = {
authorTitle: string;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isMyStory?: boolean;
onClose: () => unknown;
onReact: (emoji: string) => unknown;
onReply: (
message: string,
mentions: Array<BodyRangeType>,
timestamp: number
) => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
recentEmojis?: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replies: Array<ReplyType>;
skinTone?: number;
storyPreviewAttachment?: AttachmentType;
views: Array<ViewType>;
};
export const StoryViewsNRepliesModal = ({
authorTitle,
getPreferredBadge,
i18n,
isMyStory,
onClose,
onReact,
onReply,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
recentEmojis,
renderEmojiPicker,
replies,
skinTone,
storyPreviewAttachment,
views,
}: PropsType): JSX.Element => {
const inputApiRef = React.useRef<InputApi | undefined>();
const [messageBodyText, setMessageBodyText] = useState('');
const [showReactionPicker, setShowReactionPicker] = useState(false);
const focusComposer = useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onUseEmoji(e);
}
},
[inputApiRef, onUseEmoji]
);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
});
let composerElement: JSX.Element | undefined;
if (!isMyStory) {
composerElement = (
<div className="StoryViewsNRepliesModal__compose-container">
<div className="StoryViewsNRepliesModal__composer">
{!replies.length && (
<Quote
authorTitle={authorTitle}
conversationColor="steel"
i18n={i18n}
isFromMe={false}
isViewOnce={false}
rawAttachment={storyPreviewAttachment}
referencedMessageNotFound={false}
text={i18n('message--getNotificationText--text-with-emoji', {
text: i18n('message--getNotificationText--photo'),
emoji: '📷',
})}
/>
)}
<CompositionInput
draftText={messageBodyText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={messageText => {
setMessageBodyText(messageText);
}}
onPickEmoji={insertEmoji}
onSubmit={onReply}
onTextTooLong={onTextTooLong}
placeholder={i18n('StoryViewsNRepliesModal__placeholder')}
theme={ThemeType.dark}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onClose={focusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
</div>
<button
aria-label={i18n('StoryViewsNRepliesModal__react')}
className="StoryViewsNRepliesModal__react"
onClick={() => {
setShowReactionPicker(!showReactionPicker);
}}
ref={setReferenceElement}
type="button"
/>
{showReactionPicker && (
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ReactionPicker
i18n={i18n}
onClose={() => {
setShowReactionPicker(false);
}}
onPick={emoji => {
setShowReactionPicker(false);
onReact(emoji);
}}
onSetSkinTone={onSetSkinTone}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
)}
</div>
);
}
const repliesElement = replies.length ? (
<div className="StoryViewsNRepliesModal__replies">
{replies.map(reply =>
reply.reactionEmoji ? (
<div className="StoryViewsNRepliesModal__reaction">
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
avatarPath={reply.avatarPath}
badge={undefined}
color={getAvatarColor(reply.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.isMe)}
name={reply.name}
profileName={reply.profileName}
sharedGroupNames={reply.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={reply.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.title}
/>
</div>
{i18n('StoryViewsNRepliesModal__reacted')}
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__reply--timestamp"
now={Date.now()}
timestamp={reply.timestamp}
/>
</div>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
) : (
<div className="StoryViewsNRepliesModal__reply">
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
avatarPath={reply.avatarPath}
badge={undefined}
color={getAvatarColor(reply.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.isMe)}
name={reply.name}
profileName={reply.profileName}
sharedGroupNames={reply.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={reply.title}
/>
<div className="StoryViewsNRepliesModal__message-bubble">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.title}
/>
</div>
<MessageBody i18n={i18n} text={String(reply.body)} />
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__reply--timestamp"
now={Date.now()}
timestamp={reply.timestamp}
/>
</div>
</div>
)
)}
</div>
) : undefined;
const viewsElement = views.length ? (
<div className="StoryViewsNRepliesModal__views">
{views.map(view => (
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
<div>
<Avatar
acceptedMessageRequest={view.acceptedMessageRequest}
avatarPath={view.avatarPath}
badge={undefined}
color={getAvatarColor(view.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(view.isMe)}
name={view.name}
profileName={view.profileName}
sharedGroupNames={view.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={view.title}
/>
<span className="StoryViewsNRepliesModal__view--name">
<ContactName
contactNameColor={view.contactNameColor}
title={view.title}
/>
</span>
</div>
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__view--timestamp"
now={Date.now()}
timestamp={view.timestamp}
/>
</div>
))}
</div>
) : undefined;
const tabsElement =
views.length && replies.length ? (
<Tabs
initialSelectedTab={Tab.Views}
moduleClassName="StoryViewsNRepliesModal__tabs"
tabs={[
{
id: Tab.Views,
label: i18n('StoryViewsNRepliesModal__tab--views'),
},
{
id: Tab.Replies,
label: i18n('StoryViewsNRepliesModal__tab--replies'),
},
]}
>
{({ selectedTab }) => (
<>
{selectedTab === Tab.Views && viewsElement}
{selectedTab === Tab.Replies && (
<>
{repliesElement}
{composerElement}
</>
)}
</>
)}
</Tabs>
) : undefined;
const hasOnlyViewsElement =
viewsElement && !repliesElement && !composerElement;
return (
<Modal
i18n={i18n}
moduleClassName={classNames('StoryViewsNRepliesModal', {
'StoryViewsNRepliesModal--group': Boolean(
views.length && replies.length
),
})}
onClose={onClose}
useFocusTrap={!hasOnlyViewsElement}
>
{tabsElement || (
<>
{viewsElement}
{repliesElement}
{composerElement}
</>
)}
</Modal>
);
};

View file

@ -1,24 +1,15 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent, ReactNode } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { assert } from '../util/assert';
import { getClassNamesFor } from '../util/getClassNamesFor';
import type { ReactNode } from 'react';
import React from 'react';
type Tab = {
id: string;
label: string;
};
import type { TabsOptionsType } from '../hooks/useTabs';
import { useTabs } from '../hooks/useTabs';
type PropsType = {
children: (renderProps: { selectedTab: string }) => ReactNode;
initialSelectedTab?: string;
moduleClassName?: string;
onTabChange?: (selectedTab: string) => unknown;
tabs: Array<Tab>;
};
} & TabsOptionsType;
export const Tabs = ({
children,
@ -27,42 +18,16 @@ export const Tabs = ({
onTabChange,
tabs,
}: PropsType): JSX.Element => {
assert(tabs.length, 'Tabs needs more than 1 tab present');
const [selectedTab, setSelectedTab] = useState<string>(
initialSelectedTab || tabs[0].id
);
const getClassName = getClassNamesFor('Tabs', moduleClassName);
const { selectedTab, tabsHeaderElement } = useTabs({
initialSelectedTab,
moduleClassName,
onTabChange,
tabs,
});
return (
<>
<div className={getClassName('')}>
{tabs.map(({ id, label }) => (
<div
className={classNames(
getClassName('__tab'),
selectedTab === id && getClassName('__tab--selected')
)}
key={id}
onClick={() => {
setSelectedTab(id);
onTabChange?.(id);
}}
onKeyUp={(e: KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(id);
e.preventDefault();
e.stopPropagation();
}
}}
role="tab"
tabIndex={0}
>
{label}
</div>
))}
</div>
{tabsHeaderElement}
{children({ selectedTab })}
</>
);

View file

@ -213,6 +213,9 @@ story.add('Image Only', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -228,6 +231,9 @@ story.add('Image Attachment', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -270,6 +276,9 @@ story.add('Video Only', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -288,6 +297,9 @@ story.add('Video Attachment', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},

View file

@ -1,4 +1,4 @@
// Copyright 2018-2021 Signal Messenger, LLC
// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
@ -10,6 +10,7 @@ import * as MIME from '../../types/MIME';
import * as GoogleChrome from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody';
import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
import type { BodyRangesType, LocalizerType } from '../../types/Util';
import type {
ConversationColorType,
@ -40,19 +41,10 @@ type State = {
imageBroken: boolean;
};
export type QuotedAttachmentType = {
contentType: MIME.MIMEType;
fileName?: string;
/** Not included in protobuf */
isVoiceMessage: boolean;
thumbnail?: Attachment;
};
type Attachment = {
contentType: MIME.MIMEType;
/** Not included in protobuf, and is loaded asynchronously */
objectUrl?: string;
};
export type QuotedAttachmentType = Pick<
AttachmentType,
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail'
>;
function validateQuote(quote: Props): boolean {
if (quote.text) {
@ -75,12 +67,12 @@ function getAttachment(
: undefined;
}
function getObjectUrl(thumbnail: Attachment | undefined): string | undefined {
if (thumbnail && thumbnail.objectUrl) {
return thumbnail.objectUrl;
function getUrl(thumbnail?: ThumbnailType): string | undefined {
if (!thumbnail) {
return;
}
return undefined;
return thumbnail.objectUrl || thumbnail.url;
}
function getTypeLabel({
@ -92,7 +84,7 @@ function getTypeLabel({
i18n: LocalizerType;
isViewOnce?: boolean;
contentType: MIME.MIMEType;
isVoiceMessage: boolean;
isVoiceMessage?: boolean;
}): string | undefined {
if (GoogleChrome.isVideoTypeSupported(contentType)) {
if (isViewOnce) {
@ -249,20 +241,20 @@ export class Quote extends React.Component<Props, State> {
}
const { contentType, thumbnail } = attachment;
const objectUrl = getObjectUrl(thumbnail);
const url = getUrl(thumbnail);
if (isViewOnce) {
return this.renderIcon('view-once');
}
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return objectUrl && !imageBroken
? this.renderImage(objectUrl, 'play')
return url && !imageBroken
? this.renderImage(url, 'play')
: this.renderIcon('movie');
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return objectUrl && !imageBroken
? this.renderImage(objectUrl)
return url && !imageBroken
? this.renderImage(url)
: this.renderIcon('image');
}
if (MIME.isAudio(contentType)) {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -12,6 +12,7 @@ import { EmojiPicker } from './EmojiPicker';
import type { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly className?: string;
readonly closeOnPick?: boolean;
readonly emoji?: string;
readonly i18n: LocalizerType;
@ -26,6 +27,7 @@ export type Props = OwnProps &
export const EmojiButton = React.memo(
({
className,
closeOnPick,
emoji,
i18n,
@ -117,7 +119,7 @@ export const EmojiButton = React.memo(
type="button"
ref={ref}
onClick={handleClickButton}
className={classNames({
className={classNames(className, {
'module-emoji-button__button': true,
'module-emoji-button__button--active': open,
'module-emoji-button__button--has-emoji': Boolean(emoji),