Adds debugging information to stories

This commit is contained in:
Josh Perez 2022-07-25 14:55:44 -04:00 committed by GitHub
parent badf9d7dda
commit 06476de6c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1089 additions and 530 deletions

View File

@ -7385,6 +7385,30 @@
"message": "Unmute", "message": "Unmute",
"description": "Aria label for unmuting stories" "description": "Aria label for unmuting stories"
}, },
"StoryDetailsModal__sent-time": {
"message": "Sent $time$",
"description": "Sent timestamp",
"placeholders": {
"time": {
"content": "$1",
"example": "Today 5:33pm"
}
}
},
"StoryDetailsModal__file-size": {
"message": "File size $size$",
"description": "File size description",
"placeholders": {
"size": {
"content": "$1",
"example": "100kb"
}
}
},
"StoryDetailsModal__copy-timestamp": {
"message": "Copy timestamp",
"description": "Context menu item to help debugging"
},
"StoryViewsNRepliesModal__no-replies": { "StoryViewsNRepliesModal__no-replies": {
"message": "No replies yet", "message": "No replies yet",
"description": "Placeholder text for when there are no replies" "description": "Placeholder text for when there are no replies"
@ -7421,6 +7445,14 @@
"message": "Go to chat", "message": "Go to chat",
"description": "Label for menu item to go to conversation" "description": "Label for menu item to go to conversation"
}, },
"StoryListItem__delete": {
"message": "Delete",
"description": "Label for menu item to delete a story"
},
"StoryListItem__info": {
"message": "Info",
"description": "Label for menu item to get a story's information"
},
"StoryListItem__hide-modal--body": { "StoryListItem__hide-modal--body": {
"message": "Hide story? New story updates from $name$ wont appear at the top of the stories list anymore.", "message": "Hide story? New story updates from $name$ wont appear at the top of the stories list anymore.",
"description": "Body for the confirmation dialog for hiding a story" "description": "Body for the confirmation dialog for hiding a story"

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m15.95 19.5c-.1179.6977-.4787 1.3312-1.0185 1.7887s-1.2239.7094-1.9315.7113h-7c-.79565 0-1.55871-.3161-2.12132-.8787s-.87868-1.3257-.87868-2.1213v-10c0-.79565.31607-1.55871.87868-2.12132s1.32567-.87868 2.12132-.87868h.5v1.5h-.5c-.39782 0-.77936.15804-1.06066.43934s-.43934.66284-.43934 1.06066v10c0 .3978.15804.7794.43934 1.0607s.66284.4393 1.06066.4393h7c.3091-.0013.6103-.098.8623-.277.2521-.179.4427-.4315.5457-.723zm2.05-16h-7c-.3978 0-.7794.15804-1.06066.43934-.2813.2813-.43934.66284-.43934 1.06066v10c0 .3978.15804.7794.43934 1.0607.28126.2813.66286.4393 1.06066.4393h7c.3978 0 .7794-.158 1.0607-.4393s.4393-.6629.4393-1.0607v-10c0-.39782-.158-.77936-.4393-1.06066s-.6629-.43934-1.0607-.43934zm0-1.5c.7956 0 1.5587.31607 2.1213.87868s.8787 1.32567.8787 2.12132v10c0 .7956-.3161 1.5587-.8787 2.1213s-1.3257.8787-2.1213.8787h-7c-.7956 0-1.55871-.3161-2.12132-.8787s-.87868-1.3257-.87868-2.1213v-10c0-.79565.31607-1.55871.87868-2.12132s1.32572-.87868 2.12132-.87868z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -20,50 +20,6 @@
&__button { &__button {
@include button-reset(); @include button-reset();
align-items: center;
border-radius: 16px;
display: flex;
height: 32px;
justify-content: center;
opacity: 0.5;
width: 32px;
&:focus,
&:hover {
opacity: 1;
}
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v2/collapse-down-20.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/collapse-down-20.svg',
$color-white
);
}
content: '';
display: block;
flex-shrink: 0;
height: 24px;
width: 24px;
}
&--active {
opacity: 1;
@include light-theme() {
background-color: $color-gray-05;
}
@include dark-theme() {
background-color: $color-gray-75;
}
}
} }
&__option { &__option {

View File

@ -1,30 +1,34 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.HueSlider.Slider { .HueSlider {
background-image: linear-gradient( &.Slider {
90deg, background-image: linear-gradient(
hsl(0, 0%, 0%), 90deg,
hsl(0, 100%, 50%), hsl(0, 0%, 0%),
hsl(45, 100%, 50%), hsl(0, 100%, 50%),
hsl(90, 100%, 50%), hsl(45, 100%, 50%),
hsl(135, 100%, 50%), hsl(90, 100%, 50%),
hsl(180, 100%, 50%), hsl(135, 100%, 50%),
hsl(225, 100%, 50%), hsl(180, 100%, 50%),
hsl(270, 100%, 50%), hsl(225, 100%, 50%),
hsl(315, 100%, 50%), hsl(270, 100%, 50%),
hsl(0, 0%, 100%) hsl(315, 100%, 50%),
); hsl(0, 0%, 100%)
border-radius: 4px; );
height: 8px; border-radius: 4px;
margin-left: 7px; height: 8px;
width: 280px; margin-left: 7px;
width: 280px;
}
&__handle.Slider__handle { &__handle {
border: 7px solid $color-white; &.Slider__handle {
margin-top: -7px; border: 7px solid $color-white;
margin-left: -11px; margin-top: -7px;
height: 22px; margin-left: -11px;
width: 22px; height: 22px;
width: 22px;
}
} }
} }

View File

@ -152,7 +152,8 @@
margin-bottom: 22px; margin-bottom: 22px;
padding: 14px 12px; padding: 14px 12px;
&__tool { &__tool,
&__tool__button {
margin-right: 14px; margin-right: 14px;
} }
@ -170,6 +171,7 @@
} }
@include button-reset; @include button-reset;
display: flex;
margin: 0 8px; margin: 0 8px;
padding: 8px; padding: 8px;
@ -179,31 +181,31 @@
padding: 0 6px; padding: 0 6px;
} }
&--draw-pen { &--draw-pen__button {
@include icon('pen-20.svg'); @include icon('pen-20.svg');
} }
&--draw-highlighter { &--draw-highlighter__button {
@include icon('pen-highlighter-20.svg'); @include icon('pen-highlighter-20.svg');
} }
&--width-thin { &--width-thin__button {
@include icon('pen-light-20.svg'); @include icon('pen-light-20.svg');
} }
&--width-regular { &--width-regular__button {
@include icon('pen-regular-20.svg'); @include icon('pen-regular-20.svg');
} }
&--width-medium { &--width-medium__button {
@include icon('pen-medium-20.svg'); @include icon('pen-medium-20.svg');
} }
&--width-heavy { &--width-heavy__button {
@include icon('pen-heavy-20.svg'); @include icon('pen-heavy-20.svg');
} }
&--text-regular { &--text-regular__button {
@include icon('text-regular-20.svg'); @include icon('text-regular-20.svg');
} }
&--text-highlight { &--text-highlight__button {
@include icon('text-highlight-20.svg'); @include icon('text-highlight-20.svg');
} }
&--text-outline { &--text-outline__button {
@include icon('text-outline-20.svg'); @include icon('text-outline-20.svg');
} }
&--rotate { &--rotate {

View File

@ -28,6 +28,9 @@
min-width: 72px; min-width: 72px;
} }
.module-message-detail__unix-timestamp-menu__button {
}
.module-message-detail__unix-timestamp { .module-message-detail__unix-timestamp {
@include light-theme { @include light-theme {
color: $color-gray-05; color: $color-gray-05;

View File

@ -56,7 +56,7 @@
} }
} }
&__more { &__more__button {
align-items: center; align-items: center;
background: $color-gray-65; background: $color-gray-65;
border-radius: 100%; border-radius: 100%;
@ -64,7 +64,6 @@
height: 28px; height: 28px;
justify-content: center; justify-content: center;
margin-left: 16px; margin-left: 16px;
opacity: 1;
width: 28px; width: 28px;
&::after { &::after {

View File

@ -21,7 +21,8 @@
width: 380px; width: 380px;
padding-top: calc(14px + var(--title-bar-drag-area-height)); padding-top: calc(14px + var(--title-bar-drag-area-height));
&__settings { &__settings__button {
margin-left: 24px;
opacity: 1; opacity: 1;
position: absolute; position: absolute;
right: 12px; right: 12px;

View File

@ -143,21 +143,17 @@
margin-bottom: 22px; margin-bottom: 22px;
padding: 14px 12px; padding: 14px 12px;
&__tool { &__tool,
&__tool__button {
margin-right: 14px; margin-right: 14px;
} }
&__button { &__button {
@mixin icon($icon) { @mixin icon($icon) {
@include svg($icon); @include svg($icon);
opacity: 1;
height: 20px; height: 20px;
width: 20px; width: 20px;
border-radius: 0; border-radius: 0;
&::after {
display: none;
}
} }
@include button-reset; @include button-reset;
@ -173,19 +169,19 @@
&--bg-none { &--bg-none {
@include icon('text-effect-off-24.svg'); @include icon('text-effect-off-24.svg');
} }
&--font-regular { &--font-regular__button {
@include icon('font-regular.svg'); @include icon('font-regular.svg');
} }
&--font-bold { &--font-bold__button {
@include icon('font-bold.svg'); @include icon('font-bold.svg');
} }
&--font-serif { &--font-serif__button {
@include icon('font-serif.svg'); @include icon('font-serif.svg');
} }
&--font-script { &--font-script__button {
@include icon('font-script.svg'); @include icon('font-script.svg');
} }
&--font-condensed { &--font-condensed__button {
@include icon('font-condensed.svg'); @include icon('font-condensed.svg');
} }
} }

View File

@ -0,0 +1,79 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryDetailsModal {
min-width: 320px;
overflow: hidden;
&__overlay-container {
align-items: flex-end;
justify-content: flex-end;
}
&__debugger__button {
color: $color-gray-25;
display: block;
font-weight: 600;
height: auto;
width: auto;
&__text {
font-weight: normal;
}
}
&__copy-icon {
@include dark-theme {
@include color-svg(
'../images/icons/v2/copy-outline-24.svg',
$color-white
);
}
@include light-theme {
@include color-svg(
'../images/icons/v2/copy-outline-24.svg',
$color-black
);
}
}
&__contact-container {
border-top: 1px solid $color-gray-75;
}
&__contact-group__header {
@include font-body-1-bold;
align-items: center;
display: flex;
justify-content: space-between;
margin-top: 24px;
padding: 10px 0;
user-select: none;
&:first-child {
margin-top: 0;
}
}
&__contact {
margin-bottom: 8px;
padding: 8px 0;
display: flex;
flex-direction: row;
align-items: center;
&__text {
@include font-body-1;
flex-grow: 1;
margin-left: 10px;
}
&:last-child {
margin-bottom: 0;
}
}
&__status-timestamp {
margin-left: 6px;
}
}

View File

@ -2,24 +2,26 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.StoryListItem { .StoryListItem {
@include button-reset; &__button {
align-items: center; @include button-reset;
border-radius: 10px; align-items: center;
display: flex; border-radius: 10px;
height: 96px; display: flex;
padding: 0 10px; height: 96px;
width: 100%; padding: 0 10px;
width: 100%;
@include keyboard-mode { @include keyboard-mode {
&:focus { &:focus {
background: $color-gray-65;
}
}
&:hover {
background: $color-gray-65; background: $color-gray-65;
} }
} }
&:hover {
background: $color-gray-65;
}
&__info { &__info {
display: flex; display: flex;
flex: 1; flex: 1;
@ -107,8 +109,22 @@
@include color-svg('../images/icons/v2/open-24.svg', $color-white); @include color-svg('../images/icons/v2/open-24.svg', $color-white);
} }
&--delete {
@include color-svg(
'../images/icons/v2/trash-outline-24.svg',
$color-white
);
}
&--hide { &--hide {
@include color-svg('../images/icons/v2/x-24.svg', $color-white); @include color-svg('../images/icons/v2/x-24.svg', $color-white);
} }
&--info {
@include color-svg(
'../images/icons/v2/info-outline-24.svg',
$color-white
);
}
} }
} }

View File

@ -146,16 +146,23 @@
} }
} }
&__more { &__more__button {
@include button-reset; display: flex;
justify-content: center;
align-items: center;
height: 24px; height: 24px;
width: 24px; width: 24px;
@include color-svg('../images/icons/v2/more-horiz-24.svg', $color-white); &::after {
@include color-svg('../images/icons/v2/more-horiz-24.svg', $color-white);
content: '';
height: 20px;
width: 20px;
@include keyboard-mode { @include keyboard-mode {
&:focus { &:focus {
background-color: $color-black; background-color: $color-black;
}
} }
} }
} }

