Process incoming story messages
This commit is contained in:
parent
df7cdfacc7
commit
eb91eb6fec
84 changed files with 4382 additions and 652 deletions
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />
|
||||
));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
124
ts/components/Stories.stories.tsx
Normal file
124
ts/components/Stories.stories.tsx
Normal 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
112
ts/components/Stories.tsx
Normal 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>
|
||||
);
|
||||
};
|
124
ts/components/StoriesPane.tsx
Normal file
124
ts/components/StoriesPane.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
77
ts/components/StoryListItem.stories.tsx
Normal file
77
ts/components/StoryListItem.stories.tsx
Normal 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(),
|
||||
}}
|
||||
/>
|
||||
));
|
240
ts/components/StoryListItem.tsx
Normal file
240
ts/components/StoryListItem.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
121
ts/components/StoryViewer.stories.tsx
Normal file
121
ts/components/StoryViewer.stories.tsx
Normal 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(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
337
ts/components/StoryViewer.tsx
Normal file
337
ts/components/StoryViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
125
ts/components/StoryViewsNRepliesModal.stories.tsx
Normal file
125
ts/components/StoryViewsNRepliesModal.stories.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
388
ts/components/StoryViewsNRepliesModal.tsx
Normal file
388
ts/components/StoryViewsNRepliesModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 })}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue