Update call link edit/add name modals

This commit is contained in:
Jamie Kyle 2024-06-25 11:56:28 -07:00 committed by GitHub
parent ba77ef7563
commit b691e24d5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 526 additions and 232 deletions

View file

@ -0,0 +1,28 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { CallLinkAddNameModalProps } from './CallLinkAddNameModal';
import { CallLinkAddNameModal } from './CallLinkAddNameModal';
import type { ComponentMeta } from '../storybook/types';
import { FAKE_CALL_LINK_WITH_ADMIN_KEY } from '../test-both/helpers/fakeCallLink';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/CallLinkAddNameModal',
component: CallLinkAddNameModal,
args: {
i18n,
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
onClose: action('onClose'),
onUpdateCallLinkName: action('onUpdateCallLinkName'),
},
} satisfies ComponentMeta<CallLinkAddNameModalProps>;
export function Basic(args: CallLinkAddNameModalProps): JSX.Element {
return <CallLinkAddNameModal {...args} />;
}

View file

@ -0,0 +1,98 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
import { Button, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { Input } from './Input';
import type { CallLinkType } from '../types/CallLink';
export type CallLinkAddNameModalProps = Readonly<{
i18n: LocalizerType;
callLink: CallLinkType;
onClose: () => void;
onUpdateCallLinkName: (name: string) => void;
}>;
export function CallLinkAddNameModal({
i18n,
callLink,
onClose,
onUpdateCallLinkName,
}: CallLinkAddNameModalProps): JSX.Element {
const [formId] = useState(() => generateUuid());
const [nameId] = useState(() => generateUuid());
const [nameInput, setNameInput] = useState(callLink.name);
const handleNameInputChange = useCallback((nextNameInput: string) => {
setNameInput(nextNameInput);
}, []);
const handleSubmit = useCallback(() => {
const nameValue = nameInput.trim();
if (nameValue === callLink.name) {
return;
}
onUpdateCallLinkName(nameValue);
onClose();
}, [nameInput, callLink, onUpdateCallLinkName, onClose]);
return (
<Modal
modalName="CallLinkAddNameModal"
i18n={i18n}
hasXButton
noEscapeClose
noMouseClose
title={i18n('icu:CallLinkAddNameModal__Title')}
onClose={onClose}
moduleClassName="CallLinkAddNameModal"
modalFooter={
<>
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')}
</Button>
<Button type="submit" form={formId} variant={ButtonVariant.Primary}>
{i18n('icu:save')}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="CallLinkAddNameModal__Row"
>
<Avatar
i18n={i18n}
badge={undefined}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={
callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name
}
/>
<label htmlFor={nameId} className="CallLinkAddNameModal__SrOnly">
{i18n('icu:CallLinkAddNameModal__NameLabel')}
</label>
<Input
i18n={i18n}
id={nameId}
value={nameInput}
placeholder={i18n('icu:CallLinkAddNameModal__NameLabel')}
autoFocus
onChange={handleNameInputChange}
moduleClassName="CallLinkAddNameModal__Input"
/>
</form>
</Modal>
);
}

View file

@ -20,7 +20,7 @@ export default {
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
onClose: action('onClose'),
onCopyCallLink: action('onCopyCallLink'),
onUpdateCallLinkName: action('onUpdateCallLinkName'),
onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'),
onUpdateCallLinkRestrictions: action('onUpdateCallLinkRestrictions'),
onShareCallLinkViaSignal: action('onShareCallLinkViaSignal'),
onStartCallLinkLobby: action('onStartCallLinkLobby'),

View file

@ -1,7 +1,8 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import React, { useMemo, useState } from 'react';
import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
@ -10,19 +11,67 @@ import {
toCallLinkRestrictions,
type CallLinkType,
} from '../types/CallLink';
import { Input } from './Input';
import { Select } from './Select';
import { linkCallRoute } from '../util/signalRoutes';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { formatUrlWithoutProtocol } from '../util/url';
const CallLinkEditModalRowIconClasses = {
Edit: 'CallLinkEditModal__RowIcon--Edit',
Approve: 'CallLinkEditModal__RowIcon--Approve',
Copy: 'CallLinkEditModal__RowIcon--Copy',
Share: 'CallLinkEditModal__RowIcon--Share',
} as const;
function RowIcon({
icon,
}: {
icon: keyof typeof CallLinkEditModalRowIconClasses;
}) {
return (
<i
role="presentation"
className={`CallLinkEditModal__RowIcon ${CallLinkEditModalRowIconClasses[icon]}`}
/>
);
}
function RowText({ children }: { children: ReactNode }) {
return <div className="CallLinkEditModal__RowLabel">{children}</div>;
}
function Row({ children }: { children: ReactNode }) {
return <div className="CallLinkEditModal__Row">{children}</div>;
}
function RowButton({
onClick,
children,
}: {
onClick: () => void;
children: ReactNode;
}) {
return (
<button
className="CallLinkEditModal__RowButton"
type="button"
onClick={onClick}
>
{children}
</button>
);
}
function Hr() {
return <hr className="CallLinkEditModal__Hr" />;
}
export type CallLinkEditModalProps = {
i18n: LocalizerType;
callLink: CallLinkType;
onClose: () => void;
onCopyCallLink: () => void;
onUpdateCallLinkName: (name: string) => void;
onOpenCallLinkAddNameModal: () => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
onShareCallLinkViaSignal: () => void;
onStartCallLinkLobby: () => void;
@ -33,64 +82,32 @@ export function CallLinkEditModal({
callLink,
onClose,
onCopyCallLink,
onUpdateCallLinkName,
onOpenCallLinkAddNameModal,
onUpdateCallLinkRestrictions,
onShareCallLinkViaSignal,
onStartCallLinkLobby,
}: CallLinkEditModalProps): JSX.Element {
const { name: savedName, restrictions: savedRestrictions } = callLink;
const [nameId] = useState(() => generateUuid());
const [restrictionsId] = useState(() => generateUuid());
const [nameInput, setNameInput] = useState(savedName);
const [restrictionsInput, setRestrictionsInput] = useState(savedRestrictions);
// We only want to use the default name "Signal Call" as a value if the user
// modified the input and then chose that name. Doesn't revert when saved.
const [nameTouched, setNameTouched] = useState(false);
const callLinkWebUrl = useMemo(() => {
return formatUrlWithoutProtocol(
linkCallRoute.toWebUrl({ key: callLink.rootKey })
);
return linkCallRoute.toWebUrl({ key: callLink.rootKey }).toString();
}, [callLink.rootKey]);
const onSaveName = useCallback(
(newName: string) => {
if (!nameTouched) {
return;
}
if (newName === savedName) {
return;
}
onUpdateCallLinkName(newName);
},
[nameTouched, savedName, onUpdateCallLinkName]
);
const onSaveRestrictions = useCallback(
(newRestrictions: CallLinkRestrictions) => {
if (newRestrictions === savedRestrictions) {
return;
}
onUpdateCallLinkRestrictions(newRestrictions);
},
[savedRestrictions, onUpdateCallLinkRestrictions]
);
return (
<Modal
i18n={i18n}
modalName="CallLinkEditModal"
moduleClassName="CallLinkEditModal"
title={i18n('icu:CallLinkEditModal__Title')}
hasXButton
onClose={() => {
// Save the modal in case the user hits escape
onSaveName(nameInput);
onClose();
}}
noEscapeClose
noMouseClose
padded={false}
modalFooter={
<Button type="submit" variant={ButtonVariant.Primary} onClick={onClose}>
{i18n('icu:done')}
</Button>
}
onClose={onClose}
>
<div className="CallLinkEditModal__Header">
<Avatar
@ -101,114 +118,96 @@ export function CallLinkEditModal({
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={callLink.name ?? i18n('icu:calling__call-link-default-title')}
title={
callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name
}
/>
<div className="CallLinkEditModal__Header__Details">
<label htmlFor={nameId} className="CallLinkEditModal__SrOnly">
{i18n('icu:CallLinkEditModal__InputLabel--Name--SrOnly')}
</label>
<Input
moduleClassName="CallLinkEditModal__Input--Name"
i18n={i18n}
value={
nameInput === '' && !nameTouched
? i18n('icu:calling__call-link-default-title')
: nameInput
}
maxByteCount={120}
onChange={value => {
setNameTouched(true);
setNameInput(value);
}}
onBlur={() => {
onSaveName(nameInput);
}}
onEnter={() => {
onSaveName(nameInput);
}}
placeholder={i18n('icu:calling__call-link-default-title')}
/>
<div className="CallLinkEditModal__CallLinkAndJoinButton">
<button
className="CallLinkEditModal__CopyUrlTextButton"
type="button"
onClick={onCopyCallLink}
aria-label={i18n('icu:CallLinkDetails__CopyLink')}
>
{callLinkWebUrl}
</button>
<Button
onClick={onStartCallLinkLobby}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
className="CallLinkEditModal__JoinButton"
>
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
</Button>
<div className="CallLinkEditModal__Header__Title">
{callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name}
</div>
<button
className="CallLinkEditModal__Header__CallLinkButton"
type="button"
onClick={onCopyCallLink}
aria-label={i18n('icu:CallLinkDetails__CopyLink')}
>
<div className="CallLinkEditModal__Header__CallLinkButton__Text">
{callLinkWebUrl}
</div>
</button>
</div>
<div className="CallLinkEditModal__Header__Actions">
<Button
onClick={onStartCallLinkLobby}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
className="CallLinkEditModal__JoinButton"
>
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
</Button>
</div>
</div>
<div
className="CallLinkEditModal__ApproveAllMembers__Row"
// For testing, to easily check the restrictions saved
data-restrictions={savedRestrictions}
>
<label
htmlFor={restrictionsId}
className="CallLinkEditModal__ApproveAllMembers__Label"
>
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
</label>
<Hr />
<RowButton onClick={onOpenCallLinkAddNameModal}>
<Row>
<RowIcon icon="Edit" />
<RowText>{i18n('icu:CallLinkEditModal__AddCallNameLabel')}</RowText>
</Row>
</RowButton>
<Row>
<RowIcon icon="Approve" />
<RowText>
<label htmlFor={restrictionsId}>
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
</label>
</RowText>
<Select
id={restrictionsId}
value={restrictionsInput}
value={String(callLink.restrictions)}
moduleClassName="CallLinkEditModal__RowSelect"
options={[
{
value: CallLinkRestrictions.None,
value: String(CallLinkRestrictions.None),
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--Off'
),
},
{
value: CallLinkRestrictions.AdminApproval,
value: String(CallLinkRestrictions.AdminApproval),
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--On'
),
},
]}
onChange={value => {
const newRestrictions = toCallLinkRestrictions(value);
setRestrictionsInput(newRestrictions);
onSaveRestrictions(newRestrictions);
onUpdateCallLinkRestrictions(toCallLinkRestrictions(value));
}}
/>
</div>
</Row>
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onCopyCallLink}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Copy"
/>
{i18n('icu:CallLinkDetails__CopyLink')}
</button>
<Hr />
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onShareCallLinkViaSignal}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Share"
/>
{i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
</button>
<RowButton onClick={onCopyCallLink}>
<Row>
<RowIcon icon="Copy" />
<RowText>{i18n('icu:CallLinkDetails__CopyLink')}</RowText>
</Row>
</RowButton>
<RowButton onClick={onShareCallLinkViaSignal}>
<Row>
<RowIcon icon="Share" />
<RowText>{i18n('icu:CallLinkDetails__ShareLinkViaSignal')}</RowText>
</Row>
</RowButton>
</Modal>
);
}

View file

@ -76,7 +76,7 @@ export function EditNicknameAndNoteModal({
}, [givenName, familyName, note]);
const handleSubmit = useCallback(
(event: MouseEvent | FormEvent) => {
(event: FormEvent) => {
event.preventDefault();
if (formResult.success) {
onSave(formResult.data);
@ -104,7 +104,6 @@ export function EditNicknameAndNoteModal({
type="submit"
form={formId}
aria-disabled={!formResult.success}
onClick={handleSubmit}
>
{i18n('icu:save')}
</Button>
@ -124,7 +123,7 @@ export function EditNicknameAndNoteModal({
theme={undefined}
/>
</div>
<form onSubmit={handleSubmit}>
<form id={formId} onSubmit={handleSubmit}>
<label
htmlFor={givenNameId}
className="EditNicknameAndNoteModal__Label"

View file

@ -29,6 +29,9 @@ export type PropsType = {
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId: string | undefined;
renderAddUserToAnotherGroup: () => JSX.Element;
// CallLinkAddNameModal
callLinkAddNameModalRoomId: string | null;
renderCallLinkAddNameModal: () => JSX.Element;
// CallLinkEditModal
callLinkEditModalRoomId: string | null;
renderCallLinkEditModal: () => JSX.Element;
@ -105,6 +108,9 @@ export function GlobalModalContainer({
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId,
renderAddUserToAnotherGroup,
// CallLinkAddNameModal
callLinkAddNameModalRoomId,
renderCallLinkAddNameModal,
// CallLinkEditModal
callLinkEditModalRoomId,
renderCallLinkEditModal,
@ -194,6 +200,10 @@ export function GlobalModalContainer({
return renderAddUserToAnotherGroup();
}
if (callLinkAddNameModalRoomId) {
return renderCallLinkAddNameModal();
}
if (callLinkEditModalRoomId) {
return renderCallLinkEditModal();
}

View file

@ -18,6 +18,7 @@ import { useRefMerger } from '../hooks/useRefMerger';
import { byteLength } from '../Bytes';
export type PropsType = {
autoFocus?: boolean;
countBytes?: (value: string) => number;
countLength?: (value: string) => number;
disabled?: boolean;
@ -63,6 +64,7 @@ export const Input = forwardRef<
PropsType
>(function InputInner(
{
autoFocus,
countBytes = byteLength,
countLength = grapheme.count,
disabled,
@ -206,6 +208,7 @@ export const Input = forwardRef<
const isTextarea = expandable || forceTextarea;
const inputProps = {
autoFocus,
className: classNames(
getClassName('__input'),
icon && getClassName('__input--with-icon'),

View file

@ -43,6 +43,7 @@ type PropsType = {
export type ModalPropsType = PropsType & {
noTransform?: boolean;
noEscapeClose?: boolean;
noMouseClose?: boolean;
theme?: Theme;
};
@ -57,6 +58,7 @@ export function Modal({
modalFooter,
modalHeaderChildren,
moduleClassName,
noEscapeClose,
noMouseClose,
onBackButtonClick,
onClose = noop,
@ -114,6 +116,7 @@ export function Modal({
<ModalHost
modalName={modalName}
moduleClassName={moduleClassName}
noEscapeClose={noEscapeClose}
noMouseClose={noMouseClose}
onClose={close}
onEscape={onBackButtonClick}

View file

@ -27,6 +27,7 @@ export type PropsType = Readonly<{
children: React.ReactElement;
modalName: string;
moduleClassName?: string;
noEscapeClose?: boolean;
noMouseClose?: boolean;
onClose: () => unknown;
onEscape?: () => unknown;
@ -40,6 +41,7 @@ export const ModalHost = React.memo(function ModalHostInner({
children,
modalName,
moduleClassName,
noEscapeClose,
noMouseClose,
onClose,
onEscape,
@ -72,7 +74,7 @@ export const ModalHost = React.memo(function ModalHostInner({
};
}, [modalContainer]);
useEscapeHandling(onEscape || onClose);
useEscapeHandling(noEscapeClose ? noop : onEscape || onClose);
useEffect(() => {
if (noMouseClose) {
return noop;