View File

@ -180,6 +180,33 @@
width: 40px; width: 40px;
} }
} }
&__debugger__button {
color: $color-gray-25;
display: block;
font-weight: 600;
height: auto;
opacity: 1;
width: auto;
&--active {
@include dark-theme {
background: inherit;
}
}
&::after {
display: none;
}
&__text {
font-weight: normal;
}
}
&__copy-icon {
@include color-svg('../images/icons/v2/copy-outline-24.svg', $color-white);
}
} }
.Tabs.StoryViewsNRepliesModal__tabs { .Tabs.StoryViewsNRepliesModal__tabs {

View File

@ -108,12 +108,13 @@
@import './components/StagedLinkPreview.scss'; @import './components/StagedLinkPreview.scss';
@import './components/Stories.scss'; @import './components/Stories.scss';
@import './components/StoryCreator.scss'; @import './components/StoryCreator.scss';
@import './components/StoryDetailsModal.scss';
@import './components/StoryImage.scss'; @import './components/StoryImage.scss';
@import './components/StoryListItem.scss'; @import './components/StoryListItem.scss';
@import './components/StoryReplyQuote.scss'; @import './components/StoryReplyQuote.scss';
@import './components/StoriesSettingsModal.scss'; @import './components/StoriesSettingsModal.scss';
@import './components/StoryViewsNRepliesModal.scss';
@import './components/StoryViewer.scss'; @import './components/StoryViewer.scss';
@import './components/StoryViewsNRepliesModal.scss';
@import './components/SystemMessage.scss'; @import './components/SystemMessage.scss';
@import './components/Tabs.scss'; @import './components/Tabs.scss';
@import './components/TextAttachment.scss'; @import './components/TextAttachment.scss';

View File

@ -1,7 +1,7 @@
// Copyright 2018-2022 Signal Messenger, LLC // Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, KeyboardEvent } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import type { Options } from '@popperjs/core'; import type { Options } from '@popperjs/core';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@ -11,9 +11,10 @@ import { noop } from 'lodash';
import type { Theme } from '../util/theme'; import type { Theme } from '../util/theme';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { themeClassName } from '../util/theme'; import { themeClassName } from '../util/theme';
type OptionType<T> = { export type ContextMenuOptionType<T> = {
readonly description?: string; readonly description?: string;
readonly icon?: string; readonly icon?: string;
readonly label: string; readonly label: string;
@ -21,47 +22,53 @@ type OptionType<T> = {
readonly value?: T; readonly value?: T;
}; };
export type ContextMenuPropsType<T> = { export type PropsType<T> = {
readonly focusedIndex?: number; readonly children?: ReactNode;
readonly isMenuShowing: boolean; readonly i18n: LocalizerType;
readonly menuOptions: ReadonlyArray<OptionType<T>>; readonly menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
readonly onClose: () => unknown; readonly moduleClassName?: string;
readonly onClick?: () => unknown;
readonly onMenuShowingChanged?: (value: boolean) => unknown;
readonly popperOptions?: Pick<Options, 'placement' | 'strategy'>; readonly popperOptions?: Pick<Options, 'placement' | 'strategy'>;
readonly referenceElement: HTMLElement | null;
readonly theme?: Theme; readonly theme?: Theme;
readonly title?: string; readonly title?: string;
readonly value?: T; readonly value?: T;
}; };
export type PropsType<T> = { export function ContextMenu<T>({
readonly buttonClassName?: string; children,
readonly buttonStyle?: CSSProperties; i18n,
readonly i18n: LocalizerType;
} & Pick<
ContextMenuPropsType<T>,
'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value'
>;
export function ContextMenuPopper<T>({
menuOptions, menuOptions,
focusedIndex, moduleClassName,
isMenuShowing, onClick,
onMenuShowingChanged,
popperOptions, popperOptions,
onClose,
referenceElement,
title,
theme, theme,
title,
value, value,
}: ContextMenuPropsType<T>): JSX.Element | null { }: PropsType<T>): JSX.Element {
const [isMenuShowing, setIsMenuShowing] = useState<boolean>(false);
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
undefined
);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>( const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null null
); );
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start', placement: 'top-start',
strategy: 'fixed', strategy: 'fixed',
...popperOptions, ...popperOptions,
}); });
useEffect(() => {
if (onMenuShowingChanged) {
onMenuShowingChanged(isMenuShowing);
}
}, [isMenuShowing, onMenuShowingChanged]);
useEffect(() => { useEffect(() => {
if (!isMenuShowing) { if (!isMenuShowing) {
return noop; return noop;
@ -69,7 +76,7 @@ export function ContextMenuPopper<T>({
const handleOutsideClick = (event: MouseEvent) => { const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) { if (!referenceElement?.contains(event.target as Node)) {
onClose(); setIsMenuShowing(false);
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
} }
@ -79,92 +86,10 @@ export function ContextMenuPopper<T>({
return () => { return () => {
document.removeEventListener('click', handleOutsideClick); document.removeEventListener('click', handleOutsideClick);
}; };
}, [isMenuShowing, onClose, referenceElement]); }, [isMenuShowing, referenceElement]);
if (!isMenuShowing) {
return null;
}
return (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<div className={theme ? themeClassName(theme) : undefined}>
<div
className={classNames('ContextMenu__popper', {
'ContextMenu__popper--single-item': menuOptions.length === 1,
})}
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>
</div>
</FocusTrap>
);
}
export function ContextMenu<T>({
buttonClassName,
buttonStyle,
i18n,
menuOptions,
popperOptions,
theme,
title,
value,
}: PropsType<T>): JSX.Element {
const [menuShowing, setMenuShowing] = useState<boolean>(false);
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
undefined
);
const handleKeyDown = (ev: KeyboardEvent) => { const handleKeyDown = (ev: KeyboardEvent) => {
if (!menuShowing) { if (!isMenuShowing) {
if (ev.key === 'Enter') { if (ev.key === 'Enter') {
setFocusedIndex(0); setFocusedIndex(0);
} }
@ -194,46 +119,101 @@ export function ContextMenu<T>({
const focusedOption = menuOptions[focusedIndex]; const focusedOption = menuOptions[focusedIndex];
focusedOption.onClick(focusedOption.value); focusedOption.onClick(focusedOption.value);
} }
setMenuShowing(false); setIsMenuShowing(false);
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
} }
}; };
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => { const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true); setIsMenuShowing(true);
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
}; };
const [referenceElement, setReferenceElement] = const getClassName = getClassNamesFor('ContextMenu', moduleClassName);
useState<HTMLButtonElement | null>(null);
return ( return (
<div className={theme ? themeClassName(theme) : undefined}> <div className={theme ? themeClassName(theme) : undefined}>
<button <button
aria-label={i18n('ContextMenu--button')} aria-label={i18n('ContextMenu--button')}
className={classNames(buttonClassName, { className={classNames(
ContextMenu__button: true, getClassName('__button'),
'ContextMenu__button--active': menuShowing, isMenuShowing ? getClassName('__button--active') : undefined
})} )}
onClick={handleClick} onClick={onClick || handleClick}
onContextMenu={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
ref={setReferenceElement} ref={setReferenceElement}
style={buttonStyle}
type="button" type="button"
/> >
{menuShowing && ( {children}
<ContextMenuPopper </button>
focusedIndex={focusedIndex} {isMenuShowing && (
isMenuShowing={menuShowing} <FocusTrap
menuOptions={menuOptions} focusTrapOptions={{
onClose={() => setMenuShowing(false)} allowOutsideClick: true,
popperOptions={popperOptions} }}
referenceElement={referenceElement} >
title={title} <div className={theme ? themeClassName(theme) : undefined}>
value={value} <div
/> className={classNames(
getClassName('__popper'),
menuOptions.length === 1
? getClassName('__popper--single-item')
: undefined
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className={getClassName('__title')}>{title}</div>}
{menuOptions.map((option, index) => (
<button
aria-label={option.label}
className={classNames(
getClassName('__option'),
focusedIndex === index
? getClassName('__option--focused')
: undefined
)}
key={option.label}
type="button"
onClick={() => {
option.onClick(option.value);
setIsMenuShowing(false);
}}
>
<div className={getClassName('__option--container')}>
{option.icon && (
<div
className={classNames(
getClassName('__option--icon'),
option.icon
)}
/>
)}
<div>
<div className={getClassName('__option--title')}>
{option.label}
</div>
{option.description && (
<div className={getClassName('__option--description')}>
{option.description}
</div>
)}
</div>
</div>
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className={getClassName('__option--selected')} />
) : null}
</button>
))}
</div>
</div>
</FocusTrap>
)} )}
</div> </div>
); );

View File

@ -569,14 +569,6 @@ export const MediaEditor = ({
value={sliderValue} value={sliderValue}
/> />
<ContextMenu <ContextMenu
buttonClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--text-regular':
textStyle === TextStyle.Regular,
'MediaEditor__tools__button--text-highlight':
textStyle === TextStyle.Highlight,
'MediaEditor__tools__button--text-outline':
textStyle === TextStyle.Outline,
})}
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{ {
@ -598,6 +590,14 @@ export const MediaEditor = ({
value: TextStyle.Outline, value: TextStyle.Outline,
}, },
]} ]}
moduleClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--text-regular':
textStyle === TextStyle.Regular,
'MediaEditor__tools__button--text-highlight':
textStyle === TextStyle.Highlight,
'MediaEditor__tools__button--text-outline':
textStyle === TextStyle.Outline,
})}
theme={Theme.Dark} theme={Theme.Dark}
value={textStyle} value={textStyle}
/> />
@ -628,11 +628,6 @@ export const MediaEditor = ({
value={sliderValue} value={sliderValue}
/> />
<ContextMenu <ContextMenu
buttonClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--draw-pen': drawTool === DrawTool.Pen,
'MediaEditor__tools__button--draw-highlighter':
drawTool === DrawTool.Highlighter,
})}
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{ {
@ -648,20 +643,15 @@ export const MediaEditor = ({
value: DrawTool.Highlighter, value: DrawTool.Highlighter,
}, },
]} ]}
moduleClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--draw-pen': drawTool === DrawTool.Pen,
'MediaEditor__tools__button--draw-highlighter':
drawTool === DrawTool.Highlighter,
})}
theme={Theme.Dark} theme={Theme.Dark}
value={drawTool} value={drawTool}
/> />
<ContextMenu <ContextMenu
buttonClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--width-thin':
drawWidth === DrawWidth.Thin,
'MediaEditor__tools__button--width-regular':
drawWidth === DrawWidth.Regular,
'MediaEditor__tools__button--width-medium':
drawWidth === DrawWidth.Medium,
'MediaEditor__tools__button--width-heavy':
drawWidth === DrawWidth.Heavy,
})}
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{ {
@ -689,6 +679,16 @@ export const MediaEditor = ({
value: DrawWidth.Heavy, value: DrawWidth.Heavy,
}, },
]} ]}
moduleClassName={classNames('MediaEditor__tools__tool', {
'MediaEditor__tools__button--width-thin':
drawWidth === DrawWidth.Thin,
'MediaEditor__tools__button--width-regular':
drawWidth === DrawWidth.Regular,
'MediaEditor__tools__button--width-medium':
drawWidth === DrawWidth.Medium,
'MediaEditor__tools__button--width-heavy':
drawWidth === DrawWidth.Heavy,
})}
theme={Theme.Dark} theme={Theme.Dark}
value={drawWidth} value={drawWidth}
/> />

