Init create/admin call links flow

This commit is contained in:
Jamie Kyle 2024-06-10 08:23:43 -07:00 committed by GitHub
parent 53b8f5f152
commit f19f0fb47d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1256 additions and 149 deletions

View file

@ -0,0 +1,32 @@
// 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 { CallLinkEditModalProps } from './CallLinkEditModal';
import { CallLinkEditModal } from './CallLinkEditModal';
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/CallLinkEditModal',
component: CallLinkEditModal,
args: {
i18n,
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
onClose: action('onClose'),
onCopyCallLink: action('onCopyCallLink'),
onUpdateCallLinkName: action('onUpdateCallLinkName'),
onUpdateCallLinkRestrictions: action('onUpdateCallLinkRestrictions'),
onShareCallLinkViaSignal: action('onShareCallLinkViaSignal'),
onStartCallLinkLobby: action('onStartCallLinkLobby'),
},
} satisfies ComponentMeta<CallLinkEditModalProps>;
export function Basic(args: CallLinkEditModalProps): JSX.Element {
return <CallLinkEditModal {...args} />;
}

View file

@ -0,0 +1,214 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo, useState } from 'react';
import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
import {
CallLinkRestrictions,
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';
export type CallLinkEditModalProps = {
i18n: LocalizerType;
callLink: CallLinkType;
onClose: () => void;
onCopyCallLink: () => void;
onUpdateCallLinkName: (name: string) => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
onShareCallLinkViaSignal: () => void;
onStartCallLinkLobby: () => void;
};
export function CallLinkEditModal({
i18n,
callLink,
onClose,
onCopyCallLink,
onUpdateCallLinkName,
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 })
);
}, [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();
}}
>
<div className="CallLinkEditModal__Header">
<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')}
/>
<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>
</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>
<Select
id={restrictionsId}
value={restrictionsInput}
options={[
{
value: CallLinkRestrictions.None,
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--Off'
),
},
{
value: CallLinkRestrictions.AdminApproval,
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--On'
),
},
]}
onChange={value => {
const newRestrictions = toCallLinkRestrictions(value);
setRestrictionsInput(newRestrictions);
onSaveRestrictions(newRestrictions);
}}
/>
</div>
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onCopyCallLink}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Copy"
/>
{i18n('icu:CallLinkDetails__CopyLink')}
</button>
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onShareCallLinkViaSignal}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Share"
/>
{i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
</button>
</Modal>
);
}

View file

@ -127,6 +127,7 @@ const defaultPendingState: SearchState = {
type CallsListProps = Readonly<{
activeCall: ActiveCallStateType | undefined;
canCreateCallLinks: boolean;
getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions
) => Promise<number>;
@ -142,6 +143,7 @@ type CallsListProps = Readonly<{
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
selectedCallHistoryGroup: CallHistoryGroup | null;
onCreateCallLink: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
@ -157,10 +159,6 @@ const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
const PEEK_BATCH_COUNT = 10;
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
function rowHeight() {
return CALL_LIST_ITEM_ROW_HEIGHT;
}
function isSameOptions(
a: CallHistoryFilterOptions,
b: CallHistoryFilterOptions
@ -168,8 +166,12 @@ function isSameOptions(
return a.query === b.query && a.status === b.status;
}
type SpecialRows = 'CreateCallLink' | 'EmptyState';
type Row = CallHistoryGroup | SpecialRows;
export function CallsList({
activeCall,
canCreateCallLinks,
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
@ -180,6 +182,7 @@ export function CallsList({
hangUpActiveCall,
i18n,
selectedCallHistoryGroup,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onChangeCallsTabSelectedView,
@ -190,7 +193,7 @@ export function CallsList({
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List>(null);
const [queryInput, setQueryInput] = useState('');
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
const [statusInput, setStatusInput] = useState(CallHistoryFilterStatus.All);
const [searchState, setSearchState] = useState(defaultInitState);
const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] =
useState(false);
@ -200,6 +203,27 @@ export function CallsList({
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
const getCallHistoryGroupsRef = useRef(getCallHistoryGroups);
const searchStateQuery = searchState.options?.query ?? '';
const searchStateStatus =
searchState.options?.status ?? CallHistoryFilterStatus.All;
const searchFiltering =
searchStateQuery !== '' ||
searchStateStatus !== CallHistoryFilterStatus.All;
const searchPending = searchState.state === 'pending';
const rows = useMemo(() => {
let results: ReadonlyArray<Row> = searchState.results?.items ?? [];
if (results.length === 0) {
results = ['EmptyState'];
}
if (!searchFiltering && canCreateCallLinks) {
results = ['CreateCallLink', ...results];
}
return results;
}, [searchState.results?.items, searchFiltering, canCreateCallLinks]);
const rowCount = rows.length;
const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(
null
);
@ -208,17 +232,14 @@ export function CallsList({
new Map()
);
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
function clearPeekQueueTimer() {
if (peekQueueTimerRef.current != null) {
clearInterval(peekQueueTimerRef.current);
peekQueueTimerRef.current = null;
}
}
useEffect(() => {
return () => {
clearPeekQueueTimer();
if (peekQueueTimerRef.current != null) {
clearInterval(peekQueueTimerRef.current);
peekQueueTimerRef.current = null;
}
};
}, []);
@ -489,7 +510,7 @@ export function CallsList({
async function search() {
const options: CallHistoryFilterOptions = {
query: queryInput.toLowerCase().normalize().trim(),
status,
status: statusInput,
};
let timer = setTimeout(() => {
@ -560,7 +581,7 @@ export function CallsList({
return () => {
controller.abort();
};
}, [queryInput, status, callHistoryEdition, enqueueCallPeeks]);
}, [queryInput, statusInput, callHistoryEdition, enqueueCallPeeks]);
const loadMoreRows = useCallback(
async (props: IndexRange) => {
@ -625,9 +646,72 @@ export function CallsList({
[searchState]
);
const rowHeight = useCallback(
({ index }: Index) => {
const item = rows.at(index) ?? null;
if (item === 'EmptyState') {
// arbitary large number so the empty state can be as big as it wants,
// scrolling should always be locked when the list is empty
return 9999;
}
return CALL_LIST_ITEM_ROW_HEIGHT;
},
[rows]
);
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = searchState.results?.items.at(index) ?? null;
const item = rows.at(index) ?? null;
if (item === 'CreateCallLink') {
return (
<div key={key} style={style}>
<ListTile
moduleClassName="CallsList__ItemTile"
title={
<span className="CallsList__ItemTitle">
{i18n('icu:CallsList__CreateCallLink')}
</span>
}
leading={
<Avatar
acceptedMessageRequest
conversationType="callLink"
i18n={i18n}
isMe={false}
title=""
sharedGroupNames={[]}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
className="CallsList__ItemAvatar"
/>
}
onClick={onCreateCallLink}
/>
</div>
);
}
if (item === 'EmptyState') {
return (
<div key={key} className="CallsList__EmptyState" style={style}>
{searchStateQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<I18n
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={searchStateQuery} />,
}}
/>
)}
</div>
);
}
const conversation = getConversationForItem(item);
const activeCallConversationId = activeCall?.conversationId;
@ -647,11 +731,7 @@ export function CallsList({
);
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
if (
searchState.state === 'pending' ||
item == null ||
conversation == null
) {
if (searchPending || item == null || conversation == null) {
return (
<div key={key} style={style}>
<ListTile
@ -697,6 +777,7 @@ export function CallsList({
<div
key={key}
style={style}
data-type={item.type}
className={classNames('CallsList__Item', {
'CallsList__Item--selected': isSelected,
'CallsList__Item--missed': wasMissed,
@ -792,13 +873,16 @@ export function CallsList({
},
[
activeCall,
searchState,
rows,
searchStateQuery,
searchPending,
getCallLink,
getConversationForItem,
getIsCallActive,
getIsInCall,
selectedCallHistoryGroup,
onChangeCallsTabSelectedView,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId,
@ -819,18 +903,13 @@ export function CallsList({
}, []);
const handleStatusToggle = useCallback(() => {
setStatus(prevStatus => {
setStatusInput(prevStatus => {
return prevStatus === CallHistoryFilterStatus.All
? CallHistoryFilterStatus.Missed
: CallHistoryFilterStatus.All;
});
}, []);
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
const hasEmptyResults = searchState.results?.count === 0;
const currentQuery = searchState.options?.query ?? '';
return (
<>
{isLeaveCallDialogVisible && (
@ -874,10 +953,11 @@ export function CallsList({
>
<button
className={classNames('CallsList__ToggleFilterByMissed', {
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
'CallsList__ToggleFilterByMissed--pressed':
statusInput === CallHistoryFilterStatus.Missed,
})}
type="button"
aria-pressed={filteringByMissed}
aria-pressed={statusInput === CallHistoryFilterStatus.Missed}
aria-roledescription={i18n(
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
)}
@ -890,22 +970,6 @@ export function CallsList({
</Tooltip>
</NavSidebarSearchHeader>
{hasEmptyResults && (
<p className="CallsList__EmptyState">
{currentQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<I18n
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={currentQuery} />,
}}
/>
)}
</p>
)}
<SizeObserver>
{(ref, size) => {
return (
@ -915,7 +979,7 @@ export function CallsList({
ref={infiniteLoaderRef}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={searchState.results?.count}
rowCount={rowCount}
minimumBatchSize={100}
threshold={30}
>
@ -923,13 +987,14 @@ export function CallsList({
return (
<List
className={classNames('CallsList__List', {
'CallsList__List--loading':
searchState.state === 'pending',
'CallsList__List--disableScrolling':
searchState.results == null ||
searchState.results.count === 0,
})}
ref={refMerger(listRef, registerChild)}
width={size.width}
height={size.height}
rowCount={searchState.results?.count ?? 0}
rowCount={rowCount}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
onRowsRendered={onRowsRendered}

View file

@ -41,6 +41,7 @@ type CallsTabProps = Readonly<{
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
callHistoryEdition: number;
canCreateCallLinks: boolean;
getAdhocCall: (roomId: string) => CallStateType | undefined;
getCall: (id: string) => CallStateType | undefined;
getCallLink: (id: string) => CallLinkType | undefined;
@ -53,6 +54,7 @@ type CallsTabProps = Readonly<{
onClearCallHistory: () => void;
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onCreateCallLink: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
@ -93,6 +95,7 @@ export function CallsTab({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
canCreateCallLinks,
getAdhocCall,
getCall,
getCallLink,
@ -105,6 +108,7 @@ export function CallsTab({
onClearCallHistory,
onMarkCallHistoryRead,
onToggleNavTabsCollapse,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall,
@ -257,6 +261,7 @@ export function CallsTab({
<CallsList
key={CallsTabSidebarView.CallsListView}
activeCall={activeCall}
canCreateCallLinks={canCreateCallLinks}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
callHistoryEdition={callHistoryEdition}
@ -268,6 +273,7 @@ export function CallsTab({
i18n={i18n}
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
onChangeCallsTabSelectedView={updateSelectedView}
onCreateCallLink={onCreateCallLink}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}

View file

@ -29,6 +29,9 @@ export type PropsType = {
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId: string | undefined;
renderAddUserToAnotherGroup: () => JSX.Element;
// CallLinkEditModal
callLinkEditModalRoomId: string | null;
renderCallLinkEditModal: () => JSX.Element;
// ContactModal
contactModalState: ContactModalStateType | undefined;
renderContactModal: () => JSX.Element;
@ -102,6 +105,9 @@ export function GlobalModalContainer({
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId,
renderAddUserToAnotherGroup,
// CallLinkEditModal
callLinkEditModalRoomId,
renderCallLinkEditModal,
// ContactModal
contactModalState,
renderContactModal,
@ -164,7 +170,8 @@ export function GlobalModalContainer({
// We want the following dialogs to show in this order:
// 1. Errors
// 2. Safety Number Changes
// 3. The Rest (in no particular order, but they're ordered alphabetically)
// 3. Forward Modal, so other modals can open it
// 4. The Rest (in no particular order, but they're ordered alphabetically)
// Errors
if (errorModalProps) {
@ -176,12 +183,21 @@ export function GlobalModalContainer({
return renderSendAnywayDialog();
}
// Forward Modal
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
// The Rest
if (addUserToAnotherGroupModalContactId) {
return renderAddUserToAnotherGroup();
}
if (callLinkEditModalRoomId) {
return renderCallLinkEditModal();
}
if (editHistoryMessages) {
return renderEditHistoryMessagesModal();
}
@ -194,10 +210,6 @@ export function GlobalModalContainer({
return renderDeleteMessagesModal();
}
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
if (messageRequestActionsConfirmationProps) {
return renderMessageRequestActionsConfirmation();
}

View file

@ -32,6 +32,7 @@ export type PropsType = {
maxLengthCount?: number;
moduleClassName?: string;
onChange: (value: string) => unknown;
onBlur?: () => unknown;
onEnter?: () => unknown;
placeholder: string;
value?: string;
@ -76,6 +77,7 @@ export const Input = forwardRef<
maxLengthCount = 0,
moduleClassName,
onChange,
onBlur,
onEnter,
placeholder,
value = '',
@ -214,6 +216,7 @@ export const Input = forwardRef<
id,
spellCheck: !disableSpellcheck,
onChange: handleChange,
onBlur,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
placeholder,