View File

@ -83,7 +83,10 @@ export const MyStories = ({
aria-label={i18n('MyStories__story')} aria-label={i18n('MyStories__story')}
className="MyStories__story__preview" className="MyStories__story__preview"
onClick={() => onClick={() =>
viewStory(story.messageId, StoryViewModeType.Single) viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.Single,
})
} }
type="button" type="button"
> >
@ -120,9 +123,15 @@ export const MyStories = ({
type="button" type="button"
/> />
<ContextMenu <ContextMenu
buttonClassName="MyStories__story__more"
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{
icon: 'MyStories__icon--forward',
label: i18n('forward'),
onClick: () => {
onForward(story.messageId);
},
},
{ {
icon: 'MyStories__icon--save', icon: 'MyStories__icon--save',
label: i18n('save'), label: i18n('save'),
@ -131,10 +140,14 @@ export const MyStories = ({
}, },
}, },
{ {
icon: 'MyStories__icon--forward', icon: 'StoryListItem__icon--info',
label: i18n('forward'), label: i18n('StoryListItem__info'),
onClick: () => { onClick: () => {
onForward(story.messageId); viewStory({
storyId: story.messageId,
storyViewMode: StoryViewModeType.Single,
shouldShowDetailsModal: true,
});
}, },
}, },
{ {
@ -145,6 +158,7 @@ export const MyStories = ({
}, },
}, },
]} ]}
moduleClassName="MyStories__story__more"
theme={Theme.Dark} theme={Theme.Dark}
/> />
</div> </div>

View File

@ -42,7 +42,7 @@ export const MyStoriesButton = ({
<div className="Stories__my-stories"> <div className="Stories__my-stories">
<button <button
aria-label={i18n('StoryListItem__label')} aria-label={i18n('StoryListItem__label')}
className="StoryListItem" className="StoryListItem__button"
onClick={onClick} onClick={onClick}
tabIndex={0} tabIndex={0}
type="button" type="button"

View File

@ -101,12 +101,12 @@ export const Stories = ({
} }
}} }}
onStoriesSettings={showStoriesSettings} onStoriesSettings={showStoriesSettings}
onStoryClicked={viewUserStories}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
showConversation={showConversation} showConversation={showConversation}
stories={stories} stories={stories}
toggleHideStories={toggleHideStories} toggleHideStories={toggleHideStories}
toggleStoriesView={toggleStoriesView} toggleStoriesView={toggleStoriesView}
viewUserStories={viewUserStories}
/> />
)} )}
</div> </div>

View File

@ -66,12 +66,12 @@ export type PropsType = {
onAddStory: () => unknown; onAddStory: () => unknown;
onMyStoriesClicked: () => unknown; onMyStoriesClicked: () => unknown;
onStoriesSettings: () => unknown; onStoriesSettings: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown; queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType; showConversation: ShowConversationType;
stories: Array<ConversationStoryType>; stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown; toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown; toggleStoriesView: () => unknown;
viewUserStories: (conversationId: string) => unknown;
}; };
export const StoriesPane = ({ export const StoriesPane = ({
@ -82,12 +82,12 @@ export const StoriesPane = ({
onAddStory, onAddStory,
onMyStoriesClicked, onMyStoriesClicked,
onStoriesSettings, onStoriesSettings,
onStoryClicked,
queueStoryDownload, queueStoryDownload,
showConversation, showConversation,
stories, stories,
toggleHideStories, toggleHideStories,
toggleStoriesView, toggleStoriesView,
viewUserStories,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isShowingHiddenStories, setIsShowingHiddenStories] = useState(false); const [isShowingHiddenStories, setIsShowingHiddenStories] = useState(false);
@ -122,7 +122,6 @@ export const StoriesPane = ({
type="button" type="button"
/> />
<ContextMenu <ContextMenu
buttonClassName="Stories__pane__settings"
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{ {
@ -130,6 +129,7 @@ export const StoriesPane = ({
label: i18n('StoriesSettings__context-menu'), label: i18n('StoriesSettings__context-menu'),
}, },
]} ]}
moduleClassName="Stories__pane__settings"
popperOptions={{ popperOptions={{
placement: 'bottom', placement: 'bottom',
strategy: 'absolute', strategy: 'absolute',
@ -166,9 +166,6 @@ export const StoriesPane = ({
group={story.group} group={story.group}
i18n={i18n} i18n={i18n}
key={story.storyView.timestamp} key={story.storyView.timestamp}
onClick={() => {
onStoryClicked(story.conversationId);
}}
onHideStory={toggleHideStories} onHideStory={toggleHideStories}
onGoToConversation={conversationId => { onGoToConversation={conversationId => {
showConversation({ conversationId }); showConversation({ conversationId });
@ -176,6 +173,7 @@ export const StoriesPane = ({
}} }}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
story={story.storyView} story={story.storyView}
viewUserStories={viewUserStories}
/> />
))} ))}
{Boolean(hiddenStories.length) && ( {Boolean(hiddenStories.length) && (
@ -195,9 +193,6 @@ export const StoriesPane = ({
key={story.storyView.timestamp} key={story.storyView.timestamp}
i18n={i18n} i18n={i18n}
isHidden isHidden
onClick={() => {
onStoryClicked(story.conversationId);
}}
onHideStory={toggleHideStories} onHideStory={toggleHideStories}
onGoToConversation={conversationId => { onGoToConversation={conversationId => {
showConversation({ conversationId }); showConversation({ conversationId });
@ -205,6 +200,7 @@ export const StoriesPane = ({
}} }}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
story={story.storyView} story={story.storyView}
viewUserStories={viewUserStories}
/> />
))} ))}
</> </>

View File

@ -267,18 +267,6 @@ export const StoryCreator = ({
value={sliderValue} value={sliderValue}
/> />
<ContextMenu <ContextMenu
buttonClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
i18n={i18n} i18n={i18n}
menuOptions={[ menuOptions={[
{ {
@ -312,6 +300,18 @@ export const StoryCreator = ({
value: TextStyle.Condensed, value: TextStyle.Condensed,
}, },
]} ]}
moduleClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
theme={Theme.Dark} theme={Theme.Dark}
value={textStyle} value={textStyle}
/> />

View File

@ -0,0 +1,91 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import casual from 'casual';
import type { PropsType } from './StoryDetailsModal';
import enMessages from '../../_locales/en/messages.json';
import { SendStatus } from '../messages/MessageSendState';
import { StoryDetailsModal } from './StoryDetailsModal';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryDetailsModal',
component: StoryDetailsModal,
argTypes: {
getPreferredBadge: { action: true },
i18n: {
defaultValue: i18n,
},
onClose: { action: true },
sender: {
defaultValue: getDefaultConversation(),
},
sendState: {
defaultValue: undefined,
},
size: {
defaultValue: fakeAttachment().size,
},
timestamp: {
defaultValue: Date.now(),
},
},
} as Meta;
const Template: Story<PropsType> = args => <StoryDetailsModal {...args} />;
export const MyStory = Template.bind({});
MyStory.args = {
sendState: [
{
recipient: getDefaultConversation(),
status: SendStatus.Delivered,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Delivered,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Delivered,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Delivered,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Sent,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Viewed,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Viewed,
updatedAt: casual.unix_time,
},
{
recipient: getDefaultConversation(),
status: SendStatus.Viewed,
updatedAt: casual.unix_time,
},
],
};
export const OtherStory = Template.bind({});
OtherStory.args = {};

View File

@ -0,0 +1,244 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import formatFileSize from 'filesize';
import type { LocalizerType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { StorySendStateType, StoryViewType } from '../types/Stories';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { ContextMenu } from './ContextMenu';
import { Intl } from './Intl';
import { Modal } from './Modal';
import { SendStatus } from '../messages/MessageSendState';
import { Theme } from '../util/theme';
import { ThemeType } from '../types/Util';
import { Time } from './Time';
import { formatDateTimeLong } from '../util/timestamp';
import { groupBy } from '../util/mapUtil';
export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
onClose: () => unknown;
sender: StoryViewType['sender'];
sendState?: Array<StorySendStateType>;
size?: number;
timestamp: number;
};
const contactSortCollator = new window.Intl.Collator();
function getI18nKey(sendStatus: SendStatus | undefined): string {
if (sendStatus === SendStatus.Failed) {
return 'MessageDetailsHeader--Failed';
}
if (sendStatus === SendStatus.Viewed) {
return 'MessageDetailsHeader--Viewed';
}
if (sendStatus === SendStatus.Read) {
return 'MessageDetailsHeader--Read';
}
if (sendStatus === SendStatus.Delivered) {
return 'MessageDetailsHeader--Delivered';
}
if (sendStatus === SendStatus.Sent) {
return 'MessageDetailsHeader--Sent';
}
if (sendStatus === SendStatus.Pending) {
return 'MessageDetailsHeader--Pending';
}
return 'from';
}
export const StoryDetailsModal = ({
getPreferredBadge,
i18n,
onClose,
sender,
sendState,
size,
timestamp,
}: PropsType): JSX.Element => {
const contactsBySendStatus = sendState
? groupBy(sendState, contact => contact.status)
: undefined;
let content: JSX.Element;
if (contactsBySendStatus) {
content = (
<div className="StoryDetailsModal__contact-container">
{[
SendStatus.Failed,
SendStatus.Viewed,
SendStatus.Read,
SendStatus.Delivered,
SendStatus.Sent,
SendStatus.Pending,
].map(sendStatus => {
const contacts = contactsBySendStatus.get(sendStatus);
if (!contacts) {
return null;
}
const i18nKey = getI18nKey(sendStatus);
const sortedContacts = [...contacts].sort((a, b) =>
contactSortCollator.compare(a.recipient.title, b.recipient.title)
);
return (
<div key={i18nKey} className="StoryDetailsModal__contact-group">
<div className="StoryDetailsModal__contact-group__header">
{i18n(i18nKey)}
</div>
{sortedContacts.map(status => {
const contact = status.recipient;
return (
<div key={contact.id} className="StoryDetailsModal__contact">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType="direct"
i18n={i18n}
isMe={contact.isMe}
name={contact.profileName}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
theme={ThemeType.dark}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
/>
<div className="StoryDetailsModal__contact__text">
<ContactName title={contact.title} />
</div>
{status.updatedAt && (
<Time
className="StoryDetailsModal__status-timestamp"
timestamp={status.updatedAt}
>
{formatDateTimeLong(i18n, status.updatedAt)}
</Time>
)}
</div>
);
})}
</div>
);
})}
</div>
);
} else {
content = (
<div className="StoryDetailsModal__contact-container">
<div className="StoryDetailsModal__contact-group">
<div className="StoryDetailsModal__contact-group__header">
{i18n('sent')}
</div>
<div className="StoryDetailsModal__contact">
<Avatar
acceptedMessageRequest={sender.acceptedMessageRequest}
avatarPath={sender.avatarPath}
badge={getPreferredBadge(sender.badges)}
color={sender.color}
conversationType="direct"
i18n={i18n}
isMe={sender.isMe}
name={sender.profileName}
profileName={sender.profileName}
sharedGroupNames={sender.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
theme={ThemeType.dark}
title={sender.title}
/>
<div className="StoryDetailsModal__contact__text">
<div className="StoryDetailsModal__contact__name">
<ContactName title={sender.title} />
</div>
</div>
<Time
className="StoryDetailsModal__status-timestamp"
timestamp={timestamp}
>
{formatDateTimeLong(i18n, timestamp)}
</Time>
</div>
</div>
</div>
);
}
return (
<Modal
hasXButton
i18n={i18n}
moduleClassName="StoryDetailsModal"
onClose={onClose}
useFocusTrap={false}
theme={Theme.Dark}
title={
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryDetailsModal__copy-icon',
label: i18n('StoryDetailsModal__copy-timestamp'),
onClick: () => {
window.navigator.clipboard.writeText(String(timestamp));
},
},
]}
moduleClassName="StoryDetailsModal__debugger"
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
theme={Theme.Dark}
>
<div>
<Intl
i18n={i18n}
id="StoryDetailsModal__sent-time"
components={[
<Time
className="StoryDetailsModal__debugger__button__text"
timestamp={timestamp}
>
{formatDateTimeLong(i18n, timestamp)}
</Time>,
]}
/>
</div>
{size && (
<div>
<Intl
i18n={i18n}
id="StoryDetailsModal__file-size"
components={[
<span className="StoryDetailsModal__debugger__button__text">
{formatFileSize(size)}
</span>,
]}
/>
</div>
)}
</ContextMenu>
}
>
{content}
</Modal>
);
};

View File

@ -23,7 +23,6 @@ export default {
i18n: { i18n: {
defaultValue: i18n, defaultValue: i18n,
}, },
onClick: { action: true },
onGoToConversation: { action: true }, onGoToConversation: { action: true },
onHideStory: { action: true }, onHideStory: { action: true },
queueStoryDownload: { action: true }, queueStoryDownload: { action: true },
@ -34,6 +33,7 @@ export default {
timestamp: Date.now(), timestamp: Date.now(),
}, },
}, },
viewUserStories: { action: true },
}, },
} as Meta; } as Meta;

View File

@ -7,7 +7,7 @@ import type { LocalizerType } from '../types/Util';
import type { ConversationStoryType, StoryViewType } from '../types/Stories'; import type { ConversationStoryType, StoryViewType } from '../types/Stories';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { HasStories } from '../types/Stories'; import { HasStories } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage'; import { StoryImage } from './StoryImage';
@ -15,27 +15,27 @@ import { getAvatarColor } from '../types/Colors';
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & { export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
i18n: LocalizerType; i18n: LocalizerType;
onClick: () => unknown;
onGoToConversation: (conversationId: string) => unknown; onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown; queueStoryDownload: (storyId: string) => unknown;
story: StoryViewType; story: StoryViewType;
viewUserStories: (
conversationId: string,
shouldShowDetailsModal?: boolean
) => unknown;
}; };
export const StoryListItem = ({ export const StoryListItem = ({
group, group,
i18n, i18n,
isHidden, isHidden,
onClick,
onGoToConversation, onGoToConversation,
onHideStory, onHideStory,
queueStoryDownload, queueStoryDownload,
story, story,
viewUserStories,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const { const {
attachment, attachment,
@ -72,21 +72,42 @@ export const StoryListItem = ({
return ( return (
<> <>
<button <ContextMenu
aria-label={i18n('StoryListItem__label')} aria-label={i18n('StoryListItem__label')}
className={classNames('StoryListItem', { i18n={i18n}
menuOptions={[
{
icon: 'StoryListItem__icon--hide',
label: isHidden
? i18n('StoryListItem__unhide')
: i18n('StoryListItem__hide'),
onClick: () => {
if (isHidden) {
onHideStory(sender.id);
} else {
setHasConfirmHideStory(true);
}
},
},
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => viewUserStories(sender.id, true),
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => onGoToConversation(sender.id),
},
]}
moduleClassName={classNames('StoryListItem', {
'StoryListItem--hidden': isHidden, 'StoryListItem--hidden': isHidden,
})} })}
onClick={onClick} onClick={() => viewUserStories(sender.id)}
onContextMenu={ev => { popperOptions={{
ev.preventDefault(); placement: 'bottom',
ev.stopPropagation(); strategy: 'absolute',
setIsShowingContextMenu(true);
}} }}
ref={setReferenceElement}
tabIndex={0}
type="button"
> >
<Avatar <Avatar
acceptedMessageRequest={acceptedMessageRequest} acceptedMessageRequest={acceptedMessageRequest}
@ -133,38 +154,7 @@ export const StoryListItem = ({
storyId={story.messageId} storyId={story.messageId}
/> />
</div> </div>
</button> </ContextMenu>
<ContextMenuPopper
isMenuShowing={isShowingContextMenu}
menuOptions={[
{
icon: 'StoryListItem__icon--hide',
label: isHidden
? i18n('StoryListItem__unhide')
: i18n('StoryListItem__hide'),
onClick: () => {
if (isHidden) {
onHideStory(sender.id);
} else {
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 && ( {hasConfirmHideStory && (
<ConfirmationDialog <ConfirmationDialog
actions={[ actions={[

View File

@ -121,3 +121,22 @@ LongCaption.args = {
}), }),
}, },
}; };
export const YourStory = Template.bind({});
{
const storyView = getFakeStoryView(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
);
YourStory.args = {
story: {
...storyView,
sender: {
...storyView.sender,
isMe: true,
},
sendState: [],
},
};
YourStory.storyName = 'Your story';
}

View File

@ -12,6 +12,7 @@ import React, {
import classNames from 'classnames'; import classNames from 'classnames';
import { useSpring, animated, to } from '@react-spring/web'; import { useSpring, animated, to } from '@react-spring/web';
import type { BodyRangeType, LocalizerType } from '../types/Util'; import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ContextMenuOptionType } from './ContextMenu';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
@ -23,13 +24,14 @@ import * as log from '../logging/log';
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore'; import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { Intl } from './Intl'; import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import { StoryDetailsModal } from './StoryDetailsModal';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { StoryImage } from './StoryImage'; import { StoryImage } from './StoryImage';
import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories'; import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { ToastType } from '../state/ducks/toast'; import { ToastType } from '../state/ducks/toast';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
@ -40,6 +42,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = { export type PropsType = {
currentIndex: number; currentIndex: number;
deleteStoryForEveryone: (story: StoryViewType) => unknown;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
group?: Pick< group?: Pick<
ConversationType, ConversationType,
@ -74,6 +77,7 @@ export type PropsType = {
recentEmojis?: Array<string>; recentEmojis?: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replyState?: ReplyStateType; replyState?: ReplyStateType;
shouldShowDetailsModal?: boolean;
showToast: ShowToastActionCreatorType; showToast: ShowToastActionCreatorType;
skinTone?: number; skinTone?: number;
story: StoryViewType; story: StoryViewType;
@ -95,6 +99,7 @@ enum Arrow {
export const StoryViewer = ({ export const StoryViewer = ({
currentIndex, currentIndex,
deleteStoryForEveryone,
getPreferredBadge, getPreferredBadge,
group, group,
hasAllStoriesMuted, hasAllStoriesMuted,
@ -114,6 +119,7 @@ export const StoryViewer = ({
recentEmojis, recentEmojis,
renderEmojiPicker, renderEmojiPicker,
replyState, replyState,
shouldShowDetailsModal,
showToast, showToast,
skinTone, skinTone,
story, story,
@ -121,12 +127,14 @@ export const StoryViewer = ({
toggleHasAllStoriesMuted, toggleHasAllStoriesMuted,
viewStory, viewStory,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [isShowingContextMenu, setIsShowingContextMenu] =
useState<boolean>(false);
const [storyDuration, setStoryDuration] = useState<number | undefined>(); const [storyDuration, setStoryDuration] = useState<number | undefined>();
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>(); const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
>();
const { attachment, canReply, isHidden, messageId, sendState, timestamp } = const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
story; story;
@ -143,19 +151,25 @@ export const StoryViewer = ({
title, title,
} = story.sender; } = story.sender;
const [hasReplyModal, setHasReplyModal] = useState(false); const [hasStoryViewsNRepliesModal, setHasStoryViewsNRepliesModal] =
useState(false);
const [hasStoryDetailsModal, setHasStoryDetailsModal] = useState(
Boolean(shouldShowDetailsModal)
);
const onClose = useCallback(() => { const onClose = useCallback(() => {
viewStory(); viewStory({
closeViewer: true,
});
}, [viewStory]); }, [viewStory]);
const onEscape = useCallback(() => { const onEscape = useCallback(() => {
if (hasReplyModal) { if (hasStoryViewsNRepliesModal) {
setHasReplyModal(false); setHasStoryViewsNRepliesModal(false);
} else { } else {
onClose(); onClose();
} }
}, [hasReplyModal, onClose]); }, [hasStoryViewsNRepliesModal, onClose]);
useEscapeHandling(onEscape); useEscapeHandling(onEscape);
@ -225,11 +239,11 @@ export const StoryViewer = ({
} }
if (value === 100) { if (value === 100) {
viewStory( viewStory({
story.messageId, storyId: story.messageId,
storyViewMode, storyViewMode,
StoryViewDirectionType.Next viewDirection: StoryViewDirectionType.Next,
); });
} }
}, },
}, },
@ -263,7 +277,8 @@ export const StoryViewer = ({
const shouldPauseViewing = const shouldPauseViewing =
hasConfirmHideStory || hasConfirmHideStory ||
hasExpandedCaption || hasExpandedCaption ||
hasReplyModal || hasStoryDetailsModal ||
hasStoryViewsNRepliesModal ||
isShowingContextMenu || isShowingContextMenu ||
pauseStory || pauseStory ||
Boolean(reactionEmoji); Boolean(reactionEmoji);
@ -284,15 +299,19 @@ export const StoryViewer = ({
const navigateStories = useCallback( const navigateStories = useCallback(
(ev: KeyboardEvent) => { (ev: KeyboardEvent) => {
if (ev.key === 'ArrowRight') { if (ev.key === 'ArrowRight') {
viewStory(story.messageId, storyViewMode, StoryViewDirectionType.Next); viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
});
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} else if (ev.key === 'ArrowLeft') { } else if (ev.key === 'ArrowLeft') {
viewStory( viewStory({
story.messageId, storyId: story.messageId,
storyViewMode, storyViewMode,
StoryViewDirectionType.Previous viewDirection: StoryViewDirectionType.Previous,
); });
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} }
@ -357,10 +376,50 @@ export const StoryViewer = ({
const replyCount = replies.length; const replyCount = replies.length;
const viewCount = views.length; const viewCount = views.length;
const shouldShowContextMenu = !sendState;
const hasPrevNextArrows = storyViewMode !== StoryViewModeType.Single; const hasPrevNextArrows = storyViewMode !== StoryViewModeType.Single;
const contextMenuOptions: ReadonlyArray<ContextMenuOptionType<unknown>> =
sendState
? [
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => setHasStoryDetailsModal(true),
},
{
icon: 'StoryListItem__icon--delete',
label: i18n('StoryListItem__delete'),
onClick: () => setConfirmDeleteStory(story),
},
]
: [
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => setHasStoryDetailsModal(true),
},
{
icon: 'StoryListItem__icon--hide',
label: isHidden
? i18n('StoryListItem__unhide')
: i18n('StoryListItem__hide'),
onClick: () => {
if (isHidden) {
onHideStory(id);
} else {
setHasConfirmHideStory(true);
}
},
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => {
onGoToConversation(id);
},
},
];
return ( return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}> <FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryViewer"> <div className="StoryViewer">
@ -379,11 +438,11 @@ export const StoryViewer = ({
} }
)} )}
onClick={() => onClick={() =>
viewStory( viewStory({
story.messageId, storyId: story.messageId,
storyViewMode, storyViewMode,
StoryViewDirectionType.Previous viewDirection: StoryViewDirectionType.Previous,
) })
} }
onMouseMove={() => setArrowToShow(Arrow.Left)} onMouseMove={() => setArrowToShow(Arrow.Left)}
type="button" type="button"
@ -519,15 +578,14 @@ export const StoryViewer = ({
onClick={toggleHasAllStoriesMuted} onClick={toggleHasAllStoriesMuted}
type="button" type="button"
/> />
{shouldShowContextMenu && ( <ContextMenu
<button aria-label={i18n('MyStories__more')}
aria-label={i18n('MyStories__more')} i18n={i18n}
className="StoryViewer__more" menuOptions={contextMenuOptions}
onClick={() => setIsShowingContextMenu(true)} moduleClassName="StoryViewer__more"
ref={setReferenceElement} onMenuShowingChanged={setIsShowingContextMenu}
type="button" theme={Theme.Dark}
/> />
)}
</div> </div>
</div> </div>
<div className="StoryViewer__progress"> <div className="StoryViewer__progress">
@ -555,14 +613,14 @@ export const StoryViewer = ({
{canReply && ( {canReply && (
<button <button
className="StoryViewer__reply" className="StoryViewer__reply"
onClick={() => setHasReplyModal(true)} onClick={() => setHasStoryViewsNRepliesModal(true)}
tabIndex={0} tabIndex={0}
type="button" type="button"
> >
<> <>
{viewCount > 0 || replyCount > 0 ? ( {sendState || replyCount > 0 ? (
<span className="StoryViewer__reply__chevron"> <span className="StoryViewer__reply__chevron">
{viewCount > 0 && {sendState &&
(viewCount === 1 ? ( (viewCount === 1 ? (
<Intl <Intl
i18n={i18n} i18n={i18n}
@ -593,7 +651,7 @@ export const StoryViewer = ({
))} ))}
</span> </span>
) : null} ) : null}
{!viewCount && !replyCount && ( {!sendState && !replyCount && (
<span className="StoryViewer__reply__arrow"> <span className="StoryViewer__reply__arrow">
{isGroupStory {isGroupStory
? i18n('StoryViewer__reply-group') ? i18n('StoryViewer__reply-group')
@ -615,11 +673,11 @@ export const StoryViewer = ({
} }
)} )}
onClick={() => onClick={() =>
viewStory( viewStory({
story.messageId, storyId: story.messageId,
storyViewMode, storyViewMode,
StoryViewDirectionType.Next viewDirection: StoryViewDirectionType.Next,
) })
} }
onMouseMove={() => setArrowToShow(Arrow.Right)} onMouseMove={() => setArrowToShow(Arrow.Right)}
type="button" type="button"
@ -634,51 +692,35 @@ export const StoryViewer = ({
type="button" type="button"
/> />
</div> </div>
<ContextMenuPopper {hasStoryDetailsModal && (
isMenuShowing={isShowingContextMenu} <StoryDetailsModal
menuOptions={[ getPreferredBadge={getPreferredBadge}
{ i18n={i18n}
icon: 'StoryListItem__icon--hide', onClose={() => setHasStoryDetailsModal(false)}
label: isHidden sender={story.sender}
? i18n('StoryListItem__unhide') sendState={sendState}
: i18n('StoryListItem__hide'), size={attachment?.size}
onClick: () => { timestamp={timestamp}
if (isHidden) { />
onHideStory(id); )}
} else { {hasStoryViewsNRepliesModal && (
setHasConfirmHideStory(true);
}
},
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => {
onGoToConversation(id);
},
},
]}
onClose={() => setIsShowingContextMenu(false)}
referenceElement={referenceElement}
theme={Theme.Dark}
/>
{hasReplyModal && canReply && (
<StoryViewsNRepliesModal <StoryViewsNRepliesModal
authorTitle={firstName || title} authorTitle={firstName || title}
canReply={Boolean(canReply)}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
isGroupStory={isGroupStory} isGroupStory={isGroupStory}
isMyStory={isMe} isMyStory={isMe}
onClose={() => setHasReplyModal(false)} onClose={() => setHasStoryViewsNRepliesModal(false)}
onReact={emoji => { onReact={emoji => {
onReactToStory(emoji, story); onReactToStory(emoji, story);
setHasReplyModal(false); setHasStoryViewsNRepliesModal(false);
setReactionEmoji(emoji); setReactionEmoji(emoji);
showToast(ToastType.StoryReact); showToast(ToastType.StoryReact);
}} }}
onReply={(message, mentions, replyTimestamp) => { onReply={(message, mentions, replyTimestamp) => {
if (!isGroupStory) { if (!isGroupStory) {
setHasReplyModal(false); setHasStoryViewsNRepliesModal(false);
} }
onReplyToStory(message, mentions, replyTimestamp, story); onReplyToStory(message, mentions, replyTimestamp, story);
showToast(ToastType.StoryReply); showToast(ToastType.StoryReply);
@ -712,6 +754,21 @@ export const StoryViewer = ({
{i18n('StoryListItem__hide-modal--body', [String(firstName)])} {i18n('StoryListItem__hide-modal--body', [String(firstName)])}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
{confirmDeleteStory && (
<ConfirmationDialog
actions={[
{
text: i18n('delete'),
action: () => deleteStoryForEveryone(confirmDeleteStory),
style: 'negative',
},
]}
i18n={i18n}
onClose={() => setConfirmDeleteStory(undefined)}
>
{i18n('MyStories__delete')}
</ConfirmationDialog>
)}
</div> </div>
</FocusTrap> </FocusTrap>
); );

View File

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react'; import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewsNRepliesModal'; import type { PropsType } from './StoryViewsNRepliesModal';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
@ -18,35 +18,50 @@ const i18n = setupI18n('en', enMessages);
export default { export default {
title: 'Components/StoryViewsNRepliesModal', title: 'Components/StoryViewsNRepliesModal',
}; component: StoryViewsNRepliesModal,
argTypes: {
function getDefaultProps(): PropsType { authorTitle: {
return { defaultValue: getDefaultConversation().title,
authorTitle: getDefaultConversation().title, },
getPreferredBadge: () => undefined, canReply: {
i18n, defaultValue: true,
isMyStory: false, },
onClose: action('onClose'), getPreferredBadge: { action: true },
onSetSkinTone: action('onSetSkinTone'), i18n: {
onReact: action('onReact'), defaultValue: i18n,
onReply: action('onReply'), },
onTextTooLong: action('onTextTooLong'), isMyStory: {
onUseEmoji: action('onUseEmoji'), defaultValue: false,
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'], },
renderEmojiPicker: () => <div />, onClose: { action: true },
replies: [], onSetSkinTone: { action: true },
storyPreviewAttachment: fakeAttachment({ onReact: { action: true },
thumbnail: { onReply: { action: true },
contentType: IMAGE_JPEG, onTextTooLong: { action: true },
height: 64, onUseEmoji: { action: true },
objectUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg', preferredReactionEmoji: {
path: '', defaultValue: ['❤️', '👍', '👎', '😂', '😮', '😢'],
width: 40, },
}, renderEmojiPicker: { action: true },
}), replies: {
views: [], defaultValue: [],
}; },
} storyPreviewAttachment: {
defaultValue: fakeAttachment({
thumbnail: {
contentType: IMAGE_JPEG,
height: 64,
objectUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg',
path: '',
width: 40,
},
}),
},
views: {
defaultValue: [],
},
},
} as Meta;
function getViewsAndReplies() { function getViewsAndReplies() {
const p1 = getDefaultConversation(); const p1 = getDefaultConversation();
@ -107,47 +122,51 @@ function getViewsAndReplies() {
}; };
} }
export const CanReply = (): JSX.Element => ( const Template: Story<PropsType> = args => (
<StoryViewsNRepliesModal {...getDefaultProps()} /> <StoryViewsNRepliesModal {...args} />
); );
CanReply.story = { export const CanReply = Template.bind({});
name: 'Can reply', CanReply.args = {};
CanReply.storyName = 'Can reply';
export const ViewsOnly = Template.bind({});
ViewsOnly.args = {
isMyStory: true,
views: getViewsAndReplies().views,
}; };
ViewsOnly.storyName = 'Views only';
export const ViewsOnly = (): JSX.Element => ( export const InAGroupNoReplies = Template.bind({});
<StoryViewsNRepliesModal InAGroupNoReplies.args = {
{...getDefaultProps()} isGroupStory: true,
isMyStory
views={getViewsAndReplies().views}
/>
);
ViewsOnly.story = {
name: 'Views only',
}; };
InAGroupNoReplies.storyName = 'In a group (no replies)';
export const InAGroupNoReplies = (): JSX.Element => ( export const InAGroup = Template.bind({});
<StoryViewsNRepliesModal {...getDefaultProps()} isGroupStory /> {
);
InAGroupNoReplies.story = {
name: 'In a group (no replies)',
};
export const InAGroup = (): JSX.Element => {
const { views, replies } = getViewsAndReplies(); const { views, replies } = getViewsAndReplies();
InAGroup.args = {
isGroupStory: true,
replies,
views,
};
}
InAGroup.storyName = 'In a group';
return ( export const CantReply = Template.bind({});
<StoryViewsNRepliesModal CantReply.args = {
{...getDefaultProps()} canReply: false,
isGroupStory
replies={replies}
views={views}
/>
);
}; };
InAGroup.story = { export const InAGroupCantReply = Template.bind({});
name: 'In a group', {
}; const { views, replies } = getViewsAndReplies();
InAGroupCantReply.args = {
canReply: false,
isGroupStory: true,
replies,
views,
};
}
InAGroupCantReply.storyName = "In a group (can't reply)";

View File

@ -34,6 +34,7 @@ enum Tab {
export type PropsType = { export type PropsType = {
authorTitle: string; authorTitle: string;
canReply: boolean;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
isGroupStory?: boolean; isGroupStory?: boolean;
@ -59,6 +60,7 @@ export type PropsType = {
export const StoryViewsNRepliesModal = ({ export const StoryViewsNRepliesModal = ({
authorTitle, authorTitle,
canReply,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
isGroupStory, isGroupStory,
@ -76,7 +78,7 @@ export const StoryViewsNRepliesModal = ({
skinTone, skinTone,
storyPreviewAttachment, storyPreviewAttachment,
views, views,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element | null => {
const inputApiRef = useRef<InputApi | undefined>(); const inputApiRef = useRef<InputApi | undefined>();
const [bottom, setBottom] = useState<HTMLDivElement | null>(null); const [bottom, setBottom] = useState<HTMLDivElement | null>(null);
const [messageBodyText, setMessageBodyText] = useState(''); const [messageBodyText, setMessageBodyText] = useState('');
@ -117,7 +119,7 @@ export const StoryViewsNRepliesModal = ({
let composerElement: JSX.Element | undefined; let composerElement: JSX.Element | undefined;
if (!isMyStory) { if (!isMyStory && canReply) {
composerElement = ( composerElement = (
<> <>
{!isGroupStory && ( {!isGroupStory && (
@ -373,6 +375,10 @@ export const StoryViewsNRepliesModal = ({
</Tabs> </Tabs>
) : undefined; ) : undefined;
if (!tabsElement && !viewsElement && !repliesElement && !composerElement) {
return null;
}
return ( return (
<Modal <Modal
i18n={i18n} i18n={i18n}

View File

@ -1551,7 +1551,10 @@ export class Message extends React.PureComponent<Props, State> {
isViewOnce={false} isViewOnce={false}
moduleClassName="StoryReplyQuote" moduleClassName="StoryReplyQuote"
onClick={() => { onClick={() => {
viewStory(storyReplyContext.storyId, StoryViewModeType.Single); viewStory({
storyId: storyReplyContext.storyId,
storyViewMode: StoryViewModeType.Single,
});
}} }}
rawAttachment={storyReplyContext.rawAttachment} rawAttachment={storyReplyContext.rawAttachment}
reactionEmoji={storyReplyContext.emoji} reactionEmoji={storyReplyContext.emoji}

View File

@ -8,6 +8,7 @@ import { noop } from 'lodash';
import { Avatar, AvatarSize } from '../Avatar'; import { Avatar, AvatarSize } from '../Avatar';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { ContextMenu } from '../ContextMenu';
import { Time } from '../Time'; import { Time } from '../Time';
import type { import type {
Props as MessagePropsType, Props as MessagePropsType,
@ -392,12 +393,27 @@ export class MessageDetail extends React.Component<Props> {
<tr> <tr>
<td className="module-message-detail__label">{i18n('sent')}</td> <td className="module-message-detail__label">{i18n('sent')}</td>
<td> <td>
<Time timestamp={sentAt}> <ContextMenu
{formatDateTimeLong(i18n, sentAt)} i18n={i18n}
</Time>{' '} menuOptions={[
<span className="module-message-detail__unix-timestamp"> {
({sentAt}) icon: 'StoryDetailsModal__copy-icon',
</span> label: i18n('StoryDetailsModal__copy-timestamp'),
onClick: () => {
window.navigator.clipboard.writeText(String(sentAt));
},
},
]}
>
<>
<Time timestamp={sentAt}>
{formatDateTimeLong(i18n, sentAt)}
</Time>{' '}
<span className="module-message-detail__unix-timestamp">
({sentAt})
</span>
</>
</ContextMenu>
</td> </td>
</tr> </tr>
{receivedAt && message.direction === 'incoming' ? ( {receivedAt && message.direction === 'incoming' ? (

View File

@ -63,6 +63,7 @@ export type StoryDataType = {
export type SelectedStoryDataType = { export type SelectedStoryDataType = {
currentIndex: number; currentIndex: number;
numStories: number; numStories: number;
shouldShowDetailsModal: boolean;
story: StoryDataType; story: StoryDataType;
}; };
@ -616,7 +617,8 @@ const getSelectedStoryDataForConversationId = (
}; };
function viewUserStories( function viewUserStories(
conversationId: string conversationId: string,
shouldShowDetailsModal = false
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> { ): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> {
return (dispatch, getState) => { return (dispatch, getState) => {
const { currentIndex, hasUnread, numStories, storiesByConversationId } = const { currentIndex, hasUnread, numStories, storiesByConversationId } =
@ -630,6 +632,7 @@ function viewUserStories(
selectedStoryData: { selectedStoryData: {
currentIndex, currentIndex,
numStories, numStories,
shouldShowDetailsModal,
story, story,
}, },
storyViewMode: hasUnread storyViewMode: hasUnread
@ -640,19 +643,23 @@ function viewUserStories(
}; };
} }
export type ViewStoryActionCreatorType = ( export type ViewStoryActionCreatorType = (opts: {
storyId?: string, closeViewer?: boolean;
storyViewMode?: StoryViewModeType, storyId?: string;
viewDirection?: StoryViewDirectionType storyViewMode?: StoryViewModeType;
) => unknown; viewDirection?: StoryViewDirectionType;
shouldShowDetailsModal?: boolean;
}) => unknown;
const viewStory: ViewStoryActionCreatorType = ( const viewStory: ViewStoryActionCreatorType = ({
closeViewer,
shouldShowDetailsModal = false,
storyId, storyId,
storyViewMode, storyViewMode,
viewDirection viewDirection,
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> => { }): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> => {
return (dispatch, getState) => { return (dispatch, getState) => {
if (!storyId || !storyViewMode) { if (closeViewer || !storyId || !storyViewMode) {
dispatch({ dispatch({
type: VIEW_STORY, type: VIEW_STORY,
payload: undefined, payload: undefined,
@ -691,6 +698,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex, currentIndex,
numStories, numStories,
shouldShowDetailsModal,
story, story,
}, },
storyViewMode, storyViewMode,
@ -713,6 +721,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex: nextIndex, currentIndex: nextIndex,
numStories, numStories,
shouldShowDetailsModal: false,
story: nextStory, story: nextStory,
}, },
storyViewMode, storyViewMode,
@ -732,6 +741,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex: nextIndex, currentIndex: nextIndex,
numStories, numStories,
shouldShowDetailsModal: false,
story: nextStory, story: nextStory,
}, },
storyViewMode, storyViewMode,
@ -759,6 +769,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex: nextSelectedStoryData.currentIndex, currentIndex: nextSelectedStoryData.currentIndex,
numStories: nextSelectedStoryData.numStories, numStories: nextSelectedStoryData.numStories,
shouldShowDetailsModal: false,
story: unreadStory, story: unreadStory,
}, },
storyViewMode, storyViewMode,
@ -819,6 +830,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex: 0, currentIndex: 0,
numStories: nextSelectedStoryData.numStories, numStories: nextSelectedStoryData.numStories,
shouldShowDetailsModal: false,
story: nextSelectedStoryData.storiesByConversationId[0], story: nextSelectedStoryData.storiesByConversationId[0],
}, },
storyViewMode, storyViewMode,
@ -855,6 +867,7 @@ const viewStory: ViewStoryActionCreatorType = (
selectedStoryData: { selectedStoryData: {
currentIndex: 0, currentIndex: 0,
numStories: nextSelectedStoryData.numStories, numStories: nextSelectedStoryData.numStories,
shouldShowDetailsModal: false,
story: nextSelectedStoryData.storiesByConversationId[0], story: nextSelectedStoryData.storiesByConversationId[0],
}, },
storyViewMode, storyViewMode,

View File

@ -101,6 +101,7 @@ export function getStoryView(
const sender = pick(conversationSelector(story.sourceUuid || story.source), [ const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'acceptedMessageRequest', 'acceptedMessageRequest',
'avatarPath', 'avatarPath',
'badges',
'color', 'color',
'firstName', 'firstName',
'hideStory', 'hideStory',
@ -132,17 +133,7 @@ export function getStoryView(
innerSendState.push({ innerSendState.push({
...recipientSendState, ...recipientSendState,
recipient: pick(recipient, [ recipient,
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]),
}); });
}); });

View File

@ -106,6 +106,7 @@ export function SmartStoryViewer(): JSX.Element | null {
recentEmojis={recentEmojis} recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
replyState={replyState} replyState={replyState}
shouldShowDetailsModal={selectedStoryData.shouldShowDetailsModal}
showToast={showToast} showToast={showToast}
skinTone={skinTone} skinTone={skinTone}
story={storyView} story={storyView}

View File

@ -52,18 +52,7 @@ export type ConversationStoryType = {
export type StorySendStateType = { export type StorySendStateType = {
isAllowedToReplyToStory?: boolean; isAllowedToReplyToStory?: boolean;
recipient: Pick< recipient: ConversationType;
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
status: SendStatus; status: SendStatus;
updatedAt?: number; updatedAt?: number;
}; };
@ -80,6 +69,7 @@ export type StoryViewType = {
ConversationType, ConversationType,
| 'acceptedMessageRequest' | 'acceptedMessageRequest'
| 'avatarPath' | 'avatarPath'
| 'badges'
| 'color' | 'color'
| 'firstName' | 'firstName'
| 'id' | 'id'

View File

@ -7,11 +7,16 @@ export function getClassNamesFor(
...modules: Array<string | undefined> ...modules: Array<string | undefined>
): (modifier?: string) => string { ): (modifier?: string) => string {
return modifier => { return modifier => {
const cx = modules.map(parentModule => if (modifier === undefined) {
parentModule && modifier !== undefined return '';
? `${parentModule}${modifier}` }
: undefined
); const cx = modules
.flatMap(m => (m ? m.split(' ') : undefined))
.map(parentModule =>
parentModule ? `${parentModule}${modifier}` : undefined
);
return classNames(cx); return classNames(cx);
}; };
} }