Merge branch 'main' into HEAD

This commit is contained in:
Scott Nonnenberg 2024-07-30 16:46:34 -07:00
commit d57d0cea19
1135 changed files with 264116 additions and 302492 deletions

View file

@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useContext } from 'react';
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -13,7 +13,6 @@ import {
} from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import { AddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
const i18n = setupI18n('en', enMessages);
@ -36,7 +35,6 @@ const Template: StoryFn<Props> = args => {
toggleAddUserToAnotherGroupModal={action(
'toggleAddUserToAnotherGroupModal'
)}
theme={useContext(StorybookThemeContext)}
/>
);
};

View file

@ -6,9 +6,9 @@ import React, { useCallback } from 'react';
import type { ListRowProps } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import { ToastType } from '../types/Toast';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { filterAndSortConversations } from '../util/filterAndSortConversations';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
import {
@ -25,7 +25,6 @@ import { SizeObserver } from '../hooks/useSizeObserver';
type OwnProps = {
i18n: LocalizerType;
theme: ThemeType;
contact: Pick<ConversationType, 'id' | 'title' | 'serviceId' | 'pni'>;
candidateConversations: ReadonlyArray<ConversationType>;
regionCode: string | undefined;
@ -57,7 +56,7 @@ export function AddUserToAnotherGroupModal({
}: Props): JSX.Element | null {
const [searchTerm, setSearchTerm] = React.useState('');
const [filteredConversations, setFilteredConversations] = React.useState(
filterAndSortConversationsByRecent(candidateConversations, '', undefined)
filterAndSortConversations(candidateConversations, '', undefined)
);
const [selectedGroupId, setSelectedGroupId] = React.useState<
@ -79,7 +78,7 @@ export function AddUserToAnotherGroupModal({
React.useEffect(() => {
const timeout = setTimeout(() => {
setFilteredConversations(
filterAndSortConversationsByRecent(
filterAndSortConversations(
candidateConversations,
normalizedSearchTerm,
regionCode
@ -130,7 +129,7 @@ export function AddUserToAnotherGroupModal({
}
return {
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
...pick(convo, 'id', 'avatarUrl', 'title', 'unblurredAvatarUrl'),
memberships,
membersCount,
disabledReason,

View file

@ -6,7 +6,7 @@ import type {
ConversationType,
ShowConversationType,
} from '../state/ducks/conversations';
import { Intl } from './Intl';
import { I18n } from './I18n';
import type { LocalizerType, ThemeType } from '../types/Util';
import { Modal } from './Modal';
import { ConversationListItem } from './conversationList/ConversationListItem';
@ -51,7 +51,7 @@ export function AnnouncementsOnlyGroupBanner({
</Modal>
)}
<div className="AnnouncementsOnlyGroupBanner__banner">
<Intl
<I18n
i18n={i18n}
id="icu:AnnouncementsOnlyGroupBanner--announcements-only"
components={{

View file

@ -17,14 +17,20 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = {
appView: AppViewType;
openInbox: () => void;
getCaptchaToken: () => Promise<string>;
registerSingleDevice: (
number: string,
code: string,
sessionId: string
) => Promise<void>;
uploadProfile: (opts: {
firstName: string;
lastName: string;
}) => Promise<void>;
renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element;
hasSelectedStoryData: boolean;
readyForUpdates: () => void;
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
renderLightbox: () => JSX.Element | null;
requestVerification: (
@ -44,11 +50,13 @@ type PropsType = {
export function App({
appView,
getCaptchaToken,
hasSelectedStoryData,
isFullScreen,
isMaximized,
openInbox,
osClassName,
readyForUpdates,
registerSingleDevice,
renderCallManager,
renderGlobalModalContainer,
@ -57,6 +65,7 @@ export function App({
renderStoryViewer,
requestVerification,
theme,
uploadProfile,
viewStory,
}: PropsType): JSX.Element {
let contents;
@ -71,8 +80,11 @@ export function App({
contents = (
<StandaloneRegistration
onComplete={onComplete}
getCaptchaToken={getCaptchaToken}
readyForUpdates={readyForUpdates}
requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
/>
);
} else if (appView === AppViewType.Inbox) {

View file

@ -0,0 +1,56 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ForwardedRef } from 'react';
import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react';
import { mergeRefs } from '@react-aria/utils';
import { strictAssert } from '../util/assert';
import type { PropsType } from './Input';
import { Input } from './Input';
export const AutoSizeTextArea = forwardRef(function AutoSizeTextArea(
props: PropsType,
ref: ForwardedRef<HTMLTextAreaElement>
): JSX.Element {
const ownRef = useRef<HTMLTextAreaElement | null>(null);
const textareaRef = mergeRefs(ownRef, ref);
function update(textarea: HTMLTextAreaElement) {
const styles = window.getComputedStyle(textarea);
const { scrollHeight } = textarea;
let height = 'calc(';
height += `${scrollHeight}px`;
if (styles.boxSizing === 'border-box') {
height += ` + ${styles.borderTopWidth} + ${styles.borderBottomWidth}`;
} else {
height += ` - ${styles.paddingTop} - ${styles.paddingBottom}`;
}
height += ')';
Object.assign(textarea.style, {
height,
overflow: 'hidden',
resize: 'none',
});
}
useEffect(() => {
strictAssert(ownRef.current, 'inputRef.current should be defined');
const textarea = ownRef.current;
function onInput() {
textarea.style.height = 'auto';
requestAnimationFrame(() => update(textarea));
}
textarea.addEventListener('input', onInput);
return () => {
textarea.removeEventListener('input', onInput);
};
}, []);
useLayoutEffect(() => {
strictAssert(ownRef.current, 'inputRef.current should be defined');
const textarea = ownRef.current;
textarea.style.height = 'auto';
update(textarea);
}, [props.value]);
return <Input ref={textareaRef} {...props} forceTextarea />;
});

View file

@ -4,9 +4,8 @@
import type { Meta, StoryFn } from '@storybook/react';
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { expect, jest } from '@storybook/jest';
import { isBoolean } from 'lodash';
import { within, userEvent } from '@storybook/testing-library';
import { expect, fn, within, userEvent } from '@storybook/test';
import type { AvatarColorType } from '../types/Colors';
import type { Props } from './Avatar';
import enMessages from '../../_locales/en/messages.json';
@ -19,19 +18,6 @@ import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
const colorMap: Record<string, AvatarColorType> = AvatarColors.reduce(
(m, color) => ({
...m,
[color]: color,
}),
{}
);
const conversationTypeMap: Record<string, Props['conversationType']> = {
direct: 'direct',
group: 'group',
};
export default {
title: 'Components/Avatar',
component: Avatar,
@ -41,19 +27,19 @@ export default {
},
blur: {
control: { type: 'radio' },
options: {
Undefined: undefined,
NoBlur: AvatarBlur.NoBlur,
BlurPicture: AvatarBlur.BlurPicture,
BlurPictureWithClickToView: AvatarBlur.BlurPictureWithClickToView,
},
options: [
undefined,
AvatarBlur.NoBlur,
AvatarBlur.BlurPicture,
AvatarBlur.BlurPictureWithClickToView,
],
},
color: {
options: colorMap,
options: AvatarColors,
},
conversationType: {
control: { type: 'radio' },
options: conversationTypeMap,
options: ['direct', 'group'],
},
size: {
control: false,
@ -64,7 +50,7 @@ export default {
},
theme: {
control: { type: 'radio' },
options: ThemeType,
options: [ThemeType.light, ThemeType.dark],
},
},
args: {
@ -79,7 +65,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest)
? overrideProps.acceptedMessageRequest
: true,
avatarPath: overrideProps.avatarPath || '',
avatarUrl: overrideProps.avatarUrl || '',
badge: overrideProps.badge,
blur: overrideProps.blur,
color: overrideProps.color || AvatarColors[0],
@ -88,7 +74,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isMe: false,
loading: Boolean(overrideProps.loading),
noteToSelf: Boolean(overrideProps.noteToSelf),
onClick: jest.fn(action('onClick')),
onClick: fn(action('onClick')),
onClickBadge: action('onClickBadge'),
phoneNumber: overrideProps.phoneNumber || '',
searchResult: Boolean(overrideProps.searchResult),
@ -121,7 +107,7 @@ const TemplateSingle: StoryFn<Props> = (args: Props) => (
export const Default = Template.bind({});
Default.args = createProps({
avatarPath: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
avatarUrl: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Default.play = async (context: any) => {
@ -134,13 +120,13 @@ Default.play = async (context: any) => {
export const WithBadge = Template.bind({});
WithBadge.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
badge: getFakeBadge(),
});
export const WideImage = Template.bind({});
WideImage.args = createProps({
avatarPath: '/fixtures/wide.jpg',
avatarUrl: '/fixtures/wide.jpg',
});
export const OneWordName = Template.bind({});
@ -200,12 +186,12 @@ BrokenColor.args = createProps({
export const BrokenAvatar = Template.bind({});
BrokenAvatar.args = createProps({
avatarPath: 'badimage.png',
avatarUrl: 'badimage.png',
});
export const BrokenAvatarForGroup = Template.bind({});
BrokenAvatarForGroup.args = createProps({
avatarPath: 'badimage.png',
avatarUrl: 'badimage.png',
conversationType: 'group',
});
@ -217,29 +203,29 @@ Loading.args = createProps({
export const BlurredBasedOnProps = TemplateSingle.bind({});
BlurredBasedOnProps.args = createProps({
acceptedMessageRequest: false,
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
});
export const ForceBlurred = TemplateSingle.bind({});
ForceBlurred.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
blur: AvatarBlur.BlurPicture,
});
export const BlurredWithClickToView = TemplateSingle.bind({});
BlurredWithClickToView.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
blur: AvatarBlur.BlurPictureWithClickToView,
});
export const StoryUnread = TemplateSingle.bind({});
StoryUnread.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
storyRing: HasStories.Unread,
});
export const StoryRead = TemplateSingle.bind({});
StoryRead.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
storyRing: HasStories.Read,
});

View file

@ -38,11 +38,13 @@ export enum AvatarSize {
TWENTY = 20,
TWENTY_FOUR = 24,
TWENTY_EIGHT = 28,
THIRTY = 30,
THIRTY_TWO = 32,
THIRTY_SIX = 36,
FORTY = 40,
FORTY_EIGHT = 48,
FIFTY_TWO = 52,
SIXTY_FOUR = 64,
EIGHTY = 80,
NINETY_SIX = 96,
TWO_HUNDRED_SIXTEEN = 216,
@ -51,7 +53,7 @@ export enum AvatarSize {
type BadgePlacementType = { bottom: number; right: number };
export type Props = {
avatarPath?: string;
avatarUrl?: string;
blur?: AvatarBlur;
color?: AvatarColorType;
loading?: boolean;
@ -65,7 +67,7 @@ export type Props = {
sharedGroupNames: ReadonlyArray<string>;
size: AvatarSize;
title: string;
unblurredAvatarPath?: string;
unblurredAvatarUrl?: string;
searchResult?: boolean;
storyRing?: HasStories;
@ -85,6 +87,7 @@ export type Props = {
const BADGE_PLACEMENT_BY_SIZE = new Map<number, BadgePlacementType>([
[28, { bottom: -4, right: -2 }],
[30, { bottom: -4, right: -2 }],
[32, { bottom: -4, right: -2 }],
[36, { bottom: -3, right: 0 }],
[40, { bottom: -6, right: -4 }],
@ -104,7 +107,7 @@ const getDefaultBlur = (
export function Avatar({
acceptedMessageRequest,
avatarPath,
avatarUrl,
badge,
className,
color = 'A200',
@ -120,15 +123,15 @@ export function Avatar({
size,
theme,
title,
unblurredAvatarPath,
unblurredAvatarUrl,
searchResult,
storyRing,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
avatarUrl,
isMe,
sharedGroupNames,
unblurredAvatarPath,
unblurredAvatarUrl,
}),
...ariaProps
}: Props): JSX.Element {
@ -136,15 +139,15 @@ export function Avatar({
useEffect(() => {
setImageBroken(false);
}, [avatarPath]);
}, [avatarUrl]);
useEffect(() => {
if (!avatarPath) {
if (!avatarUrl) {
return noop;
}
const image = new Image();
image.src = avatarPath;
image.src = avatarUrl;
image.onerror = () => {
log.warn('Avatar: Image failed to load; failing over to placeholder');
setImageBroken(true);
@ -153,12 +156,15 @@ export function Avatar({
return () => {
image.onerror = noop;
};
}, [avatarPath]);
}, [avatarUrl]);
const initials = getInitials(title);
const hasImage = !noteToSelf && avatarPath && !imageBroken;
const hasImage = !noteToSelf && avatarUrl && !imageBroken;
const shouldUseInitials =
!hasImage && conversationType === 'direct' && Boolean(initials);
!hasImage &&
conversationType === 'direct' &&
Boolean(initials) &&
title !== i18n('icu:unknownContact');
let contentsChildren: ReactNode;
if (loading) {
@ -173,7 +179,7 @@ export function Avatar({
</div>
);
} else if (hasImage) {
assertDev(avatarPath, 'avatarPath should be defined here');
assertDev(avatarUrl, 'avatarUrl should be defined here');
assertDev(
blur !== AvatarBlur.BlurPictureWithClickToView ||
@ -189,7 +195,7 @@ export function Avatar({
<div
className="module-Avatar__image"
style={{
backgroundImage: `url('${encodeURI(avatarPath)}')`,
backgroundImage: `url('${avatarUrl}')`,
...(isBlurred ? { filter: `blur(${Math.ceil(size / 2)}px)` } : {}),
}}
/>
@ -310,7 +316,7 @@ export function Avatar({
Boolean(storyRing) && 'module-Avatar--with-story',
storyRing === HasStories.Unread && 'module-Avatar--with-story--unread',
className,
avatarPath === SIGNAL_AVATAR_PATH
avatarUrl === SIGNAL_AVATAR_PATH
? 'module-Avatar--signal-official'
: undefined
)}

View file

@ -18,7 +18,7 @@ const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarColor: overrideProps.avatarColor || AvatarColors[9],
avatarPath: overrideProps.avatarPath,
avatarUrl: overrideProps.avatarUrl,
conversationId: '123',
conversationTitle: overrideProps.conversationTitle || 'Default Title',
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
@ -104,7 +104,7 @@ export function HasAvatar(): JSX.Element {
return (
<AvatarEditor
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
})}
/>
);

View file

@ -24,7 +24,7 @@ import { missingCaseError } from '../util/missingCaseError';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarUrl?: string;
avatarValue?: Uint8Array;
conversationId?: string;
conversationTitle?: string;
@ -46,7 +46,7 @@ enum EditMode {
export function AvatarEditor({
avatarColor,
avatarPath,
avatarUrl,
avatarValue,
conversationId,
conversationTitle,
@ -152,7 +152,7 @@ export function AvatarEditor({
}, []);
const hasChanges =
initialAvatar !== avatarPreview || Boolean(pendingClear && avatarPath);
initialAvatar !== avatarPreview || Boolean(pendingClear && avatarUrl);
let content: JSX.Element | undefined;
@ -162,7 +162,7 @@ export function AvatarEditor({
<div className="AvatarEditor__preview">
<AvatarPreview
avatarColor={avatarColor}
avatarPath={pendingClear ? undefined : avatarPath}
avatarUrl={pendingClear ? undefined : avatarUrl}
avatarValue={avatarPreview}
conversationTitle={conversationTitle}
i18n={i18n}

View file

@ -45,5 +45,5 @@ export function Person(args: PropsType): JSX.Element {
}
export function Photo(args: PropsType): JSX.Element {
return <AvatarLightbox {...args} avatarPath="/fixtures/kitten-1-64-64.jpg" />;
return <AvatarLightbox {...args} avatarUrl="/fixtures/kitten-1-64-64.jpg" />;
}

View file

@ -11,7 +11,7 @@ import type { LocalizerType } from '../types/Util';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarUrl?: string;
conversationTitle?: string;
i18n: LocalizerType;
isGroup?: boolean;
@ -20,7 +20,7 @@ export type PropsType = {
export function AvatarLightbox({
avatarColor,
avatarPath,
avatarUrl,
conversationTitle,
i18n,
isGroup,
@ -43,16 +43,17 @@ export function AvatarLightbox({
>
<AvatarPreview
avatarColor={avatarColor}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
conversationTitle={conversationTitle}
i18n={i18n}
isGroup={isGroup}
style={{
fontSize: '16em',
height: '2em',
maxHeight: 512,
maxWidth: 512,
width: '2em',
width: 'auto',
minHeight: '64px',
height: '100%',
maxHeight: `min(${512}px, 100%)`,
aspectRatio: '1 / 1',
}}
/>
</Lightbox>

View file

@ -24,7 +24,7 @@ const TEST_IMAGE = new Uint8Array(
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarColor: overrideProps.avatarColor,
avatarPath: overrideProps.avatarPath,
avatarUrl: overrideProps.avatarUrl,
avatarValue: overrideProps.avatarValue,
conversationTitle: overrideProps.conversationTitle,
i18n,
@ -81,7 +81,7 @@ export function Value(): JSX.Element {
export function Path(): JSX.Element {
return (
<AvatarPreview
{...createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg' })}
{...createProps({ avatarUrl: '/fixtures/kitten-3-64-64.jpg' })}
/>
);
}
@ -90,7 +90,7 @@ export function ValueAndPath(): JSX.Element {
return (
<AvatarPreview
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
avatarValue: TEST_IMAGE,
})}
/>

View file

@ -15,7 +15,7 @@ import { imagePathToBytes } from '../util/imagePathToBytes';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarUrl?: string;
avatarValue?: Uint8Array;
conversationTitle?: string;
i18n: LocalizerType;
@ -35,7 +35,7 @@ enum ImageStatus {
export function AvatarPreview({
avatarColor = AvatarColors[0],
avatarPath,
avatarUrl,
avatarValue,
conversationTitle,
i18n,
@ -48,15 +48,15 @@ export function AvatarPreview({
}: PropsType): JSX.Element {
const [avatarPreview, setAvatarPreview] = useState<Uint8Array | undefined>();
// Loads the initial avatarPath if one is provided, but only if we're in editable mode.
// If we're not editable, we assume that we either have an avatarPath or we show a
// Loads the initial avatarUrl if one is provided, but only if we're in editable mode.
// If we're not editable, we assume that we either have an avatarUrl or we show a
// default avatar.
useEffect(() => {
if (!isEditable) {
return;
}
if (!avatarPath) {
if (!avatarUrl) {
return noop;
}
@ -64,7 +64,7 @@ export function AvatarPreview({
void (async () => {
try {
const buffer = await imagePathToBytes(avatarPath);
const buffer = await imagePathToBytes(avatarUrl);
if (shouldCancel) {
return;
}
@ -85,7 +85,7 @@ export function AvatarPreview({
return () => {
shouldCancel = true;
};
}, [avatarPath, onAvatarLoaded, isEditable]);
}, [avatarUrl, onAvatarLoaded, isEditable]);
// Ensures that when avatarValue changes we generate new URLs
useEffect(() => {
@ -120,8 +120,8 @@ export function AvatarPreview({
} else if (objectUrl) {
encodedPath = objectUrl;
imageStatus = ImageStatus.HasImage;
} else if (avatarPath) {
encodedPath = encodeURI(avatarPath);
} else if (avatarUrl) {
encodedPath = avatarUrl;
imageStatus = ImageStatus.HasImage;
} else {
imageStatus = ImageStatus.Nothing;

View file

@ -5,13 +5,13 @@ import React from 'react';
import classNames from 'classnames';
export type PropsType = {
avatarPath?: string;
avatarUrl?: string;
children?: React.ReactNode;
className?: string;
};
export function CallBackgroundBlur({
avatarPath,
avatarUrl,
children,
className,
}: PropsType): JSX.Element {
@ -19,15 +19,15 @@ export function CallBackgroundBlur({
<div
className={classNames(
'module-calling__background',
!avatarPath && 'module-calling__background--no-avatar',
!avatarUrl && 'module-calling__background--no-avatar',
className
)}
>
{avatarPath && (
{avatarUrl && (
<div
className="module-calling__background--blur"
style={{
backgroundImage: `url('${encodeURI(avatarPath)}')`,
backgroundImage: `url('${avatarUrl}')`,
}}
/>
)}

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,120 @@
// 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 { Button, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { Input } from './Input';
import {
CallLinkNameMaxByteLength,
type CallLinkType,
} from '../types/CallLink';
import { getColorForCallLink } from '../util/getColorForCallLink';
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 parsedForm = useMemo(() => {
const name = nameInput.trim();
if (name === callLink.name) {
return null;
}
return { name };
}, [nameInput, callLink]);
const handleNameInputChange = useCallback((nextNameInput: string) => {
setNameInput(nextNameInput);
}, []);
const handleSubmit = useCallback(() => {
if (parsedForm == null) {
return;
}
onUpdateCallLinkName(parsedForm.name);
onClose();
}, [parsedForm, onUpdateCallLinkName, onClose]);
return (
<Modal
modalName="CallLinkAddNameModal"
i18n={i18n}
hasXButton
noEscapeClose
noMouseClose
title={
callLink.name === ''
? i18n('icu:CallLinkAddNameModal__Title')
: i18n('icu:CallLinkAddNameModal__Title--Edit')
}
onClose={onClose}
moduleClassName="CallLinkAddNameModal"
modalFooter={
<>
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')}
</Button>
<Button
type="submit"
form={formId}
variant={ButtonVariant.Primary}
aria-disabled={parsedForm == null}
>
{i18n('icu:save')}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="CallLinkAddNameModal__Row"
>
<Avatar
i18n={i18n}
badge={undefined}
color={getColorForCallLink(callLink.rootKey)}
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"
maxByteCount={CallLinkNameMaxByteLength}
/>
</form>
</Modal>
);
}

View file

@ -0,0 +1,149 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { CallHistoryGroup } from '../types/CallDisposition';
import type { LocalizerType } from '../types/I18N';
import { CallHistoryGroupPanelSection } from './conversation/conversation-details/CallHistoryGroupPanelSection';
import { PanelSection } from './conversation/conversation-details/PanelSection';
import {
ConversationDetailsIcon,
IconType,
} from './conversation/conversation-details/ConversationDetailsIcon';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import type { CallLinkRestrictions, CallLinkType } from '../types/CallLink';
import { linkCallRoute } from '../util/signalRoutes';
import { drop } from '../util/drop';
import { Avatar, AvatarSize } from './Avatar';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { copyCallLink } from '../util/copyLinksWithToast';
import { getColorForCallLink } from '../util/getColorForCallLink';
import { isCallLinkAdmin } from '../util/callLinks';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
function toUrlWithoutProtocol(url: URL): string {
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
}
export type CallLinkDetailsProps = Readonly<{
callHistoryGroup: CallHistoryGroup;
callLink: CallLinkType;
i18n: LocalizerType;
onOpenCallLinkAddNameModal: () => void;
onStartCallLinkLobby: () => void;
onShareCallLinkViaSignal: () => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
}>;
export function CallLinkDetails({
callHistoryGroup,
callLink,
i18n,
onOpenCallLinkAddNameModal,
onStartCallLinkLobby,
onShareCallLinkViaSignal,
onUpdateCallLinkRestrictions,
}: CallLinkDetailsProps): JSX.Element {
const webUrl = linkCallRoute.toWebUrl({
key: callLink.rootKey,
});
return (
<div className="CallLinkDetails__Container">
<header className="CallLinkDetails__Header">
<Avatar
className="CallLinkDetails__HeaderAvatar"
i18n={i18n}
badge={undefined}
color={getColorForCallLink(callLink.rootKey)}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={callLink.name ?? i18n('icu:calling__call-link-default-title')}
/>
<div className="CallLinkDetails__HeaderDetails">
<h1 className="CallLinkDetails__HeaderTitle">
{callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name}
</h1>
<p className="CallLinkDetails__HeaderDescription">
{toUrlWithoutProtocol(webUrl)}
</p>
</div>
<div className="CallLinkDetails__HeaderActions">
<Button
className="CallLinkDetails__HeaderButton"
variant={ButtonVariant.SecondaryAffirmative}
size={ButtonSize.Small}
onClick={onStartCallLinkLobby}
>
{i18n('icu:CallLinkDetails__Join')}
</Button>
</div>
</header>
<CallHistoryGroupPanelSection
callHistoryGroup={callHistoryGroup}
i18n={i18n}
/>
{isCallLinkAdmin(callLink) && (
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__AddCallNameLabel')}
icon={IconType.edit}
/>
}
label={
callLink.name === ''
? i18n('icu:CallLinkDetails__AddCallNameLabel')
: i18n('icu:CallLinkDetails__EditCallNameLabel')
}
onClick={onOpenCallLinkAddNameModal}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__ApproveAllMembersLabel')}
icon={IconType.approveAllMembers}
/>
}
label={i18n('icu:CallLinkDetails__ApproveAllMembersLabel')}
right={
<CallLinkRestrictionsSelect
i18n={i18n}
value={callLink.restrictions}
onChange={onUpdateCallLinkRestrictions}
/>
}
/>
</PanelSection>
)}
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__CopyLink')}
icon={IconType.share}
/>
}
label={i18n('icu:CallLinkDetails__CopyLink')}
onClick={() => {
drop(copyCallLink(webUrl.toString()));
}}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
icon={IconType.forward}
/>
}
label={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
onClick={onShareCallLinkViaSignal}
/>
</PanelSection>
</div>
);
}

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'),
onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'),
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,200 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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';
import type { CallLinkRestrictions } from '../types/CallLink';
import { type CallLinkType } from '../types/CallLink';
import { linkCallRoute } from '../util/signalRoutes';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { getColorForCallLink } from '../util/getColorForCallLink';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
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;
onOpenCallLinkAddNameModal: () => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
onShareCallLinkViaSignal: () => void;
onStartCallLinkLobby: () => void;
};
export function CallLinkEditModal({
i18n,
callLink,
onClose,
onCopyCallLink,
onOpenCallLinkAddNameModal,
onUpdateCallLinkRestrictions,
onShareCallLinkViaSignal,
onStartCallLinkLobby,
}: CallLinkEditModalProps): JSX.Element {
const [restrictionsId] = useState(() => generateUuid());
const callLinkWebUrl = useMemo(() => {
return linkCallRoute.toWebUrl({ key: callLink.rootKey }).toString();
}, [callLink.rootKey]);
return (
<Modal
i18n={i18n}
modalName="CallLinkEditModal"
moduleClassName="CallLinkEditModal"
title={i18n('icu:CallLinkEditModal__Title')}
noEscapeClose
noMouseClose
padded={false}
modalFooter={
<Button type="submit" variant={ButtonVariant.Primary} onClick={onClose}>
{i18n('icu:done')}
</Button>
}
onClose={onClose}
>
<div className="CallLinkEditModal__Header">
<Avatar
i18n={i18n}
badge={undefined}
color={getColorForCallLink(callLink.rootKey)}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={
callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name
}
/>
<div className="CallLinkEditModal__Header__Details">
<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>
<Hr />
<RowButton onClick={onOpenCallLinkAddNameModal}>
<Row>
<RowIcon icon="Edit" />
<RowText>
{callLink.name === ''
? i18n('icu:CallLinkEditModal__AddCallNameLabel')
: i18n('icu:CallLinkEditModal__EditCallNameLabel')}
</RowText>
</Row>
</RowButton>
<Row>
<RowIcon icon="Approve" />
<RowText>
<label htmlFor={restrictionsId}>
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
</label>
</RowText>
<CallLinkRestrictionsSelect
i18n={i18n}
id={restrictionsId}
value={callLink.restrictions}
onChange={onUpdateCallLinkRestrictions}
/>
</Row>
<Hr />
<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

@ -0,0 +1,44 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import {
CallLinkRestrictions,
toCallLinkRestrictions,
} from '../types/CallLink';
import type { LocalizerType } from '../types/I18N';
import { Select } from './Select';
export type CallLinkRestrictionsSelectProps = Readonly<{
i18n: LocalizerType;
id?: string;
value: CallLinkRestrictions;
onChange: (value: CallLinkRestrictions) => void;
}>;
export function CallLinkRestrictionsSelect({
i18n,
id,
value,
onChange,
}: CallLinkRestrictionsSelectProps): JSX.Element {
return (
<Select
id={id}
value={String(value)}
moduleClassName="CallLinkRestrictionsSelect"
options={[
{
value: String(CallLinkRestrictions.None),
text: i18n('icu:CallLinkRestrictionsSelect__Option--Off'),
},
{
value: String(CallLinkRestrictions.AdminApproval),
text: i18n('icu:CallLinkRestrictionsSelect__Option--On'),
},
]}
onChange={nextValue => {
onChange(toCallLinkRestrictions(nextValue));
}}
/>
);
}

View file

@ -14,6 +14,10 @@ import {
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import type {
ActiveGroupCallType,
GroupCallRemoteParticipantType,
} from '../types/Calling';
import type {
ConversationType,
ConversationTypeType,
@ -23,17 +27,22 @@ import { generateAci } from '../types/ServiceId';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setupI18n } from '../util/setupI18n';
import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
import enMessages from '../../_locales/en/messages.json';
import { ThemeType } from '../types/Util';
import { StorySendMode } from '../types/Stories';
import {
FAKE_CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY,
getDefaultCallLinkConversation,
} from '../test-both/helpers/fakeCallLink';
import { allRemoteParticipants } from './CallScreen.stories';
import { getPlaceholderContact } from '../state/selectors/conversations';
const i18n = setupI18n('en', enMessages);
const getConversation = () =>
getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@ -44,6 +53,25 @@ const getConversation = () =>
lastUpdated: Date.now(),
});
const getUnknownContact = (): ConversationType => ({
...getPlaceholderContact(),
serviceId: generateAci(),
});
const getUnknownParticipant = (): GroupCallRemoteParticipantType => ({
...getPlaceholderContact(),
serviceId: generateAci(),
aci: generateAci(),
demuxId: Math.round(10000 * Math.random()),
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
mediaKeysReceived: false,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1,
});
const getCommonActiveCallData = () => ({
conversation: getConversation(),
joinedAt: Date.now(),
@ -61,23 +89,25 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
...storyProps,
availableCameras: [],
acceptCall: action('accept-call'),
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
bounceAppIconStart: action('bounce-app-icon-start'),
bounceAppIconStop: action('bounce-app-icon-stop'),
cancelCall: action('cancel-call'),
changeCallView: action('change-call-view'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
denyUser: action('deny-user'),
getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
fakeGetGroupCallVideoFrameSource(demuxId),
getPreferredBadge: () => undefined,
getIsSharingPhoneNumberWithEverybody: () => false,
getPresentingSources: action('get-presenting-sources'),
hangUpActiveCall: action('hang-up-active-call'),
hasInitialLoadCompleted: true,
i18n,
incomingCall: null,
callLink: undefined,
callLink: storyProps.callLink ?? undefined,
isGroupCallRaiseHandEnabled: true,
isGroupCallReactionsEnabled: true,
keyChangeOk: action('key-change-ok'),
me: {
...getDefaultConversation({
color: AvatarColors[0],
@ -88,10 +118,11 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
notifyForCall: action('notify-for-call'),
openSystemPreferencesAction: action('open-system-preferences-action'),
playRingtone: action('play-ringtone'),
removeClient: action('remove-client'),
blockClient: action('block-client'),
renderDeviceSelection: () => <div />,
renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberProps) => <div />,
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
sendGroupCallReaction: action('send-group-call-reaction'),
setGroupCallVideoRequest: action('set-group-call-video-request'),
@ -102,12 +133,12 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'),
setOutgoingRing: action('set-outgoing-ring'),
showToast: action('show-toast'),
showContactModal: action('show-contact-modal'),
showShareCallLinkViaSignal: action('show-share-call-link-via-signal'),
startCall: action('start-call'),
stopRingtone: action('stop-ringtone'),
switchToPresentationView: action('switch-to-presentation-view'),
switchFromPresentationView: action('switch-from-presentation-view'),
theme: ThemeType.light,
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleScreenRecordingPermissionsDialog: action(
@ -118,6 +149,39 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
pauseVoiceNotePlayer: action('pause-audio-player'),
});
const getActiveCallForCallLink = (
overrideProps: Partial<ActiveGroupCallType> = {}
): ActiveGroupCallType => {
return {
conversation: getDefaultCallLinkConversation(),
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
outgoingRing: false,
pip: false,
settingsDialogOpen: false,
showParticipantsList: overrideProps.showParticipantsList ?? true,
callMode: CallMode.Adhoc,
connectionState:
overrideProps.connectionState ?? GroupCallConnectionState.NotConnected,
conversationsByDemuxId: new Map<number, ConversationType>(),
deviceCount: 0,
joinState: overrideProps.joinState ?? GroupCallJoinState.NotJoined,
localDemuxId: 1,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants:
overrideProps.peekedParticipants ?? allRemoteParticipants.slice(0, 3),
remoteParticipants: overrideProps.remoteParticipants ?? [],
pendingParticipants: overrideProps.pendingParticipants ?? [],
raisedHands: new Set<number>(),
remoteAudioLevels: new Map<number, number>(),
};
};
export default {
title: 'Components/CallManager',
argTypes: {},
@ -154,7 +218,6 @@ export function OngoingGroupCall(): JSX.Element {
...getCommonActiveCallData(),
callMode: CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: new Map<number, ConversationType>(),
deviceCount: 0,
joinState: GroupCallJoinState.Joined,
@ -163,6 +226,7 @@ export function OngoingGroupCall(): JSX.Element {
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
@ -232,33 +296,238 @@ export function CallRequestNeeded(): JSX.Element {
);
}
export function GroupCallSafetyNumberChanged(): JSX.Element {
export function CallLinkLobbyParticipantsKnown(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: {
...getCommonActiveCallData(),
callMode: CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [
{
...getDefaultConversation({
title: 'Aaron',
}),
},
],
conversationsByDemuxId: new Map<number, ConversationType>(),
deviceCount: 0,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
},
activeCall: getActiveCallForCallLink(),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkLobbyParticipants1Unknown(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
peekedParticipants: [getPlaceholderContact()],
}),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkLobbyParticipants1Known1Unknown(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
peekedParticipants: [allRemoteParticipants[0], getUnknownContact()],
}),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkLobbyParticipants1Known2Unknown(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
peekedParticipants: [
getUnknownContact(),
allRemoteParticipants[0],
getUnknownContact(),
],
}),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkLobbyParticipants1Known12Unknown(): JSX.Element {
const peekedParticipants: Array<ConversationType> = [
allRemoteParticipants[0],
];
for (let n = 12; n > 0; n -= 1) {
peekedParticipants.push(getUnknownContact());
}
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
peekedParticipants,
}),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkLobbyParticipants3Unknown(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
peekedParticipants: [
getUnknownContact(),
getUnknownContact(),
getUnknownContact(),
],
}),
callLink: FAKE_CALL_LINK,
})}
/>
);
}
export function CallLinkWithJoinRequestsOne(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [allRemoteParticipants[1]],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsTwo(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 3),
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsMany(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 11),
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestUnknownContact(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [
getUnknownContact(),
allRemoteParticipants[1],
allRemoteParticipants[2],
],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsSystemContact(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsSystemContactMany(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: [
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
allRemoteParticipants[2],
allRemoteParticipants[3],
],
showParticipantsList: false,
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithJoinRequestsParticipantsOpen(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekedParticipants: [allRemoteParticipants[0]],
pendingParticipants: allRemoteParticipants.slice(1, 4),
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);
}
export function CallLinkWithUnknownContacts(): JSX.Element {
return (
<CallManager
{...createProps({
activeCall: getActiveCallForCallLink({
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
remoteParticipants: [
allRemoteParticipants[0],
getUnknownParticipant(),
getUnknownParticipant(),
],
}),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
})}
/>
);

View file

@ -11,8 +11,6 @@ import { CallingParticipantsList } from './CallingParticipantsList';
import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal';
import { CallingPip } from './CallingPip';
import { IncomingCallBar } from './IncomingCallBar';
import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
import type {
ActiveCallType,
CallingConversationType,
@ -28,13 +26,14 @@ import {
GroupCallJoinState,
} from '../types/Calling';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type {
AcceptCallType,
BatchUserActionPayloadType,
CancelCallType,
DeclineCallType,
GroupCallParticipantInfoType,
KeyChangeOkType,
PendingUserActionPayloadType,
RemoveClientType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
SetGroupCallVideoRequestType,
@ -46,7 +45,7 @@ import type {
} from '../state/ducks/calling';
import { CallLinkRestrictions } from '../types/CallLink';
import type { CallLinkType } from '../types/CallLink';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { CallingToastProvider } from './CallingToast';
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
@ -55,8 +54,8 @@ import * as log from '../logging/log';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { CallingAdhocCallInfo } from './CallingAdhocCallInfo';
import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import { usePrevious } from '../hooks/usePrevious';
import { copyCallLink } from '../util/copyLinksWithToast';
const GROUP_CALL_RING_DURATION = 60 * 1000;
@ -78,6 +77,8 @@ export type GroupIncomingCall = Readonly<{
remoteParticipants: Array<GroupCallParticipantInfoType>;
}>;
export type CallingImageDataCache = Map<number, ImageData>;
export type PropsType = {
activeCall?: ActiveCallType;
availableCameras: Array<MediaDeviceInfo>;
@ -89,24 +90,26 @@ export type PropsType = {
conversationId: string,
demuxId: number
) => VideoFrameSource;
getPreferredBadge: PreferredBadgeSelectorType;
getIsSharingPhoneNumberWithEverybody: () => boolean;
getPresentingSources: () => void;
incomingCall: DirectIncomingCall | GroupIncomingCall | null;
keyChangeOk: (_: KeyChangeOkType) => void;
renderDeviceSelection: () => JSX.Element;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element;
showContactModal: (contactId: string, conversationId?: string) => void;
startCall: (payload: StartCallType) => void;
toggleParticipants: () => void;
acceptCall: (_: AcceptCallType) => void;
approveUser: (payload: PendingUserActionPayloadType) => void;
batchUserAction: (payload: BatchUserActionPayloadType) => void;
bounceAppIconStart: () => unknown;
bounceAppIconStop: () => unknown;
declineCall: (_: DeclineCallType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void;
hasInitialLoadCompleted: boolean;
i18n: LocalizerType;
isGroupCallRaiseHandEnabled: boolean;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
notifyForCall: (
conversationId: string,
@ -115,6 +118,8 @@ export type PropsType = {
) => unknown;
openSystemPreferencesAction: () => unknown;
playRingtone: () => unknown;
removeClient: (payload: RemoveClientType) => void;
blockClient: (payload: RemoveClientType) => void;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
@ -125,12 +130,14 @@ export type PropsType = {
setOutgoingRing: (_: boolean) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
showToast: ShowToastAction;
showShareCallLinkViaSignal: (
callLink: CallLinkType,
i18n: LocalizerType
) => void;
stopRingtone: () => unknown;
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
hangUpActiveCall: (reason: string) => void;
theme: ThemeType;
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
@ -138,31 +145,46 @@ export type PropsType = {
pauseVoiceNotePlayer: () => void;
} & Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type ActiveCallManagerPropsType = PropsType & {
type ActiveCallManagerPropsType = {
activeCall: ActiveCallType;
};
} & Omit<
PropsType,
| 'acceptCall'
| 'bounceAppIconStart'
| 'bounceAppIconStop'
| 'declineCall'
| 'hasInitialLoadCompleted'
| 'incomingCall'
| 'notifyForCall'
| 'playRingtone'
| 'setIsCallActive'
| 'stopRingtone'
| 'isConversationTooBigToRing'
>;
function ActiveCallManager({
activeCall,
approveUser,
availableCameras,
batchUserAction,
blockClient,
callLink,
cancelCall,
changeCallView,
closeNeedPermissionScreen,
denyUser,
hangUpActiveCall,
i18n,
isGroupCallRaiseHandEnabled,
isGroupCallReactionsEnabled,
keyChangeOk,
getIsSharingPhoneNumberWithEverybody,
getGroupCallVideoFrameSource,
getPreferredBadge,
getPresentingSources,
me,
openSystemPreferencesAction,
renderDeviceSelection,
renderEmojiPicker,
renderReactionPicker,
renderSafetyNumberViewer,
removeClient,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setGroupCallVideoRequest,
@ -172,11 +194,11 @@ function ActiveCallManager({
setPresenting,
setRendererCanvas,
setOutgoingRing,
showToast,
showContactModal,
showShareCallLinkViaSignal,
startCall,
switchToPresentationView,
switchFromPresentationView,
theme,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
@ -218,6 +240,16 @@ function ActiveCallManager({
pauseVoiceNotePlayer,
]);
// For caching screenshare frames which update slowly, between Pip and CallScreen.
const imageDataCache = React.useRef<CallingImageDataCache>(new Map());
const previousConversationId = usePrevious(conversation.id, conversation.id);
useEffect(() => {
if (conversation.id !== previousConversationId) {
imageDataCache.current.clear();
}
}, [conversation.id, previousConversationId]);
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
(demuxId: number) => {
return getGroupCallVideoFrameSource(conversation.id, demuxId);
@ -243,14 +275,18 @@ function ActiveCallManager({
const link = callLinkRootKeyToUrl(callLink.rootKey);
if (link) {
await window.navigator.clipboard.writeText(link);
showToast({ toastType: ToastType.CopiedCallLink });
await copyCallLink(link);
}
}, [callLink, showToast]);
}, [callLink]);
const onSafetyNumberDialogCancel = useCallback(() => {
hangUpActiveCall('safety number dialog cancel');
}, [hangUpActiveCall]);
const handleShareCallLinkViaSignal = useCallback(() => {
if (!callLink) {
log.error('Missing call link');
return;
}
showShareCallLinkViaSignal(callLink, i18n);
}, [callLink, i18n, showShareCallLinkViaSignal]);
let isCallFull: boolean;
let showCallLobby: boolean;
@ -258,7 +294,9 @@ function ActiveCallManager({
| undefined
| Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
let isConvoTooBigToRing = false;
let isAdhocAdminApprovalRequired = false;
let isAdhocJoinRequestPending = false;
let isCallLinkAdmin = false;
switch (activeCall.callMode) {
case CallMode.Direct: {
@ -287,15 +325,38 @@ function ActiveCallManager({
isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
isConvoTooBigToRing = activeCall.isConversationTooBigToRing;
({ groupMembers } = activeCall);
isAdhocAdminApprovalRequired =
!callLink?.adminKey &&
callLink?.restrictions === CallLinkRestrictions.AdminApproval;
isAdhocJoinRequestPending =
callLink?.restrictions === CallLinkRestrictions.AdminApproval &&
isAdhocAdminApprovalRequired &&
activeCall.joinState === GroupCallJoinState.Pending;
isCallLinkAdmin = Boolean(callLink?.adminKey);
break;
}
default:
throw missingCaseError(activeCall);
}
if (pip) {
return (
<CallingPip
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
imageDataCache={imageDataCache}
hangUpActiveCall={hangUpActiveCall}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
switchToPresentationView={switchToPresentationView}
switchFromPresentationView={switchFromPresentationView}
togglePip={togglePip}
/>
);
}
if (showCallLobby) {
return (
<>
@ -307,9 +368,13 @@ function ActiveCallManager({
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isAdhocAdminApprovalRequired={isAdhocAdminApprovalRequired}
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
isCallFull={isCallFull}
isConversationTooBigToRing={isConvoTooBigToRing}
getIsSharingPhoneNumberWithEverybody={
getIsSharingPhoneNumberWithEverybody
}
me={me}
onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall}
@ -321,6 +386,7 @@ function ActiveCallManager({
setOutgoingRing={setOutgoingRing}
showParticipantsList={showParticipantsList}
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
@ -329,41 +395,31 @@ function ActiveCallManager({
<CallingAdhocCallInfo
callLink={callLink}
i18n={i18n}
isCallLinkAdmin={isCallLinkAdmin}
isUnknownContactDiscrete={false}
ourServiceId={me.serviceId}
participants={peekedParticipants}
onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink}
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
removeClient={removeClient}
blockClient={blockClient}
showContactModal={showContactModal}
/>
) : (
<CallingParticipantsList
conversationId={conversation.id}
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={peekedParticipants}
showContactModal={showContactModal}
/>
))}
</>
);
}
if (pip) {
return (
<CallingPip
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUpActiveCall={hangUpActiveCall}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
switchToPresentationView={switchToPresentationView}
switchFromPresentationView={switchFromPresentationView}
togglePip={togglePip}
/>
);
}
let isHandRaised = false;
if (isGroupOrAdhocActiveCall(activeCall)) {
const { raisedHands, localDemuxId } = activeCall;
@ -383,6 +439,7 @@ function ActiveCallManager({
hasRemoteVideo: hasLocalVideo,
isHandRaised,
presenting: Boolean(activeCall.presentingSource),
demuxId: activeCall.localDemuxId,
},
]
: [];
@ -391,14 +448,18 @@ function ActiveCallManager({
<>
<CallScreen
activeCall={activeCall}
approveUser={approveUser}
batchUserAction={batchUserAction}
changeCallView={changeCallView}
denyUser={denyUser}
getPresentingSources={getPresentingSources}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
groupMembers={groupMembers}
hangUpActiveCall={hangUpActiveCall}
i18n={i18n}
imageDataCache={imageDataCache}
isCallLinkAdmin={isCallLinkAdmin}
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
isGroupCallReactionsEnabled={isGroupCallReactionsEnabled}
me={me}
openSystemPreferencesAction={openSystemPreferencesAction}
renderEmojiPicker={renderEmojiPicker}
@ -434,65 +495,96 @@ function ActiveCallManager({
<CallingAdhocCallInfo
callLink={callLink}
i18n={i18n}
isCallLinkAdmin={isCallLinkAdmin}
isUnknownContactDiscrete
ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList}
onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink}
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
removeClient={removeClient}
blockClient={blockClient}
showContactModal={showContactModal}
/>
) : (
<CallingParticipantsList
conversationId={conversation.id}
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList}
showContactModal={showContactModal}
/>
))}
{isGroupOrAdhocActiveCall(activeCall) &&
activeCall.conversationsWithSafetyNumberChanges.length ? (
<SafetyNumberChangeDialog
confirmText={i18n('icu:continueCall')}
contacts={[
{
story: undefined,
contacts: activeCall.conversationsWithSafetyNumberChanges,
},
]}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onCancel={onSafetyNumberDialogCancel}
onConfirm={() => {
keyChangeOk({ conversationId: activeCall.conversation.id });
}}
renderSafetyNumber={renderSafetyNumberViewer}
theme={theme}
/>
) : null}
</>
);
}
export function CallManager(props: PropsType): JSX.Element | null {
const {
acceptCall,
activeCall,
bounceAppIconStart,
bounceAppIconStop,
declineCall,
i18n,
incomingCall,
notifyForCall,
playRingtone,
stopRingtone,
setIsCallActive,
setOutgoingRing,
} = props;
export function CallManager({
acceptCall,
activeCall,
approveUser,
availableCameras,
batchUserAction,
blockClient,
bounceAppIconStart,
bounceAppIconStop,
callLink,
cancelCall,
changeCallView,
closeNeedPermissionScreen,
declineCall,
denyUser,
getGroupCallVideoFrameSource,
getPresentingSources,
hangUpActiveCall,
hasInitialLoadCompleted,
i18n,
incomingCall,
isConversationTooBigToRing,
isGroupCallRaiseHandEnabled,
getIsSharingPhoneNumberWithEverybody,
me,
notifyForCall,
openSystemPreferencesAction,
pauseVoiceNotePlayer,
playRingtone,
removeClient,
renderDeviceSelection,
renderEmojiPicker,
renderReactionPicker,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setGroupCallVideoRequest,
setIsCallActive,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setOutgoingRing,
setPresenting,
setRendererCanvas,
showContactModal,
showShareCallLinkViaSignal,
startCall,
stopRingtone,
switchFromPresentationView,
switchToPresentationView,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
}: PropsType): JSX.Element | null {
const isCallActive = Boolean(activeCall);
useEffect(() => {
setIsCallActive(isCallActive);
}, [isCallActive, setIsCallActive]);
const shouldRing = getShouldRing(props);
const shouldRing = getShouldRing({
activeCall,
incomingCall,
isConversationTooBigToRing,
hasInitialLoadCompleted,
});
useEffect(() => {
if (shouldRing) {
log.info('CallManager: Playing ringtone');
@ -528,8 +620,54 @@ export function CallManager(props: PropsType): JSX.Element | null {
// `props` should logically have an `activeCall` at this point, but TypeScript can't
// figure that out, so we pass it in again.
return (
<CallingToastProvider i18n={props.i18n}>
<ActiveCallManager {...props} activeCall={activeCall} />
<CallingToastProvider i18n={i18n}>
<ActiveCallManager
activeCall={activeCall}
availableCameras={availableCameras}
approveUser={approveUser}
batchUserAction={batchUserAction}
blockClient={blockClient}
callLink={callLink}
cancelCall={cancelCall}
changeCallView={changeCallView}
closeNeedPermissionScreen={closeNeedPermissionScreen}
denyUser={denyUser}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
getPresentingSources={getPresentingSources}
hangUpActiveCall={hangUpActiveCall}
i18n={i18n}
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
getIsSharingPhoneNumberWithEverybody={
getIsSharingPhoneNumberWithEverybody
}
me={me}
openSystemPreferencesAction={openSystemPreferencesAction}
pauseVoiceNotePlayer={pauseVoiceNotePlayer}
removeClient={removeClient}
renderDeviceSelection={renderDeviceSelection}
renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker}
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
sendGroupCallReaction={sendGroupCallReaction}
setGroupCallVideoRequest={setGroupCallVideoRequest}
setLocalAudio={setLocalAudio}
setLocalPreview={setLocalPreview}
setLocalVideo={setLocalVideo}
setOutgoingRing={setOutgoingRing}
setPresenting={setPresenting}
setRendererCanvas={setRendererCanvas}
showContactModal={showContactModal}
showShareCallLinkViaSignal={showShareCallLinkViaSignal}
startCall={startCall}
switchFromPresentationView={switchFromPresentationView}
switchToPresentationView={switchToPresentationView}
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleScreenRecordingPermissionsDialog={
toggleScreenRecordingPermissionsDialog
}
toggleSettings={toggleSettings}
/>
</CallingToastProvider>
);
}
@ -581,9 +719,20 @@ function getShouldRing({
activeCall,
incomingCall,
isConversationTooBigToRing,
hasInitialLoadCompleted,
}: Readonly<
Pick<PropsType, 'activeCall' | 'incomingCall' | 'isConversationTooBigToRing'>
Pick<
PropsType,
| 'activeCall'
| 'incomingCall'
| 'isConversationTooBigToRing'
| 'hasInitialLoadCompleted'
>
>): boolean {
if (!hasInitialLoadCompleted) {
return false;
}
if (incomingCall != null) {
// don't ring a large group
if (isConversationTooBigToRing) {

View file

@ -5,7 +5,7 @@ import React, { useRef, useEffect } from 'react';
import type { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { ContactName } from './conversation/ContactName';
import type { ConversationType } from '../state/ducks/conversations';
@ -13,7 +13,7 @@ export type Props = {
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'isMe'
| 'name'
@ -21,7 +21,7 @@ export type Props = {
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
| 'unblurredAvatarUrl'
>;
i18n: LocalizerType;
close: () => void;
@ -46,7 +46,7 @@ export function CallNeedPermissionScreen({
<div className="module-call-need-permission-screen">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPath={conversation.avatarPath}
avatarUrl={conversation.avatarUrl}
badge={undefined}
color={conversation.color || AvatarColors[0]}
noteToSelf={false}
@ -61,7 +61,7 @@ export function CallNeedPermissionScreen({
/>
<p className="module-call-need-permission-screen__text">
<Intl
<I18n
i18n={i18n}
id="icu:callNeedPermission"
components={{

View file

@ -18,7 +18,7 @@ import { CallReactionBurstEmoji } from './CallReactionBurstEmoji';
const LIFETIME = 3000;
export type CallReactionBurstType = {
value: string;
values: Array<string>;
};
type CallReactionBurstStateType = CallReactionBurstType & {
@ -124,10 +124,10 @@ export function CallReactionBurstProvider({
<CallReactionBurstContext.Provider value={contextValue}>
{createPortal(
<div className="CallReactionBursts">
{bursts.map(({ value, key }) => (
{bursts.map(({ values, key }) => (
<CallReactionBurstEmoji
key={key}
value={value}
values={values}
onAnimationEnd={() => hideBurst(key)}
/>
))}

View file

@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid';
import { Emojify } from './conversation/Emojify';
export type PropsType = {
value: string;
values: Array<string>;
onAnimationEnd?: () => unknown;
};
@ -25,31 +25,36 @@ type AnimationConfig = {
velocity: number;
};
export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
// values is an array of emojis, which is useful when bursting multi skin tone set of
// emojis to get the correct representation
export function CallReactionBurstEmoji({ values }: PropsType): JSX.Element {
const [toY, setToY] = React.useState<number>(0);
const fromY = -50;
const generateEmojiProps = React.useCallback(() => {
return {
key: uuid(),
value,
springConfig: {
mass: random(10, 20),
tension: random(45, 60),
friction: random(20, 60),
clamp: true,
precision: 0,
velocity: -0.01,
},
fromX: random(0, 20),
toX: random(-30, 300),
fromY,
toY,
toScale: random(1, 2.5, true),
fromRotate: random(-45, 45),
toRotate: random(-45, 45),
};
}, [fromY, toY, value]);
const generateEmojiProps = React.useCallback(
(index: number) => {
return {
key: uuid(),
value: values[index % values.length],
springConfig: {
mass: random(10, 20),
tension: random(45, 60),
friction: random(20, 60),
clamp: true,
precision: 0,
velocity: -0.01,
},
fromX: random(0, 20),
toX: random(-30, 300),
fromY,
toY,
toScale: random(1, 2.5, true),
fromRotate: random(-45, 45),
toRotate: random(-45, 45),
};
},
[fromY, toY, values]
);
// Calculate target Y position before first render. Emojis need to animate Y upwards
// by the value of the container's top, plus the emoji's maximum height.
@ -59,12 +64,12 @@ export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
const { top } = containerRef.current.getBoundingClientRect();
const calculatedToY = -top;
setToY(calculatedToY);
setEmojis([{ ...generateEmojiProps(), toY: calculatedToY }]);
setEmojis([{ ...generateEmojiProps(0), toY: calculatedToY }]);
}
}, [generateEmojiProps]);
const [emojis, setEmojis] = React.useState<Array<AnimatedEmojiProps>>([
generateEmojiProps(),
generateEmojiProps(0),
]);
React.useEffect(() => {
@ -74,14 +79,14 @@ export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
if (emojiCount + 1 >= NUM_EMOJIS) {
clearInterval(timer);
}
return [...curEmojis, generateEmojiProps()];
return [...curEmojis, generateEmojiProps(emojiCount)];
});
}, DELAY_BETWEEN_EMOJIS);
return () => {
clearInterval(timer);
};
}, [fromY, toY, value, generateEmojiProps]);
}, [fromY, toY, values, generateEmojiProps]);
return (
<div className="CallReactionBurstEmoji" ref={containerRef}>

View file

@ -33,6 +33,7 @@ import {
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import enMessages from '../../_locales/en/messages.json';
import { CallingToastProvider, useCallingToasts } from './CallingToast';
import type { CallingImageDataCache } from './CallManager';
const MAX_PARTICIPANTS = 75;
const LOCAL_DEMUX_ID = 1;
@ -41,7 +42,7 @@ const i18n = setupI18n('en', enMessages);
const conversation = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@ -67,6 +68,7 @@ type GroupCallOverrideProps = OverridePropsBase & {
callMode: CallMode.Group;
connectionState?: GroupCallConnectionState;
peekedParticipants?: Array<ConversationType>;
pendingParticipants?: Array<ConversationType>;
raisedHands?: Set<number>;
remoteParticipants?: Array<GroupCallRemoteParticipantType>;
remoteAudioLevel?: number;
@ -90,7 +92,7 @@ const createActiveDirectCallProp = (
hasRemoteVideo: boolean;
presenting: boolean;
title: string;
}
},
],
});
@ -124,7 +126,6 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
callMode: CallMode.Group as CallMode.Group,
connectionState:
overrideProps.connectionState || GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: getConversationsByDemuxId(overrideProps),
joinState: GroupCallJoinState.Joined,
localDemuxId: LOCAL_DEMUX_ID,
@ -136,6 +137,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
isConversationTooBigToRing: false,
peekedParticipants:
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
pendingParticipants: overrideProps.pendingParticipants || [],
raisedHands:
overrideProps.raisedHands ||
getRaisedHands(overrideProps) ||
@ -182,13 +184,17 @@ const createProps = (
}
): PropsType => ({
activeCall: createActiveCallProp(overrideProps),
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
changeCallView: action('change-call-view'),
denyUser: action('deny-user'),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getPresentingSources: action('get-presenting-sources'),
hangUpActiveCall: action('hang-up'),
i18n,
imageDataCache: React.createRef<CallingImageDataCache>(),
isCallLinkAdmin: true,
isGroupCallRaiseHandEnabled: true,
isGroupCallReactionsEnabled: true,
me: getDefaultConversation({
color: AvatarColors[1],
id: '6146087e-f7ef-457e-9a8d-47df1fdd6b25',
@ -231,6 +237,7 @@ export default {
title: 'Components/CallScreen',
argTypes: {},
args: {},
excludeStories: ['allRemoteParticipants'],
} satisfies Meta<PropsType>;
export function Default(): JSX.Element {
@ -374,7 +381,7 @@ export function GroupCallYourHandRaised(): JSX.Element {
const PARTICIPANT_EMOJIS = ['❤️', '🤔', '✨', '😂', '🦄'] as const;
// We generate these upfront so that the list is stable when you move the slider.
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => {
export const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => {
const mediaKeysReceived = (index + 1) % 20 !== 0;
return {
@ -654,9 +661,9 @@ export function GroupCallReactions(): JSX.Element {
})
);
const activeCall = useReactionsEmitter(
props.activeCall as ActiveGroupCallType
);
const activeCall = useReactionsEmitter({
activeCall: props.activeCall as ActiveGroupCallType,
});
return <CallScreen {...props} activeCall={activeCall} />;
}
@ -671,11 +678,30 @@ export function GroupCallReactionsSpam(): JSX.Element {
})
);
const activeCall = useReactionsEmitter(
props.activeCall as ActiveGroupCallType,
250
const activeCall = useReactionsEmitter({
activeCall: props.activeCall as ActiveGroupCallType,
frequency: 250,
});
return <CallScreen {...props} activeCall={activeCall} />;
}
export function GroupCallReactionsSkinTones(): JSX.Element {
const remoteParticipants = allRemoteParticipants.slice(0, 3);
const [props] = React.useState(
createProps({
callMode: CallMode.Group,
remoteParticipants,
viewMode: CallViewMode.Overflow,
})
);
const activeCall = useReactionsEmitter({
activeCall: props.activeCall as ActiveGroupCallType,
frequency: 500,
emojis: ['👍', '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿', '❤️', '😂', '😮', '😢'],
});
return <CallScreen {...props} activeCall={activeCall} />;
}
@ -702,11 +728,17 @@ export function GroupCallReactionsManyInOrder(): JSX.Element {
return <CallScreen {...props} />;
}
function useReactionsEmitter(
activeCall: ActiveGroupCallType,
function useReactionsEmitter({
activeCall,
frequency = 2000,
removeAfter = 5000
) {
removeAfter = 5000,
emojis = DEFAULT_PREFERRED_REACTION_EMOJI,
}: {
activeCall: ActiveGroupCallType;
frequency?: number;
removeAfter?: number;
emojis?: Array<string>;
}) {
const [call, setCall] = React.useState(activeCall);
React.useEffect(() => {
const interval = setInterval(() => {
@ -726,7 +758,7 @@ function useReactionsEmitter(
{
timestamp: timeNow,
demuxId,
value: sample(DEFAULT_PREFERRED_REACTION_EMOJI) as string,
value: sample(emojis) as string,
},
];
@ -737,7 +769,7 @@ function useReactionsEmitter(
});
}, frequency);
return () => clearInterval(interval);
}, [frequency, removeAfter, call]);
}, [emojis, frequency, removeAfter, call]);
return call;
}

View file

@ -3,11 +3,13 @@
import type { ReactNode } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { isEqual, noop, sortBy } from 'lodash';
import { isEqual, noop } from 'lodash';
import classNames from 'classnames';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import type {
ActiveCallStateType,
BatchUserActionPayloadType,
PendingUserActionPayloadType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
SetLocalAudioType,
@ -87,16 +89,23 @@ import {
} from './CallReactionBurst';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert';
import { emojiToData } from './emoji/lib';
import { CallingPendingParticipants } from './CallingPendingParticipants';
import type { CallingImageDataCache } from './CallManager';
export type PropsType = {
activeCall: ActiveCallType;
approveUser: (payload: PendingUserActionPayloadType) => void;
batchUserAction: (payload: BatchUserActionPayloadType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void;
groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
isCallLinkAdmin: boolean;
isGroupCallRaiseHandEnabled: boolean;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
openSystemPreferencesAction: () => unknown;
renderReactionPicker: (
@ -178,14 +187,18 @@ function CallDuration({
export function CallScreen({
activeCall,
approveUser,
batchUserAction,
changeCallView,
denyUser,
getGroupCallVideoFrameSource,
getPresentingSources,
groupMembers,
hangUpActiveCall,
i18n,
imageDataCache,
isCallLinkAdmin,
isGroupCallRaiseHandEnabled,
isGroupCallReactionsEnabled,
me,
openSystemPreferencesAction,
renderEmojiPicker,
@ -397,6 +410,11 @@ export function CallScreen({
throw missingCaseError(activeCall);
}
const pendingParticipants =
activeCall.callMode === CallMode.Adhoc && isCallLinkAdmin
? activeCall.pendingParticipants
: [];
let lonelyInCallNode: ReactNode;
let localPreviewNode: ReactNode;
@ -414,7 +432,7 @@ export function CallScreen({
{isSendingVideo ? (
<video ref={localVideoRef} autoPlay />
) : (
<CallBackgroundBlur avatarPath={me.avatarPath}>
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<div className="module-calling__spacer module-calling__camera-is-off-spacer" />
<div className="module-calling__camera-is-off">
{i18n('icu:calling__your-video-is-off')}
@ -435,10 +453,10 @@ export function CallScreen({
autoPlay
/>
) : (
<CallBackgroundBlur avatarPath={me.avatarPath}>
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color || AvatarColors[0]}
noteToSelf={false}
@ -473,6 +491,7 @@ export function CallScreen({
const controlsFadedOut = !showControls && !isAudioOnly && isConnected;
const controlsFadeClass = classNames({
'module-ongoing-call__controls': true,
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && !isConnected,
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
@ -540,24 +559,34 @@ export function CallScreen({
}
const renderRaisedHandsToast = React.useCallback(
(hands: Array<number>) => {
// Sort "You" to the front.
const names = sortBy(hands, demuxId =>
demuxId === localDemuxId ? 0 : 1
).map(demuxId =>
demuxId === localDemuxId
? i18n('icu:you')
: conversationsByDemuxId.get(demuxId)?.title
);
(demuxIds: Array<number>) => {
const names: Array<string> = [];
let isYourHandRaised = false;
for (const demuxId of demuxIds) {
if (demuxId === localDemuxId) {
isYourHandRaised = true;
continue;
}
const handConversation = conversationsByDemuxId.get(demuxId);
if (!handConversation) {
continue;
}
names.push(handConversation.title);
}
const count = names.length;
const name = names[0] ?? '';
const otherName = names[1] ?? '';
let message: string;
let buttonOverride: JSX.Element | undefined;
const count = names.length;
switch (count) {
case 0:
return undefined;
case 1:
if (names[0] === i18n('icu:you')) {
if (isYourHandRaised) {
message = i18n('icu:CallControls__RaiseHandsToast--you');
buttonOverride = (
<button
@ -570,22 +599,37 @@ export function CallScreen({
);
} else {
message = i18n('icu:CallControls__RaiseHandsToast--one', {
name: names[0] ?? '',
name,
});
}
break;
case 2:
message = i18n('icu:CallControls__RaiseHandsToast--two', {
name: names[0] ?? '',
otherName: names[1] ?? '',
});
if (isYourHandRaised) {
message = i18n('icu:CallControls__RaiseHandsToast--you-and-one', {
otherName,
});
} else {
message = i18n('icu:CallControls__RaiseHandsToast--two', {
name,
otherName,
});
}
break;
default:
message = i18n('icu:CallControls__RaiseHandsToast--more', {
name: names[0] ?? '',
otherName: names[1] ?? '',
overflowCount: names.length - 2,
});
default: {
const overflowCount = count - 2;
if (isYourHandRaised) {
message = i18n('icu:CallControls__RaiseHandsToast--you-and-more', {
otherName,
overflowCount,
});
} else {
message = i18n('icu:CallControls__RaiseHandsToast--more', {
name: names[0] ?? '',
otherName,
overflowCount,
});
}
}
}
return (
<div className="CallingRaisedHandsToast__Content">
@ -676,6 +720,7 @@ export function CallScreen({
<GroupCallRemoteParticipants
callViewMode={activeCall.viewMode}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
@ -812,6 +857,15 @@ export function CallScreen({
renderRaisedHandsToast={renderRaisedHandsToast}
i18n={i18n}
/>
{pendingParticipants.length ? (
<CallingPendingParticipants
i18n={i18n}
participants={pendingParticipants}
approveUser={approveUser}
batchUserAction={batchUserAction}
denyUser={denyUser}
/>
) : null}
{/* We render the local preview first and set the footer flex direction to row-reverse
to ensure the preview is visible at low viewport widths. */}
<div className="module-ongoing-call__footer">
@ -850,20 +904,19 @@ export function CallScreen({
className="CallControls__ReactionPickerContainer"
ref={reactionPickerContainerRef}
>
{isGroupCallReactionsEnabled &&
renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowReactionPicker(false),
onPick: emoji => {
setShowReactionPicker(false);
sendGroupCallReaction({
callMode: activeCall.callMode,
conversationId: conversation.id,
value: emoji,
});
},
renderEmojiPicker,
})}
{renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowReactionPicker(false),
onPick: emoji => {
setShowReactionPicker(false);
sendGroupCallReaction({
callMode: activeCall.callMode,
conversationId: conversation.id,
value: emoji,
});
},
renderEmojiPicker,
})}
</div>
)}
@ -884,14 +937,6 @@ export function CallScreen({
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={presentingButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={togglePresenting}
tooltipDirection={TooltipPlacement.Top}
/>
{isGroupCallRaiseHandEnabled && raiseHandButtonType && (
<CallingButton
buttonType={raiseHandButtonType}
@ -902,7 +947,15 @@ export function CallScreen({
tooltipDirection={TooltipPlacement.Top}
/>
)}
{isGroupCallReactionsEnabled && reactButtonType && (
<CallingButton
buttonType={presentingButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={togglePresenting}
tooltipDirection={TooltipPlacement.Top}
/>
{reactButtonType && (
<div
className={classNames('CallControls__ReactButtonContainer', {
'CallControls__ReactButtonContainer--menu-shown':
@ -1048,7 +1101,13 @@ function useReactionsToast(props: UseReactionsToastType): void {
const reactionsShown = useRef<
Map<
string,
{ value: string; isBursted: boolean; expireAt: number; demuxId: number }
{
value: string;
originalValue: string;
isBursted: boolean;
expireAt: number;
demuxId: number;
}
>
>(new Map());
const burstsShown = useRef<Map<string, number>>(new Map());
@ -1094,8 +1153,13 @@ function useReactionsToast(props: UseReactionsToastType): void {
recentBurstTime &&
recentBurstTime + REACTIONS_BURST_TRAILING_WINDOW > time
);
// Normalize skin tone emoji to calculate burst threshold, but save original
// value to show in the burst animation
const emojiData = emojiToData(value);
const normalizedValue = emojiData?.unified ?? value;
reactionsShown.current.set(key, {
value,
value: normalizedValue,
originalValue: value,
isBursted,
expireAt: timestamp + REACTIONS_BURST_WINDOW,
demuxId,
@ -1158,6 +1222,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
}
burstsShown.current.set(value, time);
const values: Array<string> = [];
reactionKeys.forEach(key => {
const reactionShown = reactionsShown.current.get(key);
if (!reactionShown) {
@ -1165,8 +1230,9 @@ function useReactionsToast(props: UseReactionsToastType): void {
}
reactionShown.isBursted = true;
values.push(reactionShown.originalValue);
});
showBurst({ value });
showBurst({ values });
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
break;

View file

@ -33,7 +33,7 @@ function createParticipant(
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,
...getDefaultConversationWithServiceId({
avatarPath: participantProps.avatarPath,
avatarUrl: participantProps.avatarUrl,
color: sample(AvatarColors),
isBlocked: Boolean(participantProps.isBlocked),
name: participantProps.name,
@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
return {
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
adminKey: null,
name: 'Axolotl Discuss',
restrictions: CallLinkRestrictions.None,
revoked: false,
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
...overrideProps,
};
@ -59,10 +61,16 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
callLink: getCallLink(overrideProps.callLink || {}),
i18n,
isCallLinkAdmin: overrideProps.isCallLinkAdmin || false,
isUnknownContactDiscrete: overrideProps.isUnknownContactDiscrete || false,
ourServiceId: generateAci(),
participants: overrideProps.participants || [],
onClose: action('on-close'),
onCopyCallLink: action('on-copy-call-link'),
onShareCallLinkViaSignal: action('on-share-call-link-via-signal'),
removeClient: overrideProps.removeClient || action('remove-client'),
blockClient: overrideProps.blockClient || action('block-client'),
showContactModal: action('show-contact-modal'),
});
export default {
@ -134,3 +142,35 @@ export function Overflow(): JSX.Element {
});
return <CallingAdhocCallInfo {...props} />;
}
export function AsAdmin(): JSX.Element {
const props = createProps({
participants: [
createParticipant({
title: 'Son Goku',
}),
createParticipant({
hasRemoteAudio: true,
hasRemoteVideo: true,
presenting: true,
name: 'Rage Trunks',
title: 'Rage Trunks',
}),
createParticipant({
hasRemoteAudio: true,
title: 'Prince Vegeta',
}),
createParticipant({
hasRemoteAudio: true,
hasRemoteVideo: true,
name: 'Goku',
title: 'Goku',
}),
createParticipant({
title: 'Someone With A Really Long Name',
}),
],
isCallLinkAdmin: true,
});
return <CallingAdhocCallInfo {...props} />;
}

View file

@ -1,11 +1,10 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable react/no-array-index-key */
import React from 'react';
import classNames from 'classnames';
import { partition } from 'lodash';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { InContactsIcon } from './InContactsIcon';
@ -16,145 +15,407 @@ import { sortByTitle } from '../util/sortByTitle';
import type { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost';
import { isInSystemContacts } from '../util/isInSystemContacts';
import type { RemoveClientType } from '../state/ducks/calling';
import { AVATAR_COLOR_COUNT, AvatarColors } from '../types/Colors';
import { Button } from './Button';
import { Modal } from './Modal';
import { Theme } from '../util/theme';
import { ConfirmationDialog } from './ConfirmationDialog';
const MAX_UNKNOWN_AVATARS_COUNT = 3;
type ParticipantType = ConversationType & {
hasRemoteAudio?: boolean;
hasRemoteVideo?: boolean;
isHandRaised?: boolean;
presenting?: boolean;
demuxId?: number;
};
export type PropsType = {
readonly callLink: CallLinkType;
readonly i18n: LocalizerType;
readonly isCallLinkAdmin: boolean;
readonly isUnknownContactDiscrete: boolean;
readonly ourServiceId: ServiceIdString | undefined;
readonly participants: Array<ParticipantType>;
readonly onClose: () => void;
readonly onCopyCallLink: () => void;
readonly onShareCallLinkViaSignal: () => void;
readonly removeClient: (payload: RemoveClientType) => void;
readonly blockClient: (payload: RemoveClientType) => void;
readonly showContactModal: (
contactId: string,
conversationId?: string
) => void;
};
type UnknownContactsPropsType = {
readonly i18n: LocalizerType;
readonly isInAdditionToKnownContacts: boolean;
readonly participants: Array<ParticipantType>;
readonly showUnknownContactDialog: () => void;
};
function UnknownContacts({
i18n,
isInAdditionToKnownContacts,
participants,
showUnknownContactDialog,
}: UnknownContactsPropsType): JSX.Element {
const renderUnknownAvatar = React.useCallback(
({
participant,
key,
size,
}: {
participant: ParticipantType;
key: React.Key;
size: AvatarSize;
}) => {
const colorIndex = participant.serviceId
? (parseInt(participant.serviceId.slice(-4), 16) || 0) %
AVATAR_COLOR_COUNT
: 0;
return (
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarUrl={participant.avatarUrl}
badge={undefined}
className="CallingAdhocCallInfo__UnknownContactAvatar"
color={AvatarColors[colorIndex]}
conversationType="direct"
key={key}
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={size}
/>
);
},
[i18n]
);
const visibleParticipants = participants.slice(0, MAX_UNKNOWN_AVATARS_COUNT);
let avatarSize: AvatarSize;
if (visibleParticipants.length === 1) {
avatarSize = AvatarSize.THIRTY_SIX;
} else if (visibleParticipants.length === 2) {
avatarSize = AvatarSize.THIRTY;
} else {
avatarSize = AvatarSize.TWENTY_EIGHT;
}
return (
<li
className="module-calling-participants-list__contact"
key="unknown-contacts"
>
<div className="module-calling-participants-list__avatar-and-name">
<div
className={classNames(
'CallingAdhocCallInfo__UnknownContactAvatarSet',
'module-calling-participants-list__avatar-and-name'
)}
>
{visibleParticipants.map((participant, key) =>
renderUnknownAvatar({ participant, key, size: avatarSize })
)}
<div className="module-contact-name module-calling-participants-list__name">
{i18n(
isInAdditionToKnownContacts
? 'icu:CallingAdhocCallInfo__UnknownContactLabel--in-addition'
: 'icu:CallingAdhocCallInfo__UnknownContactLabel',
{ count: participants.length }
)}
</div>
</div>
</div>
<button
aria-label="icu:CallingAdhocCallInfo__UnknownContactInfoButton"
className="CallingAdhocCallInfo__UnknownContactInfoButton module-calling-participants-list__status-icon module-calling-participants-list__unknown-contact"
onClick={showUnknownContactDialog}
type="button"
/>
</li>
);
}
export function CallingAdhocCallInfo({
i18n,
isCallLinkAdmin,
isUnknownContactDiscrete,
ourServiceId,
participants,
blockClient,
onClose,
onCopyCallLink,
onShareCallLinkViaSignal,
removeClient,
showContactModal,
}: PropsType): JSX.Element | null {
const [isUnknownContactDialogVisible, setIsUnknownContactDialogVisible] =
React.useState(false);
const [removeClientDialogState, setRemoveClientDialogState] = React.useState<{
demuxId: number;
name: string;
} | null>(null);
const hideUnknownContactDialog = React.useCallback(
() => setIsUnknownContactDialogVisible(false),
[setIsUnknownContactDialogVisible]
);
const onClickShareCallLinkViaSignal = React.useCallback(() => {
onClose();
onShareCallLinkViaSignal();
}, [onClose, onShareCallLinkViaSignal]);
const [visibleParticipants, unknownParticipants] = React.useMemo<
[Array<ParticipantType>, Array<ParticipantType>]
>(
() =>
partition(
participants,
(participant: ParticipantType) =>
isUnknownContactDiscrete || Boolean(participant.titleNoDefault)
),
[isUnknownContactDiscrete, participants]
);
const sortedParticipants = React.useMemo<Array<ParticipantType>>(
() => sortByTitle(participants),
[participants]
() => sortByTitle(visibleParticipants),
[visibleParticipants]
);
const renderParticipant = React.useCallback(
(participant: ParticipantType, key: React.Key) => (
<button
aria-label={i18n('icu:calling__ParticipantInfoButton')}
className="module-calling-participants-list__contact"
disabled={participant.isMe}
// It's tempting to use `participant.serviceId` as the `key`
// here, but that can result in duplicate keys for
// participants who have joined on multiple devices.
key={key}
onClick={() => {
if (participant.isMe) {
return;
}
onClose();
showContactModal(participant.id);
}}
type="button"
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
/>
{ourServiceId && participant.serviceId === ourServiceId ? (
<span className="module-calling-participants-list__name">
{i18n('icu:you')}
</span>
) : (
<>
<ContactName
module="module-calling-participants-list__name"
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
) : null}
</>
)}
</div>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.isHandRaised &&
'module-calling-participants-list__hand-raised'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.presenting &&
'module-calling-participants-list__presenting',
!participant.hasRemoteVideo &&
'module-calling-participants-list__muted--video'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
!participant.hasRemoteAudio &&
'module-calling-participants-list__muted--audio'
)}
/>
{isCallLinkAdmin &&
participant.demuxId &&
!(ourServiceId && participant.serviceId === ourServiceId) ? (
<button
aria-label={i18n('icu:CallingAdhocCallInfo__RemoveClient')}
className={classNames(
'CallingAdhocCallInfo__RemoveClient',
'module-calling-participants-list__status-icon',
'module-calling-participants-list__remove'
)}
onClick={event => {
if (!participant.demuxId) {
return;
}
event.stopPropagation();
event.preventDefault();
setRemoveClientDialogState({
demuxId: participant.demuxId,
name: participant.title,
});
}}
type="button"
/>
) : null}
</button>
),
[
i18n,
isCallLinkAdmin,
onClose,
ourServiceId,
setRemoveClientDialogState,
showContactModal,
]
);
return (
<ModalHost
modalName="CallingAdhocCallInfo"
moduleClassName="CallingAdhocCallInfo"
onClose={onClose}
>
<div className="CallingAdhocCallInfo module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{participants.length
? i18n('icu:calling__in-this-call', {
people: participants.length,
})
: i18n('icu:calling__in-this-call--zero')}
<>
{removeClientDialogState != null ? (
<ConfirmationDialog
dialogName="CallingAdhocCallInfo.removeClientDialog"
moduleClassName="CallingAdhocCallInfo__RemoveClientDialog"
actions={[
{
action: () =>
blockClient({ demuxId: removeClientDialogState.demuxId }),
style: 'negative',
text: i18n(
'icu:CallingAdhocCallInfo__RemoveClientDialogButton--block'
),
},
{
action: () =>
removeClient({ demuxId: removeClientDialogState.demuxId }),
style: 'negative',
text: i18n(
'icu:CallingAdhocCallInfo__RemoveClientDialogButton--remove'
),
},
]}
cancelText={i18n('icu:cancel')}
i18n={i18n}
theme={Theme.Dark}
onClose={() => setRemoveClientDialogState(null)}
>
{i18n('icu:CallingAdhocCallInfo__RemoveClientDialogBody', {
name: removeClientDialogState.name,
})}
</ConfirmationDialog>
) : null}
{isUnknownContactDialogVisible ? (
<Modal
modalName="CallingAdhocCallInfo.UnknownContactInfo"
moduleClassName="CallingAdhocCallInfo__UnknownContactInfoDialog"
i18n={i18n}
modalFooter={
<Button onClick={hideUnknownContactDialog}>
{i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogOk')}
</Button>
}
onClose={hideUnknownContactDialog}
theme={Theme.Dark}
>
{i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogBody')}
</Modal>
) : null}
<ModalHost
modalName="CallingAdhocCallInfo"
moduleClassName="CallingAdhocCallInfo"
onClose={onClose}
>
<div className="CallingAdhocCallInfo module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{participants.length
? i18n('icu:calling__in-this-call', {
people: participants.length,
})
: i18n('icu:calling__in-this-call--zero')}
</div>
<button
type="button"
className="module-calling-participants-list__close"
onClick={onClose}
tabIndex={0}
aria-label={i18n('icu:close')}
/>
</div>
<ul className="module-calling-participants-list__list">
{sortedParticipants.map(renderParticipant)}
{unknownParticipants.length > 0 && (
<UnknownContacts
i18n={i18n}
isInAdditionToKnownContacts={Boolean(
visibleParticipants.length
)}
participants={unknownParticipants}
showUnknownContactDialog={() =>
setIsUnknownContactDialogVisible(true)
}
/>
)}
</ul>
<div className="CallingAdhocCallInfo__Divider" />
<div className="CallingAdhocCallInfo__CallLinkInfo">
<button
className="CallingAdhocCallInfo__MenuItem"
onClick={onCopyCallLink}
type="button"
>
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
<span className="CallingAdhocCallInfo__MenuItemText">
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
</span>
</button>
<button
className="CallingAdhocCallInfo__MenuItem"
onClick={onClickShareCallLinkViaSignal}
type="button"
>
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--share-via-signal" />
<span className="CallingAdhocCallInfo__MenuItemText">
{i18n('icu:CallingAdhocCallInfo__ShareViaSignal')}
</span>
</button>
</div>
<button
type="button"
className="module-calling-participants-list__close"
onClick={onClose}
tabIndex={0}
aria-label={i18n('icu:close')}
/>
</div>
<ul className="module-calling-participants-list__list">
{sortedParticipants.map(
(participant: ParticipantType, index: number) => (
<li
className="module-calling-participants-list__contact"
// It's tempting to use `participant.serviceId` as the `key`
// here, but that can result in duplicate keys for
// participants who have joined on multiple devices.
key={index}
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPath={participant.avatarPath}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
/>
{ourServiceId && participant.serviceId === ourServiceId ? (
<span className="module-calling-participants-list__name">
{i18n('icu:you')}
</span>
) : (
<>
<ContactName
module="module-calling-participants-list__name"
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
) : null}
</>
)}
</div>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.isHandRaised &&
'module-calling-participants-list__hand-raised'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.presenting &&
'module-calling-participants-list__presenting',
!participant.hasRemoteVideo &&
'module-calling-participants-list__muted--video'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
!participant.hasRemoteAudio &&
'module-calling-participants-list__muted--audio'
)}
/>
</li>
)
)}
</ul>
<div className="CallingAdhocCallInfo__Divider" />
<div className="CallingAdhocCallInfo__CallLinkInfo">
<button
className="CallingAdhocCallInfo__MenuItem"
onClick={onCopyCallLink}
type="button"
>
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
<span className="CallingAdhocCallInfo__MenuItemText">
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
</span>
</button>
</div>
</div>
</ModalHost>
</ModalHost>
</>
);
}

View file

@ -111,34 +111,42 @@ export function CallingButton({
tooltipContent = i18n('icu:CallingButton--more-options');
}
const buttonContent = (
<button
aria-label={tooltipContent}
className={classNames(
'CallingButton__icon',
`CallingButton__icon--${classNameSuffix}`
)}
disabled={disabled}
id={uniqueButtonId}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
type="button"
>
<div />
</button>
);
return (
<div className="CallingButton">
<Tooltip
className="CallingButton__tooltip"
wrapperClassName={classNames(
'CallingButton__button-container',
!isVisible && 'CallingButton__button-container--hidden'
)}
content={tooltipContent}
direction={tooltipDirection}
theme={Theme.Dark}
>
<button
aria-label={tooltipContent}
className={classNames(
'CallingButton__icon',
`CallingButton__icon--${classNameSuffix}`
{tooltipContent === '' ? (
<div className="CallingButton__button-container">{buttonContent}</div>
) : (
<Tooltip
className="CallingButton__tooltip"
wrapperClassName={classNames(
'CallingButton__button-container',
!isVisible && 'CallingButton__button-container--hidden'
)}
disabled={disabled}
id={uniqueButtonId}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
type="button"
content={tooltipContent}
direction={tooltipDirection}
theme={Theme.Dark}
>
<div />
</button>
</Tooltip>
{buttonContent}
</Tooltip>
)}
</div>
);
}

View file

@ -20,6 +20,7 @@ import {
} from '../test-both/helpers/getDefaultConversation';
import { CallingToastProvider } from './CallingToast';
import { CallMode } from '../types/Calling';
import { getDefaultCallLinkConversation } from '../test-both/helpers/fakeCallLink';
const i18n = setupI18n('en', enMessages);
@ -33,15 +34,24 @@ const camera = {
},
};
const getConversation = (callMode: CallMode) => {
if (callMode === CallMode.Group) {
return getDefaultConversation({
title: 'Tahoe Trip',
type: 'group',
});
}
if (callMode === CallMode.Adhoc) {
return getDefaultCallLinkConversation();
}
return getDefaultConversation();
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
const callMode = overrideProps.callMode ?? CallMode.Direct;
const conversation =
callMode === CallMode.Group
? getDefaultConversation({
title: 'Tahoe Trip',
type: 'group',
})
: getDefaultConversation();
const conversation = getConversation(callMode);
return {
availableCameras: overrideProps.availableCameras || [camera],
@ -55,9 +65,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
hasLocalAudio: overrideProps.hasLocalAudio ?? true,
hasLocalVideo: overrideProps.hasLocalVideo ?? false,
i18n,
isAdhocJoinRequestPending: false,
isAdhocAdminApprovalRequired:
overrideProps.isAdhocAdminApprovalRequired ?? false,
isAdhocJoinRequestPending: overrideProps.isAdhocJoinRequestPending ?? false,
isConversationTooBigToRing: false,
isCallFull: overrideProps.isCallFull ?? false,
getIsSharingPhoneNumberWithEverybody:
overrideProps.getIsSharingPhoneNumberWithEverybody ?? (() => false),
me:
overrideProps.me ||
getDefaultConversation({
@ -75,6 +89,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
setOutgoingRing: action('set-outgoing-ring'),
showParticipantsList: overrideProps.showParticipantsList ?? false,
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
};
};
@ -114,7 +129,7 @@ export function NoCameraLocalAvatar(): JSX.Element {
const props = createProps({
availableCameras: [],
me: getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
avatarUrl: '/fixtures/kitten-4-112-112.jpg',
color: AvatarColors[0],
id: generateUuid(),
serviceId: generateAci(),
@ -205,3 +220,29 @@ export function GroupCallWith0PeekedParticipantsBigGroup(): JSX.Element {
});
return <CallingLobby {...props} />;
}
export function CallLink(): JSX.Element {
const props = createProps({
callMode: CallMode.Adhoc,
});
return <CallingLobby {...props} />;
}
// Due to storybook font loading, if you directly load this story then
// the button width is not calculated correctly
export function CallLinkAdminApproval(): JSX.Element {
const props = createProps({
callMode: CallMode.Adhoc,
isAdhocAdminApprovalRequired: true,
});
return <CallingLobby {...props} />;
}
export function CallLinkJoinRequestPending(): JSX.Element {
const props = createProps({
callMode: CallMode.Adhoc,
isAdhocAdminApprovalRequired: true,
isAdhocJoinRequestPending: true,
});
return <CallingLobby {...props} />;
}

View file

@ -28,7 +28,8 @@ import type { ConversationType } from '../state/ducks/conversations';
import { useCallingToasts } from './CallingToast';
import { CallingButtonToastsContainer } from './CallingToastManager';
import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall';
import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode';
import { Button, ButtonVariant } from './Button';
import { SpinnerV2 } from './SpinnerV2';
export type PropsType = {
availableCameras: Array<MediaDeviceInfo>;
@ -36,7 +37,7 @@ export type PropsType = {
conversation: Pick<
CallingConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'isMe'
| 'memberships'
@ -48,8 +49,9 @@ export type PropsType = {
| 'systemNickname'
| 'title'
| 'type'
| 'unblurredAvatarPath'
| 'unblurredAvatarUrl'
>;
getIsSharingPhoneNumberWithEverybody: () => boolean;
groupMembers?: Array<
Pick<
ConversationType,
@ -59,11 +61,12 @@ export type PropsType = {
hasLocalAudio: boolean;
hasLocalVideo: boolean;
i18n: LocalizerType;
isAdhocAdminApprovalRequired: boolean;
isAdhocJoinRequestPending: boolean;
isConversationTooBigToRing: boolean;
isCallFull?: boolean;
me: Readonly<
Pick<ConversationType, 'avatarPath' | 'color' | 'id' | 'serviceId'>
Pick<ConversationType, 'avatarUrl' | 'color' | 'id' | 'serviceId'>
>;
onCallCanceled: () => void;
onJoinCall: () => void;
@ -75,6 +78,7 @@ export type PropsType = {
setOutgoingRing: (_: boolean) => void;
showParticipantsList: boolean;
toggleParticipants: () => void;
togglePip: () => void;
toggleSettings: () => void;
};
@ -86,9 +90,11 @@ export function CallingLobby({
hasLocalAudio,
hasLocalVideo,
i18n,
isAdhocAdminApprovalRequired,
isAdhocJoinRequestPending,
isCallFull = false,
isConversationTooBigToRing,
getIsSharingPhoneNumberWithEverybody,
me,
onCallCanceled,
onJoinCall,
@ -98,6 +104,7 @@ export function CallingLobby({
setLocalVideo,
setOutgoingRing,
toggleParticipants,
togglePip,
toggleSettings,
outgoingRing,
}: PropsType): JSX.Element {
@ -119,6 +126,10 @@ export function CallingLobby({
setOutgoingRing(!outgoingRing);
}, [outgoingRing, setOutgoingRing]);
const togglePipForCallingHeader = isAdhocJoinRequestPending
? togglePip
: undefined;
React.useEffect(() => {
setLocalPreview({ element: localVideoRef });
@ -155,14 +166,16 @@ export function CallingLobby({
const isOnline = useIsOnline();
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
const [isCallConnecting, setIsCallConnecting] = React.useState(
isAdhocJoinRequestPending || false
);
// eslint-disable-next-line no-nested-ternary
const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON
: availableCameras.length === 0
? CallingButtonType.VIDEO_DISABLED
: CallingButtonType.VIDEO_OFF;
? CallingButtonType.VIDEO_DISABLED
: CallingButtonType.VIDEO_OFF;
const audioButtonType = hasLocalAudio
? CallingButtonType.AUDIO_ON
@ -200,13 +213,18 @@ export function CallingLobby({
}
const canJoin = !isCallFull && !isCallConnecting && isOnline;
const canLeave =
(isAdhocAdminApprovalRequired && isCallConnecting) ||
isAdhocJoinRequestPending;
let callingLobbyJoinButtonVariant: CallingLobbyJoinButtonVariant;
if (isCallFull) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
} else if (isCallConnecting) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
} else if (peekedParticipants.length) {
} else if (isAdhocAdminApprovalRequired) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.AskToJoin;
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
} else {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
@ -247,7 +265,16 @@ export function CallingLobby({
useWasInitiallyMutedToast(hasLocalAudio, i18n);
return (
<FocusTrap>
<FocusTrap
focusTrapOptions={{
allowOutsideClick: ({ target }) => {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
return target.matches('.Toast, .Toast *');
},
}}
>
<div className="module-calling__container dark-theme">
{shouldShowLocalVideo ? (
<video
@ -258,7 +285,7 @@ export function CallingLobby({
) : (
<CallBackgroundBlur
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-off"
avatarPath={me.avatarPath}
avatarUrl={me.avatarUrl}
/>
)}
@ -266,6 +293,7 @@ export function CallingLobby({
i18n={i18n}
isGroupCall={isGroupOrAdhocCall}
participantCount={peekedParticipants.length}
togglePip={togglePipForCallingHeader}
toggleSettings={toggleSettings}
onCancel={onCallCanceled}
/>
@ -292,13 +320,25 @@ export function CallingLobby({
{i18n('icu:calling__your-video-is-off')}
</div>
{callMode === CallMode.Adhoc && (
<div className="CallingLobby__CallLinkNotice">
{isSharingPhoneNumberWithEverybody()
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
: i18n('icu:CallingLobby__CallLinkNotice')}
</div>
)}
{/* eslint-disable-next-line no-nested-ternary */}
{callMode === CallMode.Adhoc ? (
isAdhocJoinRequestPending ? (
<div className="CallingLobby__CallLinkNotice CallingLobby__CallLinkNotice--join-request-pending">
<SpinnerV2
className="CallingLobby__CallLinkJoinRequestPendingSpinner"
size={16}
strokeWidth={3}
/>
{i18n('icu:CallingLobby__CallLinkNotice--join-request-pending')}
</div>
) : (
<div className="CallingLobby__CallLinkNotice">
{getIsSharingPhoneNumberWithEverybody()
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
: i18n('icu:CallingLobby__CallLinkNotice')}
</div>
)
) : null}
<CallingButtonToastsContainer
hasLocalAudio={hasLocalAudio}
@ -336,15 +376,25 @@ export function CallingLobby({
/>
</div>
<div className="CallControls__JoinLeaveButtonContainer">
<CallingLobbyJoinButton
disabled={!canJoin}
i18n={i18n}
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
variant={callingLobbyJoinButtonVariant}
/>
{canLeave ? (
<Button
className="CallControls__JoinLeaveButton CallControls__JoinLeaveButton--hangup"
onClick={onCallCanceled}
variant={ButtonVariant.Destructive}
>
{i18n('icu:CallControls__JoinLeaveButton--hangup-group')}
</Button>
) : (
<CallingLobbyJoinButton
disabled={!canJoin}
i18n={i18n}
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
variant={callingLobbyJoinButtonVariant}
/>
)}
</div>
</div>
<div className="module-calling__spacer CallControls__OuterSpacer" />

View file

@ -14,6 +14,7 @@ export enum CallingLobbyJoinButtonVariant {
Join = 'Join',
Loading = 'Loading',
Start = 'Start',
AskToJoin = 'AskToJoin',
}
type PropsType = {
@ -55,6 +56,9 @@ export function CallingLobbyJoinButton({
[CallingLobbyJoinButtonVariant.Start]: i18n(
'icu:CallingLobbyJoinButton--start'
),
[CallingLobbyJoinButtonVariant.AskToJoin]: i18n(
'icu:CallingLobbyJoinButton--ask-to-join'
),
};
return (

View file

@ -31,7 +31,7 @@ function createParticipant(
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,
...getDefaultConversationWithServiceId({
avatarPath: participantProps.avatarPath,
avatarUrl: participantProps.avatarUrl,
color: sample(AvatarColors),
isBlocked: Boolean(participantProps.isBlocked),
name: participantProps.name,
@ -43,9 +43,11 @@ function createParticipant(
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
conversationId: 'fake-conversation-id',
onClose: action('on-close'),
ourServiceId: generateAci(),
participants: overrideProps.participants || [],
showContactModal: action('show-contact-modal'),
});
export default {

View file

@ -26,18 +26,25 @@ type ParticipantType = ConversationType & {
};
export type PropsType = {
readonly conversationId: string;
readonly i18n: LocalizerType;
readonly onClose: () => void;
readonly ourServiceId: ServiceIdString | undefined;
readonly participants: Array<ParticipantType>;
readonly showContactModal: (
contactId: string,
conversationId?: string
) => void;
};
export const CallingParticipantsList = React.memo(
function CallingParticipantsListInner({
conversationId,
i18n,
onClose,
ourServiceId,
participants,
showContactModal,
}: PropsType) {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
@ -96,22 +103,33 @@ export const CallingParticipantsList = React.memo(
aria-label={i18n('icu:close')}
/>
</div>
<ul className="module-calling-participants-list__list">
<div className="module-calling-participants-list__list">
{sortedParticipants.map(
(participant: ParticipantType, index: number) => (
<li
<button
aria-label={i18n('icu:calling__ParticipantInfoButton')}
className="module-calling-participants-list__contact"
disabled={participant.isMe}
// It's tempting to use `participant.serviceId` as the `key`
// here, but that can result in duplicate keys for
// participants who have joined on multiple devices.
key={index}
onClick={() => {
if (participant.isMe) {
return;
}
onClose();
showContactModal(participant.id, conversationId);
}}
type="button"
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={
participant.acceptedMessageRequest
}
avatarPath={participant.avatarPath}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
@ -134,13 +152,10 @@ export const CallingParticipantsList = React.memo(
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
) : null}
</>
)}
@ -168,10 +183,10 @@ export const CallingParticipantsList = React.memo(
'module-calling-participants-list__muted--audio'
)}
/>
</li>
</button>
)
)}
</ul>
</div>
</div>
</div>
</FocusTrap>,

View file

@ -0,0 +1,91 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './CallingPendingParticipants';
import { CallingPendingParticipants } from './CallingPendingParticipants';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { allRemoteParticipants } from './CallScreen.stories';
const i18n = setupI18n('en', enMessages);
const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
i18n,
participants: [allRemoteParticipants[0], allRemoteParticipants[1]],
approveUser: action('approve-user'),
batchUserAction: action('batch-user-action'),
denyUser: action('deny-user'),
...storyProps,
});
export default {
title: 'Components/CallingPendingParticipants',
argTypes: {},
args: {},
} satisfies Meta<PropsType>;
export function One(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: [allRemoteParticipants[0]],
})}
/>
);
}
export function Two(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: allRemoteParticipants.slice(0, 2),
})}
/>
);
}
export function Many(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
participants: allRemoteParticipants.slice(0, 10),
})}
/>
);
}
export function ExpandedOne(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: [allRemoteParticipants[0]],
})}
/>
);
}
export function ExpandedTwo(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: allRemoteParticipants.slice(0, 2),
})}
/>
);
}
export function ExpandedMany(): JSX.Element {
return (
<CallingPendingParticipants
{...createProps({
defaultIsExpanded: true,
participants: allRemoteParticipants.slice(0, 10),
})}
/>
);
}

View file

@ -0,0 +1,352 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable react/no-array-index-key */
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { noop } from 'lodash';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { InContactsIcon } from './InContactsIcon';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { isInSystemContacts } from '../util/isInSystemContacts';
import type {
BatchUserActionPayloadType,
PendingUserActionPayloadType,
} from '../state/ducks/calling';
import { Button, ButtonVariant } from './Button';
import type { ServiceIdString } from '../types/ServiceId';
import { handleOutsideClick } from '../util/handleOutsideClick';
import { Theme } from '../util/theme';
import { ConfirmationDialog } from './ConfirmationDialog';
enum ConfirmDialogState {
None = 'None',
ApproveAll = 'ApproveAll',
DenyAll = 'DenyAll',
}
export type PropsType = {
readonly i18n: LocalizerType;
readonly participants: Array<ConversationType>;
// For storybook
readonly defaultIsExpanded?: boolean;
readonly approveUser: (payload: PendingUserActionPayloadType) => void;
readonly batchUserAction: (payload: BatchUserActionPayloadType) => void;
readonly denyUser: (payload: PendingUserActionPayloadType) => void;
};
export function CallingPendingParticipants({
defaultIsExpanded,
i18n,
participants,
approveUser,
batchUserAction,
denyUser,
}: PropsType): JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(defaultIsExpanded ?? false);
const [confirmDialogState, setConfirmDialogState] =
React.useState<ConfirmDialogState>(ConfirmDialogState.None);
const [serviceIdsStagedForAction, setServiceIdsStagedForAction] =
React.useState<Array<ServiceIdString>>([]);
const expandedListRef = useRef<HTMLDivElement>(null);
const handleHideAllRequests = useCallback(() => {
setIsExpanded(false);
}, [setIsExpanded]);
// When opening the "Approve all" confirm dialog, save the current list of participants
// to ensure we only approve users who the admin has checked. If additional people
// request to join while the dialog is open, we don't auto approve those.
const stageServiceIdsForAction = useCallback(() => {
const serviceIds: Array<ServiceIdString> = [];
participants.forEach(participant => {
if (participant.serviceId) {
serviceIds.push(participant.serviceId);
}
});
setServiceIdsStagedForAction(serviceIds);
}, [participants, setServiceIdsStagedForAction]);
const hideConfirmDialog = useCallback(() => {
setConfirmDialogState(ConfirmDialogState.None);
setServiceIdsStagedForAction([]);
}, [setConfirmDialogState]);
const handleApprove = useCallback(
(participant: ConversationType) => {
const { serviceId } = participant;
if (!serviceId) {
return;
}
approveUser({ serviceId });
},
[approveUser]
);
const handleDeny = useCallback(
(participant: ConversationType) => {
const { serviceId } = participant;
if (!serviceId) {
return;
}
denyUser({ serviceId });
},
[denyUser]
);
const handleApproveAll = useCallback(() => {
batchUserAction({
action: 'approve',
serviceIds: serviceIdsStagedForAction,
});
hideConfirmDialog();
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
const handleDenyAll = useCallback(() => {
batchUserAction({
action: 'deny',
serviceIds: serviceIdsStagedForAction,
});
hideConfirmDialog();
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
const renderApprovalButtons = useCallback(
(participant: ConversationType) => {
if (participant.serviceId == null) {
return null;
}
return (
<>
<Button
aria-label={i18n('icu:CallingPendingParticipants__DenyUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => handleDeny(participant)}
variant={ButtonVariant.Destructive}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Deny" />
</Button>
<Button
aria-label={i18n('icu:CallingPendingParticipants__ApproveUser')}
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
onClick={() => handleApprove(participant)}
variant={ButtonVariant.Calling}
>
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Approve" />
</Button>
</>
);
},
[i18n, handleApprove, handleDeny]
);
useEffect(() => {
if (!isExpanded) {
return noop;
}
return handleOutsideClick(
() => {
handleHideAllRequests();
return true;
},
{
containerElements: [expandedListRef],
name: 'CallingPendingParticipantsList.expandedList',
}
);
}, [isExpanded, handleHideAllRequests]);
if (confirmDialogState === ConfirmDialogState.ApproveAll) {
return (
<ConfirmationDialog
dialogName="CallingPendingParticipants.confirmDialog"
actions={[
{
action: handleApproveAll,
style: 'affirmative',
text: i18n('icu:CallingPendingParticipants__ApproveAll'),
},
]}
cancelText={i18n('icu:cancel')}
i18n={i18n}
theme={Theme.Dark}
title={i18n(
'icu:CallingPendingParticipants__ConfirmDialogTitle--ApproveAll',
{ count: serviceIdsStagedForAction.length }
)}
onClose={hideConfirmDialog}
>
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--ApproveAll', {
count: serviceIdsStagedForAction.length,
})}
</ConfirmationDialog>
);
}
if (confirmDialogState === ConfirmDialogState.DenyAll) {
return (
<ConfirmationDialog
dialogName="CallingPendingParticipants.confirmDialog"
actions={[
{
action: handleDenyAll,
style: 'affirmative',
text: i18n('icu:CallingPendingParticipants__DenyAll'),
},
]}
cancelText={i18n('icu:cancel')}
i18n={i18n}
theme={Theme.Dark}
title={i18n(
'icu:CallingPendingParticipants__ConfirmDialogTitle--DenyAll',
{ count: serviceIdsStagedForAction.length }
)}
onClose={hideConfirmDialog}
>
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--DenyAll', {
count: serviceIdsStagedForAction.length,
})}
</ConfirmationDialog>
);
}
if (isExpanded) {
return (
<div
className="CallingPendingParticipants CallingPendingParticipants--Expanded module-calling-participants-list"
ref={expandedListRef}
>
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{i18n('icu:CallingPendingParticipants__RequestsToJoin', {
count: participants.length,
})}
</div>
<button
type="button"
className="module-calling-participants-list__close"
onClick={handleHideAllRequests}
tabIndex={0}
aria-label={i18n('icu:close')}
/>
</div>
<ul className="module-calling-participants-list__list">
{participants.map((participant: ConversationType, index: number) => (
<li
className="module-calling-participants-list__contact"
key={index}
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
/>
<ContactName
module="module-calling-participants-list__name"
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
) : null}
</div>
{renderApprovalButtons(participant)}
</li>
))}
</ul>
<div className="CallingPendingParticipants__ActionPanel">
<Button
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--DenyAll"
variant={ButtonVariant.Destructive}
onClick={() => {
stageServiceIdsForAction();
setConfirmDialogState(ConfirmDialogState.DenyAll);
}}
>
{i18n('icu:CallingPendingParticipants__DenyAll')}
</Button>
<Button
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--ApproveAll"
variant={ButtonVariant.Calling}
onClick={() => {
stageServiceIdsForAction();
setConfirmDialogState(ConfirmDialogState.ApproveAll);
}}
>
{i18n('icu:CallingPendingParticipants__ApproveAll')}
</Button>
</div>
</div>
);
}
const participant = participants[0];
return (
<div className="CallingPendingParticipants CallingPendingParticipants--Compact module-calling-participants-list">
<div className="CallingPendingParticipants__CompactParticipant">
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}
/>
<div className="CallingPendingParticipants__CompactParticipantNameColumn">
<div className="CallingPendingParticipants__ParticipantName">
<ContactName title={participant.title} />
{isInSystemContacts(participant) ? (
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
) : null}
</div>
<div className="CallingPendingParticipants__WouldLikeToJoin">
{i18n('icu:CallingPendingParticipants__WouldLikeToJoin')}
</div>
</div>
</div>
{renderApprovalButtons(participant)}
</div>
{participants.length > 1 && (
<div className="CallingPendingParticipants__ShowAllRequestsButtonContainer">
<button
className="CallingPendingParticipants__ShowAllRequestsButton"
onClick={() => setIsExpanded(true)}
type="button"
>
{i18n('icu:CallingPendingParticipants__AdditionalRequests', {
count: participants.length - 1,
})}
</button>
</div>
)}
</div>
);
}

View file

@ -26,7 +26,7 @@ const i18n = setupI18n('en', enMessages);
const conversation: ConversationType = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@ -98,7 +98,7 @@ export function ContactWithAvatarAndNoVideo(args: PropsType): JSX.Element {
...getDefaultCall({}),
conversation: {
...conversation,
avatarPath: 'https://www.fillmurray.com/64/64',
avatarUrl: 'https://www.fillmurray.com/64/64',
},
remoteParticipants: [
{ hasRemoteVideo: false, presenting: false, title: 'Julian' },
@ -131,7 +131,6 @@ export function GroupCall(args: PropsType): JSX.Element {
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
@ -140,6 +139,7 @@ export function GroupCall(args: PropsType): JSX.Element {
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),

View file

@ -13,6 +13,7 @@ import type {
} from '../state/ducks/calling';
import { missingCaseError } from '../util/missingCaseError';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import type { CallingImageDataCache } from './CallManager';
enum PositionMode {
BeingDragged,
@ -54,6 +55,7 @@ export type PropsType = {
hangUpActiveCall: (reason: string) => void;
hasLocalVideo: boolean;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
@ -75,6 +77,7 @@ export function CallingPip({
getGroupCallVideoFrameSource,
hangUpActiveCall,
hasLocalVideo,
imageDataCache,
i18n,
setGroupCallVideoRequest,
setLocalPreview,
@ -304,6 +307,7 @@ export function CallingPip({
<CallingPipRemoteVideo
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
setGroupCallVideoRequest={setGroupCallVideoRequest}

View file

@ -14,7 +14,7 @@ import type {
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
} from '../types/Calling';
import { CallMode } from '../types/Calling';
import { CallMode, GroupCallJoinState } from '../types/Calling';
import { AvatarColors } from '../types/Colors';
import type { SetRendererCanvasType } from '../state/ducks/calling';
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
@ -25,6 +25,7 @@ import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteP
import { isReconnecting } from '../util/callingIsReconnecting';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert';
import type { CallingImageDataCache } from './CallManager';
// This value should be kept in sync with the hard-coded CSS height. It should also be
// less than `MAX_FRAME_HEIGHT`.
@ -39,8 +40,9 @@ function NoVideo({
}): JSX.Element {
const {
acceptedMessageRequest,
avatarPath,
avatarUrl,
color,
type: conversationType,
isMe,
phoneNumber,
profileName,
@ -50,15 +52,15 @@ function NoVideo({
return (
<div className="module-calling-pip__video--remote">
<CallBackgroundBlur avatarPath={avatarPath}>
<CallBackgroundBlur avatarUrl={avatarUrl}>
<div className="module-calling-pip__video--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
@ -77,6 +79,7 @@ export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
@ -87,6 +90,7 @@ export type PropsType = {
export function CallingPipRemoteVideo({
activeCall,
getGroupCallVideoFrameSource,
imageDataCache,
i18n,
setGroupCallVideoRequest,
setRendererCanvas,
@ -103,6 +107,10 @@ export function CallingPipRemoteVideo({
return undefined;
}
if (activeCall.joinState !== GroupCallJoinState.Joined) {
return undefined;
}
return maxBy(activeCall.remoteParticipants, participant =>
participant.presenting ? Infinity : participant.speakerTime || -Infinity
);
@ -176,6 +184,7 @@ export function CallingPipRemoteVideo({
<GroupCallRemoteParticipant
getFrameBuffer={getGroupCallFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
isInPip
remoteParticipant={activeGroupCallSpeaker}

View file

@ -20,7 +20,7 @@ export type PropsType = {
conversation: Pick<
CallingConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'isMe'
| 'phoneNumber'
@ -30,7 +30,7 @@ export type PropsType = {
| 'systemNickname'
| 'title'
| 'type'
| 'unblurredAvatarPath'
| 'unblurredAvatarUrl'
>;
i18n: LocalizerType;
me: Pick<ConversationType, 'id' | 'serviceId'>;
@ -186,7 +186,7 @@ export function CallingPreCallInfo({
return (
<div className="module-CallingPreCallInfo">
<Avatar
avatarPath={conversation.avatarPath}
avatarUrl={conversation.avatarUrl}
badge={undefined}
color={conversation.color}
acceptedMessageRequest={conversation.acceptedMessageRequest}
@ -198,7 +198,7 @@ export function CallingPreCallInfo({
sharedGroupNames={conversation.sharedGroupNames}
size={AvatarSize.NINETY_SIX}
title={conversation.title}
unblurredAvatarPath={conversation.unblurredAvatarPath}
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
i18n={i18n}
/>
<div className="module-CallingPreCallInfo__title">

View file

@ -35,7 +35,7 @@ const i18n = setupI18n('en', enMessages);
const conversation = getDefaultConversationWithServiceId({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',

View file

@ -77,6 +77,12 @@ export function CallingRaisedHandsList({
{i18n('icu:CallingRaisedHandsList__Title', {
count: participants.length,
})}
{participants.length > 1 ? (
<span className="CallingRaisedHandsList__TitleHint">
{' '}
{i18n('icu:CallingRaisedHandsList__TitleHint')}
</span>
) : null}
</div>
<button
type="button"
@ -95,7 +101,7 @@ export function CallingRaisedHandsList({
<div className="CallingRaisedHandsList__AvatarAndName module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPath={participant.avatarPath}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"

View file

@ -9,6 +9,7 @@ import type { PropsType } from './CallingScreenSharingController';
import { CallingScreenSharingController } from './CallingScreenSharingController';
import { setupI18n } from '../util/setupI18n';
import { ScreenShareStatus } from '../types/Calling';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
@ -18,6 +19,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
onCloseController: action('on-close-controller'),
onStopSharing: action('on-stop-sharing'),
presentedSourceName: overrideProps.presentedSourceName || 'Application',
status: overrideProps.status || ScreenShareStatus.Connected,
});
export default {
@ -38,3 +40,13 @@ export function ReallyLongAppName(): JSX.Element {
/>
);
}
export function Reconnecting(): JSX.Element {
return (
<CallingScreenSharingController
{...createProps({
status: ScreenShareStatus.Reconnecting,
})}
/>
);
}

View file

@ -4,11 +4,13 @@
import React from 'react';
import { Button, ButtonVariant } from './Button';
import type { LocalizerType } from '../types/Util';
import { ScreenShareStatus } from '../types/Calling';
export type PropsType = {
i18n: LocalizerType;
onCloseController: () => unknown;
onStopSharing: () => unknown;
status: ScreenShareStatus;
presentedSourceName: string;
};
@ -16,15 +18,22 @@ export function CallingScreenSharingController({
i18n,
onCloseController,
onStopSharing,
status,
presentedSourceName,
}: PropsType): JSX.Element {
let text: string;
if (status === ScreenShareStatus.Reconnecting) {
text = i18n('icu:calling__presenting--reconnecting');
} else {
text = i18n('icu:calling__presenting--info', {
window: presentedSourceName,
});
}
return (
<div className="module-CallingScreenSharingController">
<div className="module-CallingScreenSharingController__text">
{i18n('icu:calling__presenting--info', {
window: presentedSourceName,
})}
</div>
<div className="module-CallingScreenSharingController__text">{text}</div>
<div className="module-CallingScreenSharingController__buttons">
<Button
className="module-CallingScreenSharingController__button"

View file

@ -29,20 +29,43 @@ import {
GroupCallStatus,
isSameCallHistoryGroup,
} from '../types/CallDisposition';
import { formatDateTimeShort } from '../util/timestamp';
import { formatDateTimeShort, isMoreRecentThan } from '../util/timestamp';
import type { ConversationType } from '../state/ducks/conversations';
import * as log from '../logging/log';
import { refMerger } from '../util/refMerger';
import { drop } from '../util/drop';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { NavSidebarSearchHeader } from './NavSidebar';
import { SizeObserver } from '../hooks/useSizeObserver';
import { formatCallHistoryGroup } from '../util/callDisposition';
import {
formatCallHistoryGroup,
getCallIdFromEra,
} from '../util/callDisposition';
import { CallsNewCallButton } from './CallsNewCall';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
import type { CallingConversationType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import type { CallLinkType } from '../types/CallLink';
import {
callLinkToConversation,
getPlaceholderCallLinkConversation,
} from '../util/callLinks';
import type { CallsTabSelectedView } from './CallsTab';
import type { CallStateType } from '../state/selectors/calling';
import {
isGroupOrAdhocCallMode,
isGroupOrAdhocCallState,
} from '../util/isGroupOrAdhocCall';
import { isAnybodyInGroupCall } from '../state/ducks/callingHelpers';
import type {
ActiveCallStateType,
PeekNotConnectedGroupCallType,
} from '../state/ducks/calling';
import { DAY, MINUTE, SECOND } from '../util/durations';
import type { StartCallData } from './ConfirmLeaveCallModal';
function Timestamp({
i18n,
@ -103,7 +126,8 @@ const defaultPendingState: SearchState = {
};
type CallsListProps = Readonly<{
hasActiveCall: boolean;
activeCall: ActiveCallStateType | undefined;
canCreateCallLinks: boolean;
getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions
) => Promise<number>;
@ -112,22 +136,29 @@ type CallsListProps = Readonly<{
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
callHistoryEdition: number;
getAdhocCall: (roomId: string) => CallStateType | undefined;
getCall: (id: string) => CallStateType | undefined;
getCallLink: (id: string) => CallLinkType | undefined;
getConversation: (id: string) => ConversationType | void;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
selectedCallHistoryGroup: CallHistoryGroup | null;
onCreateCallLink: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
onSelectCallHistoryGroup: (
conversationId: string,
selectedCallHistoryGroup: CallHistoryGroup
) => void;
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
startCallLinkLobbyByRoomId: (options: { roomId: string }) => void;
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
togglePip: () => void;
}>;
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
function rowHeight() {
return CALL_LIST_ITEM_ROW_HEIGHT;
}
const INACTIVE_CALL_LINKS_TO_PEEK = 10;
const INACTIVE_CALL_LINK_AGE_THRESHOLD = 10 * DAY;
const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
const PEEK_BATCH_COUNT = 10;
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
function isSameOptions(
a: CallHistoryFilterOptions,
@ -136,22 +167,34 @@ function isSameOptions(
return a.query === b.query && a.status === b.status;
}
type SpecialRows = 'CreateCallLink' | 'EmptyState';
type Row = CallHistoryGroup | SpecialRows;
export function CallsList({
hasActiveCall,
activeCall,
canCreateCallLinks,
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
getAdhocCall,
getCall,
getCallLink,
getConversation,
i18n,
selectedCallHistoryGroup,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onSelectCallHistoryGroup,
onChangeCallsTabSelectedView,
peekNotConnectedGroupCall,
startCallLinkLobbyByRoomId,
toggleConfirmLeaveCallModal,
togglePip,
}: CallsListProps): JSX.Element {
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 prevOptionsRef = useRef<CallHistoryFilterOptions | null>(null);
@ -159,18 +202,316 @@ 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
);
const peekQueueRef = useRef<Set<string>>(new Set());
const peekQueueArgsRef = useRef<Map<string, PeekNotConnectedGroupCallType>>(
new Map()
);
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (peekQueueTimerRef.current != null) {
clearInterval(peekQueueTimerRef.current);
peekQueueTimerRef.current = null;
}
};
}, []);
useEffect(() => {
getCallHistoryGroupsCountRef.current = getCallHistoryGroupsCount;
getCallHistoryGroupsRef.current = getCallHistoryGroups;
}, [getCallHistoryGroupsCount, getCallHistoryGroups]);
const getConversationForItem = useCallback(
(item: CallHistoryGroup | null): CallingConversationType | null => {
if (!item) {
return null;
}
const isAdhoc = item?.type === CallType.Adhoc;
if (isAdhoc) {
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
if (callLink) {
return callLinkToConversation(callLink, i18n);
}
return getPlaceholderCallLinkConversation(item.peerId, i18n);
}
return getConversation(item.peerId) ?? null;
},
[getCallLink, getConversation, i18n]
);
const getCallByPeerId = useCallback(
({
mode,
peerId,
}: {
mode: CallMode | undefined;
peerId: string | undefined;
}): CallStateType | undefined => {
if (!peerId || !mode) {
return;
}
if (mode === CallMode.Adhoc) {
return getAdhocCall(peerId);
}
const conversation = getConversation(peerId);
if (!conversation) {
return;
}
return getCall(conversation.id);
},
[getAdhocCall, getCall, getConversation]
);
const getIsCallActive = useCallback(
({
callHistoryGroup,
}: {
callHistoryGroup: CallHistoryGroup | null;
}): boolean => {
if (!callHistoryGroup) {
return false;
}
const { mode, peerId } = callHistoryGroup;
const call = getCallByPeerId({ mode, peerId });
if (!call) {
return false;
}
if (isGroupOrAdhocCallState(call)) {
if (!isAnybodyInGroupCall(call.peekInfo)) {
return false;
}
if (mode === CallMode.Group) {
const eraId = call.peekInfo?.eraId;
if (!eraId) {
return false;
}
const callId = getCallIdFromEra(eraId);
return callHistoryGroup.children.some(
groupItem => groupItem.callId === callId
);
}
return true;
}
// We can't tell from CallHistory alone whether a 1:1 call is active
return false;
},
[getCallByPeerId]
);
const getIsInCall = useCallback(
({
activeCallConversationId,
callHistoryGroup,
conversation,
isActive,
}: {
activeCallConversationId: string | undefined;
callHistoryGroup: CallHistoryGroup | null;
conversation: CallingConversationType | null;
isActive: boolean;
}): boolean => {
if (!callHistoryGroup) {
return false;
}
const { mode, peerId } = callHistoryGroup;
if (mode === CallMode.Adhoc) {
return peerId === activeCallConversationId;
}
// For direct conversations, we know the call is active if it's the active call!
if (mode === CallMode.Direct) {
return Boolean(
conversation && conversation?.id === activeCallConversationId
);
}
// For group and adhoc calls, a call has to have members in it (see getIsCallActive)
return Boolean(
isActive &&
conversation &&
conversation?.id === activeCallConversationId
);
},
[]
);
// If the call is already enqueued then this is a no op.
const maybeEnqueueCallPeek = useCallback((item: CallHistoryGroup): void => {
const { mode: callMode, peerId } = item;
const queue = peekQueueRef.current;
if (queue.has(peerId)) {
return;
}
if (isGroupOrAdhocCallMode(callMode)) {
peekQueueArgsRef.current.set(peerId, {
callMode,
conversationId: peerId,
});
queue.add(peerId);
} else {
log.error(`Trying to peek unsupported call mode ${callMode}`);
}
}, []);
// Get the oldest inserted peerIds by iterating the Set in insertion order.
const getPeerIdsToPeek = useCallback((): ReadonlyArray<string> => {
const peerIds: Array<string> = [];
for (const peerId of peekQueueRef.current) {
peerIds.push(peerId);
if (peerIds.length === PEEK_BATCH_COUNT) {
return peerIds;
}
}
return peerIds;
}, []);
const doCallPeeks = useCallback((): void => {
const peerIds = getPeerIdsToPeek();
for (const peerId of peerIds) {
const peekArgs = peekQueueArgsRef.current.get(peerId);
if (peekArgs) {
inactiveCallLinksPeekedAtRef.current.set(peerId, new Date().getTime());
peekNotConnectedGroupCall(peekArgs);
}
peekQueueRef.current.delete(peerId);
peekQueueArgsRef.current.delete(peerId);
}
}, [getPeerIdsToPeek, peekNotConnectedGroupCall]);
const enqueueCallPeeks = useCallback(
(callItems: ReadonlyArray<CallHistoryGroup>, isFirstRun: boolean): void => {
let peekCount = 0;
let inactiveCallLinksToPeek = 0;
for (const item of callItems) {
const { mode } = item;
if (isGroupOrAdhocCallMode(mode)) {
const isActive = getIsCallActive({ callHistoryGroup: item });
if (isActive) {
// Don't peek if you're already in the call.
const activeCallConversationId = activeCall?.conversationId;
if (activeCallConversationId) {
const conversation = getConversationForItem(item);
const isInCall = getIsInCall({
activeCallConversationId,
callHistoryGroup: item,
conversation,
isActive,
});
if (isInCall) {
continue;
}
}
maybeEnqueueCallPeek(item);
peekCount += 1;
continue;
}
if (
mode === CallMode.Adhoc &&
isFirstRun &&
inactiveCallLinksToPeek < INACTIVE_CALL_LINKS_TO_PEEK &&
isMoreRecentThan(item.timestamp, INACTIVE_CALL_LINK_AGE_THRESHOLD)
) {
const peekedAt = inactiveCallLinksPeekedAtRef.current.get(
item.peerId
);
if (
peekedAt &&
isMoreRecentThan(peekedAt, INACTIVE_CALL_LINK_PEEK_INTERVAL)
) {
continue;
}
maybeEnqueueCallPeek(item);
inactiveCallLinksToPeek += 1;
peekCount += 1;
}
}
}
if (peekCount === 0) {
return;
}
log.info(`Found ${peekCount} calls to peek.`);
if (peekQueueTimerRef.current != null) {
return;
}
log.info('Starting background call peek.');
peekQueueTimerRef.current = setInterval(() => {
if (searchStateItemsRef.current) {
enqueueCallPeeks(searchStateItemsRef.current, false);
}
if (peekQueueRef.current.size > 0) {
doCallPeeks();
}
}, PEEK_QUEUE_INTERVAL);
doCallPeeks();
},
[
activeCall?.conversationId,
doCallPeeks,
getConversationForItem,
getIsCallActive,
getIsInCall,
maybeEnqueueCallPeek,
]
);
useEffect(() => {
const controller = new AbortController();
async function search() {
const options: CallHistoryFilterOptions = {
query: queryInput.toLowerCase().normalize().trim(),
status,
status: statusInput,
};
let timer = setTimeout(() => {
@ -209,6 +550,11 @@ export function CallsList({
return;
}
if (results) {
enqueueCallPeeks(results.items, true);
searchStateItemsRef.current = results.items;
}
// Only commit the new search state once the results are ready
setSearchState({
state: results == null ? 'rejected' : 'fulfilled',
@ -236,7 +582,7 @@ export function CallsList({
return () => {
controller.abort();
};
}, [queryInput, status, callHistoryEdition]);
}, [queryInput, statusInput, callHistoryEdition, enqueueCallPeeks]);
const loadMoreRows = useCallback(
async (props: IndexRange) => {
@ -269,6 +615,8 @@ export function CallsList({
return;
}
enqueueCallPeeks(groups, false);
setSearchState(prevSearchState => {
strictAssert(
prevSearchState.results != null,
@ -276,6 +624,7 @@ export function CallsList({
);
const newItems = prevSearchState.results.items.slice();
newItems.splice(startIndex, stopIndex, ...groups);
searchStateItemsRef.current = newItems;
return {
...prevSearchState,
results: {
@ -288,7 +637,7 @@ export function CallsList({
log.error('CallsList#loadMoreRows error fetching', error);
}
},
[searchState]
[enqueueCallPeeks, searchState]
);
const isRowLoaded = useCallback(
@ -298,16 +647,92 @@ 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 conversation = item != null ? getConversation(item.peerId) : null;
const item = rows.at(index) ?? null;
if (
searchState.state === 'pending' ||
item == null ||
conversation == 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;
const isActive = getIsCallActive({
callHistoryGroup: item,
});
const isInCall = getIsInCall({
activeCallConversationId,
callHistoryGroup: item,
conversation,
isActive,
});
const isAdhoc = item?.type === CallType.Adhoc;
const isCallButtonVisible = Boolean(
!isAdhoc || (isAdhoc && getCallLink(item.peerId))
);
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
if (searchPending || item == null || conversation == null) {
return (
<div key={key} style={style}>
<ListTile
@ -333,12 +758,18 @@ export function CallsList({
item.direction === CallDirection.Incoming &&
(item.status === DirectCallStatus.Missed ||
item.status === GroupCallStatus.Missed);
const wasDeclined =
item.direction === CallDirection.Incoming &&
(item.status === DirectCallStatus.Declined ||
item.status === GroupCallStatus.Declined);
let statusText;
if (wasMissed) {
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
} else if (item.type === CallType.Group) {
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
} else if (wasDeclined) {
statusText = i18n('icu:CallsList__ItemCallInfo--Declined');
} else if (isAdhoc) {
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
} else if (item.direction === CallDirection.Outgoing) {
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
} else if (item.direction === CallDirection.Incoming) {
@ -347,13 +778,52 @@ export function CallsList({
strictAssert(false, 'Cannot format call');
}
const inCallAndNotThisOne = !isInCall && activeCall;
const callButton = (
<CallsNewCallButton
callType={item.type}
isActive={isActiveVisible}
isInCall={isInCall}
isEnabled={!inCallAndNotThisOne}
onClick={() => {
if (isInCall) {
togglePip();
} else if (activeCall) {
if (isAdhoc) {
toggleConfirmLeaveCallModal({
type: 'adhoc-roomId',
roomId: item.peerId,
});
} else {
toggleConfirmLeaveCallModal({
type: 'conversation',
conversationId: conversation.id,
isVideoCall: item.type !== CallType.Audio,
});
}
} else if (isAdhoc) {
startCallLinkLobbyByRoomId({ roomId: item.peerId });
} else if (conversation) {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
}
}
}}
i18n={i18n}
/>
);
return (
<div
key={key}
style={style}
data-type={item.type}
className={classNames('CallsList__Item', {
'CallsList__Item--selected': isSelected,
'CallsList__Item--missed': wasMissed,
'CallsList__Item--declined': wasDeclined,
})}
>
<ListTile
@ -362,8 +832,9 @@ export function CallsList({
leading={
<Avatar
acceptedMessageRequest
avatarPath={conversation.avatarPath}
conversationType="group"
avatarUrl={conversation.avatarUrl}
color={conversation.color}
conversationType={conversation.type}
i18n={i18n}
isMe={false}
title={conversation.title}
@ -373,19 +844,7 @@ export function CallsList({
className="CallsList__ItemAvatar"
/>
}
trailing={
<CallsNewCallButton
callType={item.type}
hasActiveCall={hasActiveCall}
onClick={() => {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
}
}}
/>
}
trailing={isCallButtonVisible ? callButton : undefined}
title={
<span
className="CallsList__ItemTitle"
@ -399,24 +858,53 @@ export function CallsList({
<span className="CallsList__ItemCallInfo">
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
{statusText} &middot;{' '}
<Timestamp i18n={i18n} timestamp={item.timestamp} />
{isActiveVisible ? (
i18n('icu:CallsList__ItemCallInfo--Active')
) : (
<Timestamp i18n={i18n} timestamp={item.timestamp} />
)}
</span>
}
onClick={() => {
onSelectCallHistoryGroup(conversation.id, item);
if (isAdhoc) {
onChangeCallsTabSelectedView({
type: 'callLink',
roomId: item.peerId,
callHistoryGroup: item,
});
return;
}
if (conversation == null) {
return;
}
onChangeCallsTabSelectedView({
type: 'conversation',
conversationId: conversation.id,
callHistoryGroup: item,
});
}}
/>
</div>
);
},
[
hasActiveCall,
searchState,
getConversation,
activeCall,
rows,
searchStateQuery,
searchPending,
getCallLink,
getConversationForItem,
getIsCallActive,
getIsInCall,
selectedCallHistoryGroup,
onSelectCallHistoryGroup,
onChangeCallsTabSelectedView,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId,
toggleConfirmLeaveCallModal,
togglePip,
i18n,
]
);
@ -433,18 +921,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 (
<>
<NavSidebarSearchHeader>
@ -463,10 +946,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'
)}
@ -479,22 +963,6 @@ export function CallsList({
</Tooltip>
</NavSidebarSearchHeader>
{hasEmptyResults && (
<p className="CallsList__EmptyState">
{currentQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<Intl
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={currentQuery} />,
}}
/>
)}
</p>
)}
<SizeObserver>
{(ref, size) => {
return (
@ -504,7 +972,7 @@ export function CallsList({
ref={infiniteLoaderRef}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={searchState.results?.count}
rowCount={rowCount}
minimumBatchSize={100}
threshold={30}
>
@ -512,13 +980,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

@ -6,24 +6,28 @@ import React, { useCallback, useMemo, useState } from 'react';
import { partition } from 'lodash';
import type { ListRowProps } from 'react-virtualized';
import { List } from 'react-virtualized';
import classNames from 'classnames';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/I18N';
import { SearchInput } from './SearchInput';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { filterAndSortConversations } from '../util/filterAndSortConversations';
import { NavSidebarSearchHeader } from './NavSidebar';
import { ListTile } from './ListTile';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition';
import type { CallsTabSelectedView } from './CallsTab';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { offsetDistanceModifier } from '../util/popperUtil';
type CallsNewCallProps = Readonly<{
hasActiveCall: boolean;
allConversations: ReadonlyArray<ConversationType>;
i18n: LocalizerType;
onSelectConversation: (conversationId: string) => void;
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
regionCode: string | undefined;
@ -35,40 +39,78 @@ type Row =
export function CallsNewCallButton({
callType,
hasActiveCall,
isEnabled,
isActive,
isInCall,
i18n,
onClick,
}: {
callType: CallType;
hasActiveCall: boolean;
isActive: boolean;
isEnabled: boolean;
isInCall: boolean;
i18n: LocalizerType;
onClick: () => void;
}): JSX.Element {
return (
let innerContent: React.ReactNode | string;
let tooltipContent = '';
if (!isEnabled) {
tooltipContent = i18n('icu:ContactModal--already-in-call');
}
// Note: isActive is only set for groups and adhoc calls
if (isActive) {
innerContent = isInCall
? i18n('icu:CallsNewCallButton--return')
: i18n('icu:joinOngoingCall');
} else if (callType === CallType.Audio) {
innerContent = (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
);
} else {
innerContent = (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
);
}
const buttonContent = (
<button
type="button"
className="CallsNewCall__ItemActionButton"
aria-disabled={hasActiveCall}
className={classNames(
'CallsNewCall__ItemActionButton',
isActive ? 'CallsNewCall__ItemActionButton--join-call' : undefined,
isEnabled
? undefined
: 'CallsNewCall__ItemActionButton--join-call-disabled'
)}
aria-label={tooltipContent}
onClick={event => {
event.stopPropagation();
if (!hasActiveCall) {
onClick();
}
onClick();
}}
>
{callType === CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
)}
{callType !== CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
)}
{innerContent}
</button>
);
return tooltipContent === '' ? (
buttonContent
) : (
<Tooltip
className="CallsNewCall__ItemActionButtonTooltip"
content={tooltipContent}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(15)]}
>
{buttonContent}
</Tooltip>
);
}
export function CallsNewCall({
hasActiveCall,
allConversations,
i18n,
onSelectConversation,
onChangeCallsTabSelectedView,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
regionCode,
@ -89,11 +131,7 @@ export function CallsNewCall({
if (query === '') {
return activeConversations;
}
return filterAndSortConversationsByRecent(
activeConversations,
query,
regionCode
);
return filterAndSortConversations(activeConversations, query, regionCode);
}, [activeConversations, query, regionCode]);
const [groupConversations, directConversations] = useMemo(() => {
@ -177,13 +215,15 @@ export function CallsNewCall({
);
}
const isNewCallEnabled = !hasActiveCall;
return (
<div key={key} style={style}>
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPath={item.conversation.avatarPath}
avatarUrl={item.conversation.avatarUrl}
conversationType="group"
i18n={i18n}
isMe={false}
@ -199,24 +239,38 @@ export function CallsNewCall({
{item.conversation.type === 'direct' && (
<CallsNewCallButton
callType={CallType.Audio}
hasActiveCall={hasActiveCall}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
onOutgoingAudioCallInConversation(item.conversation.id);
if (isNewCallEnabled) {
onOutgoingAudioCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
)}
<CallsNewCallButton
// It's okay if this is a group
callType={CallType.Video}
hasActiveCall={hasActiveCall}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
onOutgoingVideoCallInConversation(item.conversation.id);
if (isNewCallEnabled) {
onOutgoingVideoCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
</div>
}
onClick={() => {
onSelectConversation(item.conversation.id);
onChangeCallsTabSelectedView({
type: 'conversation',
conversationId: item.conversation.id,
callHistoryGroup: null,
});
}}
/>
</div>
@ -226,7 +280,7 @@ export function CallsNewCall({
rows,
i18n,
hasActiveCall,
onSelectConversation,
onChangeCallsTabSelectedView,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
]
@ -248,7 +302,7 @@ export function CallsNewCall({
{query === '' ? (
i18n('icu:CallsNewCall__EmptyState--noQuery')
) : (
<Intl
<I18n
i18n={i18n}
id="icu:CallsNewCall__EmptyState--hasQuery"
components={{

View file

@ -13,11 +13,17 @@ import type {
} from '../types/CallDisposition';
import { CallsNewCall } from './CallsNewCall';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { ActiveCallStateType } from '../state/ducks/calling';
import type {
ActiveCallStateType,
PeekNotConnectedGroupCallType,
} from '../state/ducks/calling';
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { UnreadStats } from '../util/countUnreadStats';
import type { WidthBreakpoint } from './_util';
import type { CallLinkType } from '../types/CallLink';
import type { CallStateType } from '../state/selectors/calling';
import type { StartCallData } from './ConfirmLeaveCallModal';
enum CallsTabSidebarView {
CallsListView,
@ -36,7 +42,12 @@ 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;
getConversation: (id: string) => ConversationType | void;
hangUpActiveCall: (reason: string) => void;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
i18n: LocalizerType;
@ -44,9 +55,15 @@ 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;
preferredLeftPaneWidth: number;
renderCallLinkDetails: (
roomId: string,
callHistoryGroup: CallHistoryGroup
) => JSX.Element;
renderConversationDetails: (
conversationId: string,
callHistoryGroup: CallHistoryGroup | null
@ -56,8 +73,23 @@ type CallsTabProps = Readonly<{
}) => JSX.Element;
regionCode: string | undefined;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
startCallLinkLobbyByRoomId: (options: { roomId: string }) => void;
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
togglePip: () => void;
}>;
export type CallsTabSelectedView =
| {
type: 'conversation';
conversationId: string;
callHistoryGroup: CallHistoryGroup | null;
}
| {
type: 'callLink';
roomId: string;
callHistoryGroup: CallHistoryGroup;
};
export function CallsTab({
activeCall,
allConversations,
@ -65,7 +97,12 @@ export function CallsTab({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
canCreateCallLinks,
getAdhocCall,
getCall,
getCallLink,
getConversation,
hangUpActiveCall,
hasFailedStorySends,
hasPendingUpdate,
i18n,
@ -73,48 +110,48 @@ export function CallsTab({
onClearCallHistory,
onMarkCallHistoryRead,
onToggleNavTabsCollapse,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall,
preferredLeftPaneWidth,
renderCallLinkDetails,
renderConversationDetails,
renderToastManager,
regionCode,
savePreferredLeftPaneWidth,
startCallLinkLobbyByRoomId,
toggleConfirmLeaveCallModal,
togglePip,
}: CallsTabProps): JSX.Element {
const [sidebarView, setSidebarView] = useState(
CallsTabSidebarView.CallsListView
);
const [selected, setSelected] = useState<{
conversationId: string;
callHistoryGroup: CallHistoryGroup | null;
} | null>(null);
const [selectedView, setSelectedViewInner] =
useState<CallsTabSelectedView | null>(null);
const [selectedViewKey, setSelectedViewKey] = useState(() => 1);
const [
confirmClearCallHistoryDialogOpen,
setConfirmClearCallHistoryDialogOpen,
] = useState(false);
const updateSelectedView = useCallback(
(nextSelected: CallsTabSelectedView | null) => {
setSelectedViewInner(nextSelected);
setSelectedViewKey(key => key + 1);
},
[]
);
const updateSidebarView = useCallback(
(newSidebarView: CallsTabSidebarView) => {
setSidebarView(newSidebarView);
setSelected(null);
updateSelectedView(null);
},
[]
[updateSelectedView]
);
const handleSelectCallHistoryGroup = useCallback(
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
setSelected({
conversationId,
callHistoryGroup,
});
},
[]
);
const handleSelectConversation = useCallback((conversationId: string) => {
setSelected({ conversationId, callHistoryGroup: null });
}, []);
useEscapeHandling(
sidebarView === CallsTabSidebarView.NewCallView
? () => {
@ -148,12 +185,12 @@ export function CallsTab({
);
useEffect(() => {
if (selected?.callHistoryGroup != null) {
selected.callHistoryGroup.children.forEach(child => {
onMarkCallHistoryRead(selected.conversationId, child.callId);
if (selectedView?.type === 'conversation') {
selectedView.callHistoryGroup?.children.forEach(child => {
onMarkCallHistoryRead(selectedView.conversationId, child.callId);
});
}
}, [selected, onMarkCallHistoryRead]);
}, [selectedView, onMarkCallHistoryRead]);
return (
<>
@ -227,20 +264,30 @@ export function CallsTab({
{sidebarView === CallsTabSidebarView.CallsListView && (
<CallsList
key={CallsTabSidebarView.CallsListView}
hasActiveCall={activeCall != null}
activeCall={activeCall}
canCreateCallLinks={canCreateCallLinks}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
callHistoryEdition={callHistoryEdition}
getAdhocCall={getAdhocCall}
getCall={getCall}
getCallLink={getCallLink}
getConversation={getConversation}
hangUpActiveCall={hangUpActiveCall}
i18n={i18n}
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
onChangeCallsTabSelectedView={updateSelectedView}
onCreateCallLink={onCreateCallLink}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
handleOutgoingVideoCallInConversation
}
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
toggleConfirmLeaveCallModal={toggleConfirmLeaveCallModal}
togglePip={togglePip}
/>
)}
{sidebarView === CallsTabSidebarView.NewCallView && (
@ -250,7 +297,7 @@ export function CallsTab({
allConversations={allConversations}
i18n={i18n}
regionCode={regionCode}
onSelectConversation={handleSelectConversation}
onChangeCallsTabSelectedView={updateSelectedView}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}
@ -260,7 +307,7 @@ export function CallsTab({
/>
)}
</NavSidebar>
{selected == null ? (
{selectedView == null ? (
<div className="CallsTab__EmptyState">
<div className="CallsTab__EmptyStateIcon" />
<p className="CallsTab__EmptyStateLabel">
@ -270,13 +317,19 @@ export function CallsTab({
) : (
<div
className="CallsTab__ConversationCallDetails"
// Force scrolling to top when a new conversation is selected.
key={selected.conversationId}
// Force scrolling to top when selection changes
key={selectedViewKey}
>
{renderConversationDetails(
selected.conversationId,
selected.callHistoryGroup
)}
{selectedView.type === 'conversation' &&
renderConversationDetails(
selectedView.conversationId,
selectedView.callHistoryGroup
)}
{selectedView.type === 'callLink' &&
renderCallLinkDetails(
selectedView.roomId,
selectedView.callHistoryGroup
)}
</div>
)}
</div>

View file

@ -8,17 +8,20 @@ import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { Spinner } from './Spinner';
export type PropsType = {
export type PropsType = Readonly<{
i18n: LocalizerType;
isPending: boolean;
onContinue: () => void;
onSkip: () => void;
};
export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
const { i18n, isPending, onSkip, onContinue } = props;
}>;
export function CaptchaDialog({
i18n,
isPending,
onSkip,
onContinue,
}: PropsType): JSX.Element {
const [isClosing, setIsClosing] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);

View file

@ -397,9 +397,8 @@ function CustomColorBubble({
event.stopPropagation();
event.preventDefault();
const conversations = await getConversationsWithCustomColor(
colorId
);
const conversations =
await getConversationsWithCustomColor(colorId);
if (!conversations.length) {
onDelete();
} else {

View file

@ -1,11 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { forwardRef, useMemo } from 'react';
import { v4 as uuid } from 'uuid';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { Emojify } from './conversation/Emojify';
export type PropsType = {
checked?: boolean;
@ -15,7 +15,7 @@ export type PropsType = {
labelNode: JSX.Element;
checked?: boolean;
}) => JSX.Element;
description?: string;
description?: ReactNode;
disabled?: boolean;
isRadio?: boolean;
label: string;
@ -62,9 +62,7 @@ export const Checkbox = forwardRef(function CheckboxInner(
<div>
<label htmlFor={id}>
<div>{label}</div>
<div className={getClassName('__description')}>
<Emojify text={description ?? ''} />
</div>
<div className={getClassName('__description')}>{description}</div>
</label>
</div>
);

View file

@ -108,7 +108,7 @@ export default {
blockConversation: action('blockConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
deleteConversation: action('deleteConversation'),
title: '',
conversationName: getDefaultConversation(),
// GroupV1 Disabled Actions
showGV2MigrationDialog: action('showGV2MigrationDialog'),
// GroupV2

View file

@ -1,7 +1,7 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
@ -13,6 +13,7 @@ import type { LocalizerType, ThemeType } from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
import { RecordingState } from '../types/AudioRecorder';
import type { imageToBlurHash } from '../util/imageToBlurHash';
import { dropNull } from '../util/dropNull';
import { Spinner } from './Spinner';
import type {
Props as EmojiButtonProps,
@ -43,12 +44,14 @@ import type { AciString } from '../types/ServiceId';
import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload';
import type {
ConversationRemovalStage,
ConversationType,
PushPanelForConversationActionType,
ShowConversationType,
} from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { isSameLinkPreview } from '../types/message/LinkPreviews';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { MediaQualitySelector } from './MediaQualitySelector';
@ -71,18 +74,20 @@ import type { SmartCompositionRecordingProps } from '../state/smart/CompositionR
import SelectModeActions from './conversation/SelectModeActions';
import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
import { ForwardMessagesModalType } from './ForwardMessagesModal';
export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean;
removalStage?: 'justNotification' | 'messageRequest';
acceptedMessageRequest: boolean | null;
removalStage: ConversationRemovalStage | null;
addAttachment: (
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
announcementsOnly?: boolean;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
announcementsOnly: boolean | null;
areWeAdmin: boolean | null;
areWePending: boolean | null;
areWePendingApproval: boolean | null;
cancelRecording: () => unknown;
completeRecording: (
conversationId: string,
@ -93,29 +98,30 @@ export type OwnProps = Readonly<{
) => HydratedBodyRangesType | undefined;
conversationId: string;
discardEditMessage: (id: string) => unknown;
draftEditMessage?: DraftEditMessageType;
draftEditMessage: DraftEditMessageType | null;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number;
groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2;
groupVersion: 1 | 2 | null;
i18n: LocalizerType;
imageToBlurHash: typeof imageToBlurHash;
isDisabled: boolean;
isFetchingUUID?: boolean;
isFetchingUUID: boolean | null;
isFormattingEnabled: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSignalConversation?: boolean;
lastEditableMessageId?: string;
isGroupV1AndDisabled: boolean | null;
isMissingMandatoryProfileSharing: boolean | null;
isSignalConversation: boolean | null;
isActive: boolean;
lastEditableMessageId: string | null;
recordingState: RecordingState;
messageCompositionId: string;
shouldHidePopovers?: boolean;
isSMSOnly?: boolean;
left?: boolean;
shouldHidePopovers: boolean | null;
isSMSOnly: boolean | null;
left: boolean | null;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewType;
linkPreviewResult: LinkPreviewType | null;
onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown;
platform: string;
@ -149,15 +155,15 @@ export type OwnProps = Readonly<{
voiceNoteAttachment?: InMemoryAttachmentDraftType;
}
): unknown;
quotedMessageId?: string;
quotedMessageProps?: ReadonlyDeep<
quotedMessageId: string | null;
quotedMessageProps: null | ReadonlyDeep<
Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
>
>;
quotedMessageAuthorAci?: AciString;
quotedMessageSentAt?: number;
quotedMessageAuthorAci: AciString | null;
quotedMessageSentAt: number | null;
removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
@ -180,7 +186,7 @@ export type OwnProps = Readonly<{
selectedMessageIds: ReadonlyArray<string> | undefined;
toggleSelectMode: (on: boolean) => void;
toggleForwardMessagesModal: (
messageIds: ReadonlyArray<string>,
payload: ForwardMessagesPayload,
onForward: () => void
) => void;
}>;
@ -210,6 +216,7 @@ export type Props = Pick<
| 'blessedPacks'
| 'recentStickers'
| 'clearInstalledStickerPack'
| 'showIntroduction'
| 'clearShowIntroduction'
| 'showPickerHint'
| 'clearShowPickerHint'
@ -220,7 +227,7 @@ export type Props = Pick<
pushPanelForConversation: PushPanelForConversationActionType;
} & OwnProps;
export function CompositionArea({
export const CompositionArea = memo(function CompositionArea({
// Base props
addAttachment,
conversationId,
@ -232,6 +239,7 @@ export function CompositionArea({
imageToBlurHash,
isDisabled,
isSignalConversation,
isActive,
lastEditableMessageId,
messageCompositionId,
pushPanelForConversation,
@ -291,6 +299,7 @@ export function CompositionArea({
recentStickers,
clearInstalledStickerPack,
sendStickerMessage,
showIntroduction,
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
@ -301,14 +310,18 @@ export function CompositionArea({
conversationType,
groupVersion,
isBlocked,
isHidden,
isReported,
isMissingMandatoryProfileSharing,
left,
removalStage,
acceptConversation,
blockConversation,
reportSpam,
blockAndReportSpam,
deleteConversation,
title,
conversationName,
addedByName,
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
showGV2MigrationDialog,
@ -347,8 +360,29 @@ export function CompositionArea({
const draftEditMessageBody = draftEditMessage?.body;
const editedMessageId = draftEditMessage?.targetMessageId;
const canSend =
// Text or link preview edited
dirty ||
// Quote of edited message changed
(draftEditMessage != null &&
dropNull(draftEditMessage.quote?.messageId) !==
dropNull(quotedMessageId)) ||
// Link preview of edited message changed
(draftEditMessage != null &&
!isSameLinkPreview(linkPreviewResult, draftEditMessage?.preview)) ||
// Not edit message, but has attachments
(draftEditMessage == null && draftAttachments.length !== 0);
const handleSubmit = useCallback(
(message: string, bodyRanges: DraftBodyRanges, timestamp: number) => {
(
message: string,
bodyRanges: DraftBodyRanges,
timestamp: number
): boolean => {
if (!canSend) {
return false;
}
emojiButtonRef.current?.close();
if (editedMessageId) {
@ -356,8 +390,8 @@ export function CompositionArea({
bodyRanges,
message,
// sent timestamp for the quote
quoteSentAt: quotedMessageSentAt,
quoteAuthorAci: quotedMessageAuthorAci,
quoteSentAt: quotedMessageSentAt ?? undefined,
quoteAuthorAci: quotedMessageAuthorAci ?? undefined,
targetMessageId: editedMessageId,
});
} else {
@ -369,9 +403,12 @@ export function CompositionArea({
});
}
setLarge(false);
return true;
},
[
conversationId,
canSend,
draftAttachments,
editedMessageId,
quotedMessageSentAt,
@ -504,7 +541,7 @@ export function CompositionArea({
inputApiRef.current?.setContents(
draftEditMessageBody ?? '',
draftBodyRanges,
draftBodyRanges ?? undefined,
true
);
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
@ -520,7 +557,11 @@ export function CompositionArea({
return;
}
inputApiRef.current?.setContents(draftText, draftBodyRanges, true);
inputApiRef.current?.setContents(
draftText,
draftBodyRanges ?? undefined,
true
);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]);
const handleToggleLarge = useCallback(() => {
@ -582,6 +623,7 @@ export function CompositionArea({
<button
aria-label={i18n('icu:CompositionArea__edit-action--send')}
className="CompositionArea__edit-button CompositionArea__edit-button--accept"
disabled={!canSend}
onClick={() => inputApiRef.current?.submit()}
type="button"
/>
@ -637,6 +679,7 @@ export function CompositionArea({
onPickSticker={(packId, stickerId) =>
sendStickerMessage(conversationId, { packId, stickerId })
}
showIntroduction={showIntroduction}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
@ -718,9 +761,15 @@ export function CompositionArea({
}}
onForwardMessages={() => {
if (selectedMessageIds.length > 0) {
toggleForwardMessagesModal(selectedMessageIds, () => {
toggleSelectMode(false);
});
toggleForwardMessagesModal(
{
type: ForwardMessagesModalType.Forward,
messageIds: selectedMessageIds,
},
() => {
toggleSelectMode(false);
}
);
}
}}
showToast={showToast}
@ -735,16 +784,19 @@ export function CompositionArea({
) {
return (
<MessageRequestActions
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId}
addedByName={addedByName}
conversationType={conversationType}
deleteConversation={deleteConversation}
conversationId={conversationId}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isHidden={removalStage !== undefined}
title={title}
isHidden={isHidden}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
@ -788,14 +840,18 @@ export function CompositionArea({
) {
return (
<MandatoryProfileSharingActions
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
deleteConversation={deleteConversation}
conversationName={conversationName}
i18n={i18n}
title={title}
isBlocked={isBlocked}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
@ -979,6 +1035,7 @@ export function CompositionArea({
i18n={i18n}
inputApi={inputApiRef}
isFormattingEnabled={isFormattingEnabled}
isActive={isActive}
large={large}
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult}
@ -993,7 +1050,7 @@ export function CompositionArea({
platform={platform}
sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
skinTone={skinTone}
skinTone={skinTone ?? null}
sortedGroupMembers={sortedGroupMembers}
theme={theme}
/>
@ -1031,4 +1088,4 @@ export function CompositionArea({
/>
</div>
);
}
});

View file

@ -21,30 +21,39 @@ export default {
args: {},
} satisfies Meta<Props>;
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
disabled: overrideProps.disabled ?? false,
draftText: overrideProps.draftText || undefined,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
large: overrideProps.large ?? false,
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
skinTone: overrideProps.skinTone ?? undefined,
theme: React.useContext(StorybookThemeContext),
});
const useProps = (overrideProps: Partial<Props> = {}): Props => {
const conversation = getDefaultConversation();
return {
i18n,
conversationId: conversation.id,
disabled: overrideProps.disabled ?? false,
draftText: overrideProps.draftText ?? null,
draftEditMessage: overrideProps.draftEditMessage ?? null,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
isActive: true,
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
large: overrideProps.large ?? false,
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
skinTone: overrideProps.skinTone ?? null,
theme: React.useContext(StorybookThemeContext),
inputApi: null,
shouldHidePopovers: null,
linkPreviewResult: null,
};
};
export function Default(): JSX.Element {
const props = useProps();

View file

@ -22,7 +22,12 @@ import type {
HydratedBodyRangesType,
RangeNode,
} from '../types/BodyRange';
import { BodyRange, collapseRangeTree, insertRange } from '../types/BodyRange';
import {
BodyRange,
areBodyRangesEqual,
collapseRangeTree,
insertRange,
} from '../types/BodyRange';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
@ -52,6 +57,7 @@ import { isNotNil } from '../util/isNotNil';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { useRefMerger } from '../hooks/useRefMerger';
import { useEmojiSearch } from '../hooks/useEmojiSearch';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d';
@ -96,25 +102,26 @@ export type InputApi = {
export type Props = Readonly<{
children?: React.ReactNode;
conversationId?: string;
conversationId: string | null;
i18n: LocalizerType;
disabled?: boolean;
draftEditMessage?: DraftEditMessageType;
draftEditMessage: DraftEditMessageType | null;
getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>;
large: boolean | null;
inputApi: React.MutableRefObject<InputApi | undefined> | null;
isFormattingEnabled: boolean;
isActive: boolean;
sendCounter: number;
skinTone?: EmojiPickDataType['skinTone'];
draftText?: string;
draftBodyRanges?: HydratedBodyRangesType;
skinTone: NonNullable<EmojiPickDataType['skinTone']> | null;
draftText: string | null;
draftBodyRanges: HydratedBodyRangesType | null;
moduleClassName?: string;
theme: ThemeType;
placeholder?: string;
sortedGroupMembers?: ReadonlyArray<ConversationType>;
sortedGroupMembers: ReadonlyArray<ConversationType> | null;
scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(options: {
onEditorStateChange(options: {
bodyRanges: DraftBodyRanges;
caretLocation?: number;
conversationId: string | undefined;
@ -132,11 +139,11 @@ export type Props = Readonly<{
): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
platform: string;
shouldHidePopovers?: boolean;
shouldHidePopovers: boolean | null;
getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown;
linkPreviewLoading?: boolean;
linkPreviewResult?: LinkPreviewType;
linkPreviewResult: LinkPreviewType | null;
onCloseLinkPreview?(conversationId: string): unknown;
}>;
@ -157,6 +164,7 @@ export function CompositionInput(props: Props): React.ReactElement {
i18n,
inputApi,
isFormattingEnabled,
isActive,
large,
linkPreviewLoading,
linkPreviewResult,
@ -356,7 +364,11 @@ export function CompositionInput(props: Props): React.ReactElement {
`CompositionInput: Submitting message ${timestamp} with ${bodyRanges.length} ranges`
);
canSendRef.current = false;
onSubmit(text, bodyRanges, timestamp);
const didSend = onSubmit(text, bodyRanges, timestamp);
if (!didSend) {
canSendRef.current = true;
}
};
if (inputApi) {
@ -408,9 +420,14 @@ export function CompositionInput(props: Props): React.ReactElement {
isMouseDown,
previousFormattingEnabled,
previousIsMouseDown,
quillRef,
]);
React.useEffect(() => {
quillRef.current?.getModule('signalClipboard').updateOptions({
isDisabled: !isActive,
});
}, [isActive]);
const onEnter = (): boolean => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
@ -562,7 +579,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onEditorStateChange({
bodyRanges,
caretLocation: selection ? selection.index : undefined,
conversationId,
conversationId: conversationId ?? undefined,
messageText: text,
sendCounter,
});
@ -571,7 +588,19 @@ export function CompositionInput(props: Props): React.ReactElement {
}
if (propsRef.current.onDirtyChange) {
propsRef.current.onDirtyChange(text.length > 0);
let isDirty: boolean = false;
if (!draftEditMessage) {
isDirty = text.length > 0;
} else if (text.trimEnd() !== draftEditMessage.body.trimEnd()) {
isDirty = true;
} else if (bodyRanges.length !== draftEditMessage.bodyRanges?.length) {
isDirty = true;
} else if (!areBodyRangesEqual(bodyRanges, draftEditMessage.bodyRanges)) {
isDirty = true;
}
propsRef.current.onDirtyChange(isDirty);
}
};
@ -612,7 +641,7 @@ export function CompositionInput(props: Props): React.ReactElement {
React.useEffect(() => {
const emojiCompletion = emojiCompletionRef.current;
if (emojiCompletion === undefined || skinTone === undefined) {
if (emojiCompletion == null || skinTone == null) {
return;
}
@ -688,6 +717,8 @@ export function CompositionInput(props: Props): React.ReactElement {
const callbacksRef = React.useRef(unstaleCallbacks);
callbacksRef.current = unstaleCallbacks;
const search = useEmojiSearch(i18n.getLocale());
const reactQuill = React.useMemo(
() => {
const delta = generateDelta(draftText || '', draftBodyRanges || []);
@ -699,7 +730,9 @@ export function CompositionInput(props: Props): React.ReactElement {
defaultValue={delta}
modules={{
toolbar: false,
signalClipboard: true,
signalClipboard: {
isDisabled: !isActive,
},
clipboard: {
matchers: [
['IMG', matchEmojiImage],
@ -739,6 +772,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onPickEmoji: (emoji: EmojiPickDataType) =>
callbacksRef.current.onPickEmoji(emoji),
skinTone,
search,
},
autoSubstituteAsciiEmojis: {
skinTone,

View file

@ -45,12 +45,12 @@ export function Default(): JSX.Element {
{active && (
<CompositionRecording
i18n={i18n}
conversationId="convo-id"
onCancel={handleCancel}
onSend={handleSend}
errorRecording={_ => action('error')()}
addAttachment={action('addAttachment')}
completeRecording={action('completeRecording')}
saveDraftRecordingIfNeeded={action('saveDraftRecordingIfNeeded')}
showToast={action('showToast')}
hideToast={action('hideToast')}
/>

View file

@ -2,9 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { usePrevious } from '../hooks/usePrevious';
import type { HideToastAction, ShowToastAction } from '../state/ducks/toast';
import type { InMemoryAttachmentDraftType } from '../types/Attachment';
import { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
@ -18,7 +17,6 @@ import { RecordingComposer } from './RecordingComposer';
export type Props = {
i18n: LocalizerType;
conversationId: string;
onCancel: () => void;
onSend: () => void;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
@ -31,47 +29,30 @@ export type Props = {
conversationId: string,
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown;
saveDraftRecordingIfNeeded: () => void;
showToast: ShowToastAction;
hideToast: HideToastAction;
};
export function CompositionRecording({
i18n,
conversationId,
onCancel,
onSend,
errorRecording,
errorDialogAudioRecorderType,
addAttachment,
completeRecording,
saveDraftRecordingIfNeeded,
showToast,
hideToast,
}: Props): JSX.Element {
useEscapeHandling(onCancel);
// when interrupted (blur, switching convos)
// stop recording and save draft
const handleRecordingInterruption = useCallback(() => {
completeRecording(conversationId, attachment => {
addAttachment(conversationId, attachment);
});
}, [conversationId, completeRecording, addAttachment]);
// switched to another app
useEffect(() => {
window.addEventListener('blur', handleRecordingInterruption);
window.addEventListener('blur', saveDraftRecordingIfNeeded);
return () => {
window.removeEventListener('blur', handleRecordingInterruption);
window.removeEventListener('blur', saveDraftRecordingIfNeeded);
};
}, [handleRecordingInterruption]);
// switched conversations
const previousConversationId = usePrevious(conversationId, conversationId);
useEffect(() => {
if (previousConversationId !== conversationId) {
handleRecordingInterruption();
}
});
}, [saveDraftRecordingIfNeeded]);
useEffect(() => {
const toast: AnyToast = { toastType: ToastType.VoiceNoteLimit };

View file

@ -19,8 +19,9 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType;
bodyRanges: HydratedBodyRangesType | null;
i18n: LocalizerType;
isActive: boolean;
isFormattingEnabled: boolean;
maxLength?: number;
placeholder?: string;
@ -58,6 +59,7 @@ export function CompositionTextArea({
draftText,
getPreferredBadge,
i18n,
isActive,
isFormattingEnabled,
maxLength,
onChange,
@ -139,6 +141,7 @@ export function CompositionTextArea({
getPreferredBadge={getPreferredBadge}
getQuotedMessage={noop}
i18n={i18n}
isActive={isActive}
isFormattingEnabled={isFormattingEnabled}
inputApi={inputApiRef}
large
@ -153,6 +156,17 @@ export function CompositionTextArea({
scrollerRef={scrollerRef}
sendCounter={0}
theme={theme}
skinTone={skinTone ?? null}
// These do not apply in the forward modal because there isn't
// strictly one conversation
conversationId={null}
sortedGroupMembers={null}
// we don't edit in this context
draftEditMessage={null}
// rendered in the forward modal
linkPreviewResult={null}
// Panels appear behind this modal
shouldHidePopovers={null}
/>
<div className="CompositionTextArea__emoji">
<EmojiButton

View file

@ -0,0 +1,59 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { LocalizerType } from '../types/Util';
import type {
StartCallingLobbyType,
StartCallLinkLobbyByRoomIdType,
StartCallLinkLobbyType,
} from '../state/ducks/calling';
export type StartCallData =
| ({
type: 'conversation';
} & StartCallingLobbyType)
| ({ type: 'adhoc-roomId' } & StartCallLinkLobbyByRoomIdType)
| ({ type: 'adhoc-rootKey' } & StartCallLinkLobbyType);
type HousekeepingProps = {
i18n: LocalizerType;
};
type DispatchProps = {
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
leaveCurrentCallAndStartCallingLobby: (options: StartCallData) => void;
};
export type Props = { data: StartCallData } & HousekeepingProps & DispatchProps;
export function ConfirmLeaveCallModal({
i18n,
data,
leaveCurrentCallAndStartCallingLobby,
toggleConfirmLeaveCallModal,
}: Props): JSX.Element | null {
return (
<ConfirmationDialog
dialogName="GroupCallRemoteParticipant.blockInfo"
cancelText={i18n('icu:cancel')}
i18n={i18n}
onClose={() => {
toggleConfirmLeaveCallModal(null);
}}
title={i18n('icu:CallsList__LeaveCallDialogTitle')}
actions={[
{
text: i18n('icu:CallsList__LeaveCallDialogButton--leave'),
style: 'affirmative',
action: () => {
leaveCurrentCallAndStartCallingLobby(data);
},
},
]}
>
{i18n('icu:CallsList__LeaveCallDialogBody')}
</ConfirmationDialog>
);
}

View file

@ -129,7 +129,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
<Button
key={
typeof action.text === 'string'
? action.id ?? action.text
? (action.id ?? action.text)
: action.id
}
disabled={action.disabled || isSpinning}

View file

@ -15,7 +15,7 @@ export type PropsType = {
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'firstName'
| 'id'
@ -24,12 +24,12 @@ export type PropsType = {
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
| 'unblurredAvatarUrl'
>;
export function ContactPill({
acceptedMessageRequest,
avatarPath,
avatarUrl,
color,
firstName,
i18n,
@ -39,7 +39,7 @@ export function ContactPill({
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
unblurredAvatarUrl,
onClickRemove,
}: PropsType): JSX.Element {
const removeLabel = i18n('icu:ContactPill--remove');
@ -48,7 +48,7 @@ export function ContactPill({
<div className="module-ContactPill">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
badge={undefined}
color={color}
noteToSelf={false}
@ -60,7 +60,7 @@ export function ContactPill({
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY}
unblurredAvatarPath={unblurredAvatarPath}
unblurredAvatarUrl={unblurredAvatarUrl}
/>
<ContactName
firstName={firstName}

View file

@ -38,7 +38,7 @@ const contactPillProps = (
): ContactPillPropsType => ({
...(overrideProps ??
getDefaultConversation({
avatarPath: gifUrl,
avatarUrl: gifUrl,
firstName: 'John',
id: 'abc123',
isMe: false,

View file

@ -140,6 +140,20 @@ export function ContactDirect(): JSX.Element {
);
}
export function ContactInSystemContacts(): JSX.Element {
const contact = defaultConversations[0];
return (
<Wrapper
rows={[
{
type: RowType.Contact,
contact: { ...contact, systemGivenName: contact.title },
},
]}
/>
);
}
export function ContactDirectWithContextMenu(): JSX.Element {
return (
<Wrapper
@ -261,7 +275,7 @@ const createConversation = (
: true,
badges: [],
isMe: overrideProps.isMe ?? false,
avatarPath: overrideProps.avatarPath ?? '',
avatarUrl: overrideProps.avatarUrl ?? '',
id: overrideProps.id || '',
isSelected: overrideProps.isSelected ?? false,
title: overrideProps.title ?? 'Some Person',
@ -294,7 +308,7 @@ export const ConversationName = (): JSX.Element => renderConversation();
export const ConversationNameAndAvatar = (): JSX.Element =>
renderConversation({
avatarPath: '/fixtures/kitten-1-64-64.jpg',
avatarUrl: '/fixtures/kitten-1-64-64.jpg',
});
export const ConversationWithYourself = (): JSX.Element =>

View file

@ -371,12 +371,13 @@ export function ConversationList({
case RowType.Conversation: {
const itemProps = pick(row.conversation, [
'acceptedMessageRequest',
'avatarPath',
'avatarUrl',
'badges',
'color',
'draftPreview',
'groupId',
'id',
'isBlocked',
'isMe',
'isSelected',
'isPinned',
@ -392,7 +393,7 @@ export function ConversationList({
'title',
'type',
'typingContactIdTimestamps',
'unblurredAvatarPath',
'unblurredAvatarUrl',
'unreadCount',
'unreadMentionsCount',
'serviceId',

View file

@ -18,9 +18,12 @@ export type PropsType = {
isPending: boolean;
} & PropsActionsType;
export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
const { i18n, isPending, writeCrashReportsToLog, eraseCrashReports } = props;
export function CrashReportDialog({
i18n,
isPending,
writeCrashReportsToLog,
eraseCrashReports,
}: Readonly<PropsType>): JSX.Element {
const onEraseClick = (event: React.MouseEvent) => {
event.preventDefault();

View file

@ -193,6 +193,7 @@ export function CustomizingPreferredReactionsModal({
onClose={() => {
deselectDraftEmoji();
}}
wasInvokedFromKeyboard={false}
/>
</div>
)}

View file

@ -12,7 +12,6 @@ import * as log from '../logging/log';
import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner';
import { ToastManager } from './ToastManager';
import { WidthBreakpoint } from './_util';
import { createSupportUrl } from '../util/createSupportUrl';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
@ -157,7 +156,8 @@ export function DebugLogWindow({
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
containerWidthBreakpoint={null}
isInFullScreenCall={false}
/>
</div>
);
@ -213,7 +213,8 @@ export function DebugLogWindow({
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
containerWidthBreakpoint={null}
isInFullScreenCall={false}
/>
</div>
);

View file

@ -0,0 +1,78 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import DeleteMessagesModal from './DeleteMessagesModal';
import type { DeleteMessagesModalProps } from './DeleteMessagesModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/DeleteMessagesModal',
component: DeleteMessagesModal,
args: {
i18n,
isMe: false,
isDeleteSyncSendEnabled: false,
canDeleteForEveryone: true,
messageCount: 1,
onClose: action('onClose'),
onDeleteForMe: action('onDeleteForMe'),
onDeleteForEveryone: action('onDeleteForEveryone'),
showToast: action('showToast'),
},
} satisfies Meta<DeleteMessagesModalProps>;
function createProps(args: Partial<DeleteMessagesModalProps>) {
return {
i18n,
isMe: false,
isDeleteSyncSendEnabled: false,
canDeleteForEveryone: true,
messageCount: 1,
onClose: action('onClose'),
onDeleteForMe: action('onDeleteForMe'),
onDeleteForEveryone: action('onDeleteForEveryone'),
showToast: action('showToast'),
...args,
};
}
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<DeleteMessagesModalProps> = args => {
return <DeleteMessagesModal {...args} />;
};
export const OneMessage = Template.bind({});
export const ThreeMessages = Template.bind({});
ThreeMessages.args = createProps({
messageCount: 3,
});
export const IsMe = Template.bind({});
IsMe.args = createProps({
isMe: true,
});
export const IsMeThreeMessages = Template.bind({});
IsMeThreeMessages.args = createProps({
isMe: true,
messageCount: 3,
});
export const DeleteSyncEnabled = Template.bind({});
DeleteSyncEnabled.args = createProps({
isDeleteSyncSendEnabled: true,
});
export const IsMeDeleteSyncEnabled = Template.bind({});
IsMeDeleteSyncEnabled.args = createProps({
isDeleteSyncSendEnabled: true,
isMe: true,
});

View file

@ -8,8 +8,9 @@ import type { LocalizerType } from '../types/Util';
import type { ShowToastAction } from '../state/ducks/toast';
import { ToastType } from '../types/Toast';
type DeleteMessagesModalProps = Readonly<{
export type DeleteMessagesModalProps = Readonly<{
isMe: boolean;
isDeleteSyncSendEnabled: boolean;
canDeleteForEveryone: boolean;
i18n: LocalizerType;
messageCount: number;
@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30;
export default function DeleteMessagesModal({
isMe,
isDeleteSyncSendEnabled,
canDeleteForEveryone,
i18n,
messageCount,
@ -33,15 +35,22 @@ export default function DeleteMessagesModal({
}: DeleteMessagesModalProps): JSX.Element {
const actions: Array<ActionSpec> = [];
const syncNoteToSelfDelete = isMe && isDeleteSyncSendEnabled;
let deleteForMeText = i18n('icu:DeleteMessagesModal--deleteForMe');
if (syncNoteToSelfDelete) {
deleteForMeText = i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync');
} else if (isMe) {
deleteForMeText = i18n('icu:DeleteMessagesModal--deleteFromThisDevice');
}
actions.push({
action: onDeleteForMe,
style: 'negative',
text: isMe
? i18n('icu:DeleteMessagesModal--deleteFromThisDevice')
: i18n('icu:DeleteMessagesModal--deleteForMe'),
text: deleteForMeText,
});
if (canDeleteForEveryone) {
if (canDeleteForEveryone && !syncNoteToSelfDelete) {
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
actions.push({
'aria-disabled': tooManyMessages,
@ -63,6 +72,20 @@ export default function DeleteMessagesModal({
});
}
let descriptionText = i18n('icu:DeleteMessagesModal--description', {
count: messageCount,
});
if (syncNoteToSelfDelete) {
descriptionText = i18n(
'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
{ count: messageCount }
);
} else if (isMe) {
descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', {
count: messageCount,
});
}
return (
<ConfirmationDialog
actions={actions}
@ -74,13 +97,7 @@ export default function DeleteMessagesModal({
})}
moduleClassName="DeleteMessagesModal"
>
{isMe
? i18n('icu:DeleteMessagesModal--description--noteToSelf', {
count: messageCount,
})
: i18n('icu:DeleteMessagesModal--description', {
count: messageCount,
})}
{descriptionText}
</ConfirmationDialog>
);
}

View file

@ -20,6 +20,7 @@ const defaultProps = {
hasNetworkDialog: true,
i18n,
isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING,
manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false,
@ -54,6 +55,7 @@ KnobsPlayground.args = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
hasNetworkDialog: true,
isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING,
};
@ -105,6 +107,19 @@ export function OfflineWide(): JSX.Element {
);
}
export function OutageWide(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Wide}>
<DialogNetworkStatus
{...defaultProps}
containerWidthBreakpoint={WidthBreakpoint.Wide}
isOnline={false}
isOutage
/>
</FakeLeftPaneContainer>
);
}
export function ConnectingNarrow(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
@ -152,3 +167,16 @@ export function OfflineNarrow(): JSX.Element {
</FakeLeftPaneContainer>
);
}
export function OutageNarrow(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
<DialogNetworkStatus
{...defaultProps}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
isOnline={false}
isOutage
/>
</FakeLeftPaneContainer>
);
}

View file

@ -13,7 +13,10 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
const FIVE_SECONDS = 5 * 1000;
export type PropsType = Pick<NetworkStateType, 'isOnline' | 'socketStatus'> & {
export type PropsType = Pick<
NetworkStateType,
'isOnline' | 'isOutage' | 'socketStatus'
> & {
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
manualReconnect: () => void;
@ -23,6 +26,7 @@ export function DialogNetworkStatus({
containerWidthBreakpoint,
i18n,
isOnline,
isOutage,
socketStatus,
manualReconnect,
}: PropsType): JSX.Element | null {
@ -48,6 +52,17 @@ export function DialogNetworkStatus({
manualReconnect();
};
if (isOutage) {
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="error"
subtitle={i18n('icu:DialogNetworkStatus__outage')}
/>
);
}
if (isConnecting) {
const spinner = (
<div className="LeftPaneDialog__spinner-container">

View file

@ -1,16 +1,29 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import formatFileSize from 'filesize';
import type { ReactNode } from 'react';
import React, { useCallback } from 'react';
import { isBeta } from '../util/version';
import { DialogType } from '../types/Dialogs';
import type { LocalizerType } from '../types/Util';
import { PRODUCTION_DOWNLOAD_URL, BETA_DOWNLOAD_URL } from '../types/support';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { LeftPaneDialog } from './LeftPaneDialog';
import type { WidthBreakpoint } from './_util';
import { formatFileSize } from '../util/formatFileSize';
function contactSupportLink(parts: ReactNode): JSX.Element {
return (
<a
key="signal-support"
href="https://support.signal.org/hc/en-us/requests/new?desktop"
rel="noreferrer"
target="_blank"
>
{parts}
</a>
);
}
export type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
@ -37,6 +50,22 @@ export function DialogUpdate({
version,
currentVersion,
}: PropsType): JSX.Element | null {
const retryUpdateButton = useCallback(
(parts: ReactNode): JSX.Element => {
return (
<button
className="LeftPaneDialog__retry"
key="signal-retry"
onClick={startUpdate}
type="button"
>
{parts}
</button>
);
},
[startUpdate]
);
if (dialogType === DialogType.Cannot_Update) {
const url = isBeta(currentVersion)
? BETA_DOWNLOAD_URL
@ -48,18 +77,9 @@ export function DialogUpdate({
title={i18n('icu:cannotUpdate')}
>
<span>
<Intl
<I18n
components={{
retry: (
<button
className="LeftPaneDialog__retry"
key="signal-retry"
onClick={startUpdate}
type="button"
>
{i18n('icu:autoUpdateRetry')}
</button>
),
retryUpdateButton,
url: (
<a
key="signal-download"
@ -70,19 +90,10 @@ export function DialogUpdate({
{url}
</a>
),
support: (
<a
key="signal-support"
href="https://support.signal.org/hc/en-us/requests/new?desktop"
rel="noreferrer"
target="_blank"
>
{i18n('icu:autoUpdateContactSupport')}
</a>
),
contactSupportLink,
}}
i18n={i18n}
id="icu:cannotUpdateDetail"
id="icu:cannotUpdateDetail-v2"
/>
</span>
</LeftPaneDialog>
@ -100,7 +111,7 @@ export function DialogUpdate({
title={i18n('icu:cannotUpdate')}
>
<span>
<Intl
<I18n
components={{
url: (
<a
@ -112,19 +123,10 @@ export function DialogUpdate({
{url}
</a>
),
support: (
<a
key="signal-support"
href="https://support.signal.org/hc/en-us/requests/new?desktop"
rel="noreferrer"
target="_blank"
>
{i18n('icu:autoUpdateContactSupport')}
</a>
),
contactSupportLink,
}}
i18n={i18n}
id="icu:cannotUpdateRequireManualDetail"
id="icu:cannotUpdateRequireManualDetail-v2"
/>
</span>
</LeftPaneDialog>
@ -142,7 +144,7 @@ export function DialogUpdate({
type="warning"
>
<span>
<Intl
<I18n
components={{
app: <strong key="app">Signal.app</strong>,
folder: <strong key="folder">/Applications</strong>,
@ -195,7 +197,7 @@ export function DialogUpdate({
(dialogType === DialogType.DownloadReady ||
dialogType === DialogType.FullDownloadReady)
) {
title += ` (${formatFileSize(downloadSize, { round: 0 })})`;
title += ` (${formatFileSize(downloadSize)})`;
}
let clickLabel = i18n('icu:autoUpdateNewVersionMessage');

View file

@ -52,7 +52,7 @@ function renderAvatar(
i18n: LocalizerType,
{
acceptedMessageRequest,
avatarPath,
avatarUrl,
color,
isMe,
phoneNumber,
@ -62,7 +62,7 @@ function renderAvatar(
}: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'isMe'
| 'phoneNumber'
@ -73,10 +73,10 @@ function renderAvatar(
): JSX.Element {
return (
<div className="module-ongoing-call__remote-video-disabled">
<CallBackgroundBlur avatarPath={avatarPath}>
<CallBackgroundBlur avatarUrl={avatarUrl}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}

View file

@ -23,7 +23,7 @@ export type PropsType = Readonly<{
const UNITS = ['seconds', 'minutes', 'hours', 'days', 'weeks'] as const;
export type Unit = typeof UNITS[number];
export type Unit = (typeof UNITS)[number];
const UNIT_TO_SEC = new Map<Unit, number>([
['seconds', 1],

View file

@ -0,0 +1,32 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { action } from '@storybook/addon-actions';
import * as React from 'react';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import type { EditNicknameAndNoteModalProps } from './EditNicknameAndNoteModal';
import { EditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/EditNicknameAndNoteModal',
component: EditNicknameAndNoteModal,
argTypes: {},
args: {
conversation: getDefaultConversation({
nicknameGivenName: 'Bestie',
nicknameFamilyName: 'McBesterson',
note: 'Met at UC Berkeley, mutual friends with Katie Hall.\n\nWebsite: https://example.com/',
}),
i18n,
onClose: action('onClose'),
onSave: action('onSave'),
},
} satisfies ComponentMeta<EditNicknameAndNoteModalProps>;
export function Normal(args: EditNicknameAndNoteModalProps): JSX.Element {
return <EditNicknameAndNoteModal {...args} />;
}

View file

@ -0,0 +1,190 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FormEvent } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import uuid from 'uuid';
import { z } from 'zod';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
import { Avatar, AvatarSize } from './Avatar';
import type {
ConversationType,
NicknameAndNote,
} from '../state/ducks/conversations';
import { Input } from './Input';
import { AutoSizeTextArea } from './AutoSizeTextArea';
import { Button, ButtonVariant } from './Button';
import { strictAssert } from '../util/assert';
const formSchema = z.object({
nickname: z
.object({
givenName: z.string().nullable(),
familyName: z.string().nullable(),
})
.nullable(),
note: z.string().nullable(),
});
function toOptionalStringValue(value: string): string | null {
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
export type EditNicknameAndNoteModalProps = Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
onSave: (result: NicknameAndNote) => void;
onClose: () => void;
}>;
export function EditNicknameAndNoteModal({
conversation,
i18n,
onSave,
onClose,
}: EditNicknameAndNoteModalProps): JSX.Element {
strictAssert(
conversation.type === 'direct',
'Expected a direct conversation'
);
const [givenName, setGivenName] = useState(
conversation.nicknameGivenName ?? ''
);
const [familyName, setFamilyName] = useState(
conversation.nicknameFamilyName ?? ''
);
const [note, setNote] = useState(conversation.note ?? '');
const [formId] = useState(() => uuid());
const [givenNameId] = useState(() => uuid());
const [familyNameId] = useState(() => uuid());
const [noteId] = useState(() => uuid());
const formResult = useMemo(() => {
const givenNameValue = toOptionalStringValue(givenName);
const familyNameValue = toOptionalStringValue(familyName);
const noteValue = toOptionalStringValue(note);
const hasEitherName = givenNameValue != null || familyNameValue != null;
return formSchema.safeParse({
nickname: hasEitherName
? { givenName: givenNameValue, familyName: familyNameValue }
: null,
note: noteValue,
});
}, [givenName, familyName, note]);
const handleSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (formResult.success) {
onSave(formResult.data);
onClose();
}
},
[formResult, onSave, onClose]
);
return (
<Modal
modalName="EditNicknameAndNoteModal"
moduleClassName="EditNicknameAndNoteModal"
i18n={i18n}
onClose={onClose}
title={i18n('icu:EditNicknameAndNoteModal__Title')}
hasXButton
modalFooter={
<>
<Button variant={ButtonVariant.Secondary} onClick={onClose}>
{i18n('icu:cancel')}
</Button>
<Button
variant={ButtonVariant.Primary}
type="submit"
form={formId}
aria-disabled={!formResult.success}
>
{i18n('icu:save')}
</Button>
</>
}
>
<p className="EditNicknameAndNoteModal__Description">
{i18n('icu:EditNicknameAndNoteModal__Description')}
</p>
<div className="EditNicknameAndNoteModal__Avatar">
<Avatar
{...conversation}
conversationType={conversation.type}
i18n={i18n}
size={AvatarSize.EIGHTY}
badge={undefined}
theme={undefined}
/>
</div>
<form id={formId} onSubmit={handleSubmit}>
<label
htmlFor={givenNameId}
className="EditNicknameAndNoteModal__Label"
>
{i18n('icu:EditNicknameAndNoteModal__FirstName__Label')}
</label>
<Input
id={givenNameId}
i18n={i18n}
placeholder={i18n(
'icu:EditNicknameAndNoteModal__FirstName__Placeholder'
)}
value={givenName}
hasClearButton
maxLengthCount={26}
maxByteCount={128}
onChange={value => {
setGivenName(value);
}}
/>
<label
htmlFor={familyNameId}
className="EditNicknameAndNoteModal__Label"
>
{i18n('icu:EditNicknameAndNoteModal__LastName__Label')}
</label>
<Input
id={familyNameId}
i18n={i18n}
placeholder={i18n(
'icu:EditNicknameAndNoteModal__LastName__Placeholder'
)}
value={familyName}
hasClearButton
maxLengthCount={26}
maxByteCount={128}
onChange={value => {
setFamilyName(value);
}}
/>
<label htmlFor={noteId} className="EditNicknameAndNoteModal__Label">
{i18n('icu:EditNicknameAndNoteModal__Note__Label')}
</label>
<AutoSizeTextArea
i18n={i18n}
id={noteId}
placeholder={i18n('icu:EditNicknameAndNoteModal__Note__Placeholder')}
value={note}
maxByteCount={240}
maxLengthCount={240}
whenToShowRemainingCount={140}
whenToWarnRemainingCount={235}
onChange={value => {
setNote(value);
}}
/>
<button type="submit" hidden>
{i18n('icu:submit')}
</button>
</form>
</Modal>
);
}

View file

@ -36,25 +36,20 @@ export default {
},
state: {
control: { type: 'radio' },
options: {
Open: State.Open,
Closed: State.Closed,
Reserving: State.Reserving,
Confirming: State.Confirming,
},
options: [State.Open, State.Closed, State.Reserving, State.Confirming],
},
error: {
control: { type: 'radio' },
options: {
None: undefined,
NotEnoughCharacters: UsernameReservationError.NotEnoughCharacters,
TooManyCharacters: UsernameReservationError.TooManyCharacters,
CheckStartingCharacter: UsernameReservationError.CheckStartingCharacter,
CheckCharacters: UsernameReservationError.CheckCharacters,
UsernameNotAvailable: UsernameReservationError.UsernameNotAvailable,
General: UsernameReservationError.General,
TooManyAttempts: UsernameReservationError.TooManyAttempts,
},
options: [
undefined,
UsernameReservationError.NotEnoughCharacters,
UsernameReservationError.TooManyCharacters,
UsernameReservationError.CheckStartingCharacter,
UsernameReservationError.CheckCharacters,
UsernameReservationError.UsernameNotAvailable,
UsernameReservationError.General,
UsernameReservationError.TooManyAttempts,
],
},
reservation: {
type: { name: 'string', required: false },

View file

@ -179,12 +179,12 @@ export function EditUsernameModalBody({
return undefined;
}
if (error === UsernameReservationError.NotEnoughCharacters) {
return i18n('icu:ProfileEditor--username--check-character-min', {
return i18n('icu:ProfileEditor--username--check-character-min-plural', {
min: minNickname,
});
}
if (error === UsernameReservationError.TooManyCharacters) {
return i18n('icu:ProfileEditor--username--check-character-max', {
return i18n('icu:ProfileEditor--username--check-character-max-plural', {
max: maxNickname,
});
}

View file

@ -1,38 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { ConfirmationDialog } from './ConfirmationDialog';
type PropsType = {
i18n: LocalizerType;
onSendAnyway: () => void;
onCancel: () => void;
};
export function FormattingWarningModal({
i18n,
onSendAnyway,
onCancel,
}: PropsType): JSX.Element | null {
return (
<ConfirmationDialog
actions={[
{
action: onSendAnyway,
autoClose: true,
style: 'affirmative',
text: i18n('icu:sendAnyway'),
},
]}
dialogName="FormattingWarningModal"
i18n={i18n}
onCancel={onCancel}
onClose={onCancel}
title={i18n('icu:SendFormatting--dialog--title')}
>
{i18n('icu:SendFormatting--dialog--body')}
</ConfirmationDialog>
);
}

View file

@ -7,7 +7,10 @@ import type { Meta } from '@storybook/react';
import enMessages from '../../_locales/en/messages.json';
import type { AttachmentType } from '../types/Attachment';
import type { PropsType } from './ForwardMessagesModal';
import { ForwardMessagesModal } from './ForwardMessagesModal';
import {
ForwardMessagesModal,
ForwardMessagesModalType,
} from './ForwardMessagesModal';
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
@ -23,7 +26,7 @@ const createAttachment = (
contentType: stringToMIMEType(props.contentType ?? ''),
fileName: props.fileName ?? '',
screenshotPath: props.pending === false ? props.screenshotPath : undefined,
url: props.pending === false ? props.url ?? '' : '',
url: props.pending === false ? (props.url ?? '') : '',
size: 3433,
});
@ -49,6 +52,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
doForwardMessages: action('doForwardMessages'),
getPreferredBadge: () => undefined,
i18n,
isInFullScreenCall: false,
linkPreviewForSource: () => undefined,
onClose: action('onClose'),
onChange: action('onChange'),
@ -58,6 +62,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
{...props}
getPreferredBadge={() => undefined}
i18n={i18n}
isActive
isFormattingEnabled
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
@ -67,6 +72,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
/>
),
showToast: action('showToast'),
type: ForwardMessagesModalType.Forward,
theme: React.useContext(StorybookThemeContext),
regionCode: 'US',
});

View file

@ -1,6 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentType } from 'react';
import React, {
useCallback,
useEffect,
@ -22,7 +23,7 @@ import type { LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { filterAndSortConversations } from '../util/filterAndSortConversations';
import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
@ -41,6 +42,13 @@ import {
isDraftForwardable,
type MessageForwardDraft,
} from '../types/ForwardDraft';
import { missingCaseError } from '../util/missingCaseError';
import { Theme } from '../util/theme';
export enum ForwardMessagesModalType {
Forward,
ShareCallLink,
}
export type DataPropsType = {
candidateConversations: ReadonlyArray<ConversationType>;
@ -51,6 +59,7 @@ export type DataPropsType = {
drafts: ReadonlyArray<MessageForwardDraft>;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isInFullScreenCall: boolean;
linkPreviewForSource: (
source: LinkPreviewSourceType
@ -61,9 +70,8 @@ export type DataPropsType = {
caretLocation?: number
) => unknown;
regionCode: string | undefined;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>;
type: ForwardMessagesModalType;
showToast: ShowToastAction;
theme: ThemeType;
};
@ -77,12 +85,14 @@ export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5;
export function ForwardMessagesModal({
type,
drafts,
candidateConversations,
doForwardMessages,
linkPreviewForSource,
getPreferredBadge,
i18n,
isInFullScreenCall,
onClose,
onChange,
removeLinkPreview,
@ -97,7 +107,7 @@ export function ForwardMessagesModal({
>([]);
const [searchTerm, setSearchTerm] = useState('');
const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
filterAndSortConversations(candidateConversations, '', regionCode)
);
const [isEditingMessage, setIsEditingMessage] = useState(false);
const [cannotMessage, setCannotMessage] = useState(false);
@ -170,7 +180,7 @@ export function ForwardMessagesModal({
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredConversations(
filterAndSortConversationsByRecent(
filterAndSortConversations(
candidateConversations,
normalizedSearchTerm,
regionCode
@ -293,6 +303,17 @@ export function ForwardMessagesModal({
</div>
);
let title: string;
if (type === ForwardMessagesModalType.Forward) {
title = i18n('icu:ForwardMessageModal__title');
} else if (type === ForwardMessagesModalType.ShareCallLink) {
title = i18n('icu:ForwardMessageModal__ShareCallLink');
} else {
throw missingCaseError(type);
}
const modalTheme = isInFullScreenCall ? Theme.Dark : undefined;
return (
<>
{cannotMessage && (
@ -312,8 +333,9 @@ export function ForwardMessagesModal({
onClose={onClose}
onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined}
moduleClassName="module-ForwardMessageModal"
title={i18n('icu:ForwardMessageModal__title')}
useFocusTrap={false}
title={title}
theme={modalTheme}
useFocusTrap={isInFullScreenCall}
padded={false}
modalFooter={footer}
noMouseClose
@ -413,9 +435,7 @@ type ForwardMessageEditorProps = Readonly<{
draft: MessageForwardDraft;
linkPreview: LinkPreviewType | null | void;
removeLinkPreview(): void;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>;
onChange: (
messageText: string,
bodyRanges: HydratedBodyRangesType,
@ -470,8 +490,9 @@ function ForwardMessageEditor({
) : null}
<RenderCompositionTextArea
bodyRanges={draft.bodyRanges}
bodyRanges={draft.bodyRanges ?? null}
draftText={draft.messageBody ?? ''}
isActive
onChange={onChange}
onSubmit={onSubmit}
theme={theme}

View file

@ -3,27 +3,24 @@
import React from 'react';
import type {
AuthorizeArtCreatorDataType,
ContactModalStateType,
DeleteMessagesPropsType,
EditHistoryMessagesType,
FormattingWarningDataType,
EditNicknameAndNoteModalPropsType,
ForwardMessagesPropsType,
MessageRequestActionsConfirmationPropsType,
SafetyNumberChangedBlockingDataType,
SendEditWarningDataType,
UserNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util';
import { UsernameOnboardingState } from '../types/globalModals';
import type { ExplodePromiseResultType } from '../util/explodePromise';
import { missingCaseError } from '../util/missingCaseError';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { FormattingWarningModal } from './FormattingWarningModal';
import { SendEditWarningModal } from './SendEditWarningModal';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { WhatsNewModal } from './WhatsNewModal';
import type { StartCallData } from './ConfirmLeaveCallModal';
// NOTE: All types should be required for this component so that the smart
// component gives you type errors when adding/removing props.
@ -33,12 +30,24 @@ export type PropsType = {
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId: string | undefined;
renderAddUserToAnotherGroup: () => JSX.Element;
// CallLinkAddNameModal
callLinkAddNameModalRoomId: string | null;
renderCallLinkAddNameModal: () => JSX.Element;
// CallLinkEditModal
callLinkEditModalRoomId: string | null;
renderCallLinkEditModal: () => JSX.Element;
// ConfirmLeaveCallModal
confirmLeaveCallModalState: StartCallData | null;
renderConfirmLeaveCallModal: () => JSX.Element;
// ContactModal
contactModalState: ContactModalStateType | undefined;
renderContactModal: () => JSX.Element;
// EditHistoryMessagesModal
editHistoryMessages: EditHistoryMessagesType | undefined;
renderEditHistoryMessagesModal: () => JSX.Element;
// EditNicknameAndNoteModal
editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null;
renderEditNicknameAndNoteModal: () => JSX.Element;
// ErrorModal
errorModalProps:
| { buttonVariant?: ButtonVariant; description?: string; title?: string }
@ -51,25 +60,21 @@ export type PropsType = {
// DeleteMessageModal
deleteMessagesProps: DeleteMessagesPropsType | undefined;
renderDeleteMessagesModal: () => JSX.Element;
// FormattingWarningModal
showFormattingWarningModal: (
explodedPromise: ExplodePromiseResultType<boolean> | undefined
) => void;
formattingWarningData: FormattingWarningDataType | undefined;
// ForwardMessageModal
forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessagesModal: () => JSX.Element;
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
renderMessageRequestActionsConfirmation: () => JSX.Element;
// NotePreviewModal
notePreviewModalProps: { conversationId: string } | null;
renderNotePreviewModal: () => JSX.Element;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
// SafetyNumberModal
safetyNumberModalContactId: string | undefined;
renderSafetyNumber: () => JSX.Element;
// SendEditWarningModal
showSendEditWarningModal: (
explodedPromise: ExplodePromiseResultType<boolean> | undefined
) => void;
sendEditWarningData: SendEditWarningDataType | undefined;
// ShortcutGuideModal
isShortcutGuideModalVisible: boolean;
renderShortcutGuideModal: () => JSX.Element;
@ -100,11 +105,6 @@ export type PropsType = {
// UsernameOnboarding
usernameOnboardingState: UsernameOnboardingState;
renderUsernameOnboarding: () => JSX.Element;
// AuthArtCreatorModal
authArtCreatorData?: AuthorizeArtCreatorDataType;
isAuthorizingArtCreator?: boolean;
cancelAuthorizeArtCreator: () => unknown;
confirmAuthorizeArtCreator: () => unknown;
};
export function GlobalModalContainer({
@ -112,33 +112,45 @@ export function GlobalModalContainer({
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId,
renderAddUserToAnotherGroup,
// CallLinkAddNameModal
callLinkAddNameModalRoomId,
renderCallLinkAddNameModal,
// CallLinkEditModal
callLinkEditModalRoomId,
renderCallLinkEditModal,
// ConfirmLeaveCallModal
confirmLeaveCallModalState,
renderConfirmLeaveCallModal,
// ContactModal
contactModalState,
renderContactModal,
// EditHistoryMessages
editHistoryMessages,
renderEditHistoryMessagesModal,
// EditNicknameAndNoteModal
editNicknameAndNoteModalProps,
renderEditNicknameAndNoteModal,
// ErrorModal
errorModalProps,
renderErrorModal,
// DeleteMessageModal
deleteMessagesProps,
renderDeleteMessagesModal,
// FormattingWarningModal
showFormattingWarningModal,
formattingWarningData,
// ForwardMessageModal
forwardMessagesProps,
renderForwardMessagesModal,
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps,
renderMessageRequestActionsConfirmation,
// NotePreviewModal
notePreviewModalProps,
renderNotePreviewModal,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
// SafetyNumberModal
safetyNumberModalContactId,
renderSafetyNumber,
// SendEditWarningDataType
showSendEditWarningModal,
sendEditWarningData,
// ShortcutGuideModal
isShortcutGuideModalVisible,
renderShortcutGuideModal,
@ -167,16 +179,12 @@ export function GlobalModalContainer({
// UsernameOnboarding
usernameOnboardingState,
renderUsernameOnboarding,
// AuthArtCreatorModal
authArtCreatorData,
isAuthorizingArtCreator,
cancelAuthorizeArtCreator,
confirmAuthorizeArtCreator,
}: PropsType): JSX.Element | null {
// 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) {
@ -188,62 +196,53 @@ export function GlobalModalContainer({
return renderSendAnywayDialog();
}
// Forward Modal
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
// The Rest
if (confirmLeaveCallModalState) {
return renderConfirmLeaveCallModal();
}
if (addUserToAnotherGroupModalContactId) {
return renderAddUserToAnotherGroup();
}
if (callLinkAddNameModalRoomId) {
return renderCallLinkAddNameModal();
}
if (callLinkEditModalRoomId) {
return renderCallLinkEditModal();
}
if (editHistoryMessages) {
return renderEditHistoryMessagesModal();
}
if (editNicknameAndNoteModalProps) {
return renderEditNicknameAndNoteModal();
}
if (deleteMessagesProps) {
return renderDeleteMessagesModal();
}
if (formattingWarningData) {
const { resolve } = formattingWarningData.explodedPromise;
return (
<FormattingWarningModal
i18n={i18n}
onSendAnyway={() => {
showFormattingWarningModal(undefined);
resolve(true);
}}
onCancel={() => {
showFormattingWarningModal(undefined);
resolve(false);
}}
/>
);
if (messageRequestActionsConfirmationProps) {
return renderMessageRequestActionsConfirmation();
}
if (forwardMessagesProps) {
return renderForwardMessagesModal();
if (notePreviewModalProps) {
return renderNotePreviewModal();
}
if (isProfileEditorVisible) {
return renderProfileEditor();
}
if (sendEditWarningData) {
const { resolve } = sendEditWarningData.explodedPromise;
return (
<SendEditWarningModal
i18n={i18n}
onSendAnyway={() => {
showSendEditWarningModal(undefined);
resolve(true);
}}
onCancel={() => {
showSendEditWarningModal(undefined);
resolve(false);
}}
/>
);
}
if (isShortcutGuideModalVisible) {
return renderShortcutGuideModal();
}
@ -312,28 +311,5 @@ export function GlobalModalContainer({
);
}
if (authArtCreatorData) {
return (
<ConfirmationDialog
dialogName="GlobalModalContainer.authArtCreator"
cancelText={i18n('icu:AuthArtCreator--dialog--dismiss')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
isSpinning={isAuthorizingArtCreator}
onClose={cancelAuthorizeArtCreator}
actions={[
{
text: i18n('icu:AuthArtCreator--dialog--confirm'),
style: 'affirmative',
action: confirmAuthorizeArtCreator,
autoClose: false,
},
]}
>
{i18n('icu:AuthArtCreator--dialog--message')}
</ConfirmationDialog>
);
}
return null;
}

View file

@ -13,6 +13,7 @@ import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGr
import { FRAME_BUFFER_SIZE } from '../calling/constants';
import enMessages from '../../_locales/en/messages.json';
import { generateAci } from '../types/ServiceId';
import type { CallingImageDataCache } from './CallManager';
const MAX_PARTICIPANTS = 32;
@ -42,7 +43,9 @@ export default {
const defaultProps = {
getFrameBuffer: memoize(() => Buffer.alloc(FRAME_BUFFER_SIZE)),
getCallingImageDataCache: memoize(() => new Map()),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
imageDataCache: React.createRef<CallingImageDataCache>(),
i18n,
isCallReconnecting: false,
onParticipantVisibilityChanged: action('onParticipantVisibilityChanged'),

View file

@ -8,6 +8,7 @@ import type { VideoFrameSource } from '@signalapp/ringrtc';
import type { LocalizerType } from '../types/Util';
import type { GroupCallRemoteParticipantType } from '../types/Calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import type { CallingImageDataCache } from './CallManager';
const OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD = 20;
const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75;
@ -19,6 +20,7 @@ export type PropsType = {
getFrameBuffer: () => Buffer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
isCallReconnecting: boolean;
onClickRaisedHand?: () => void;
onParticipantVisibilityChanged: (
@ -33,6 +35,7 @@ export type PropsType = {
export function GroupCallOverflowArea({
getFrameBuffer,
getGroupCallVideoFrameSource,
imageDataCache,
i18n,
isCallReconnecting,
onClickRaisedHand,
@ -121,6 +124,7 @@ export function GroupCallOverflowArea({
key={remoteParticipant.demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
audioLevel={remoteAudioLevels.get(remoteParticipant.demuxId) ?? 0}
onClickRaisedHand={onClickRaisedHand}

View file

@ -11,6 +11,7 @@ import { FRAME_BUFFER_SIZE } from '../calling/constants';
import { setupI18n } from '../util/setupI18n';
import { generateAci } from '../types/ServiceId';
import enMessages from '../../_locales/en/messages.json';
import type { CallingImageDataCache } from './CallManager';
const i18n = setupI18n('en', enMessages);
@ -54,6 +55,7 @@ const createProps = (
getGroupCallVideoFrameSource: () => {
return { receiveVideoFrame: () => undefined };
},
imageDataCache: React.createRef<CallingImageDataCache>(),
i18n,
audioLevel: 0,
remoteParticipant: {
@ -192,3 +194,43 @@ export function NoMediaKeys(): JSX.Element {
/>
);
}
export function NoMediaKeysBlockedIntermittent(): JSX.Element {
const [isBlocked, setIsBlocked] = React.useState(false);
React.useEffect(() => {
const interval = setInterval(() => {
setIsBlocked(value => !value);
}, 6000);
return () => clearInterval(interval);
}, [isBlocked]);
const [mediaKeysReceived, setMediaKeysReceived] = React.useState(false);
React.useEffect(() => {
const interval = setInterval(() => {
setMediaKeysReceived(value => !value);
}, 3000);
return () => clearInterval(interval);
}, [mediaKeysReceived]);
return (
<GroupCallRemoteParticipant
{...createProps(
{
isInPip: false,
height: 120,
left: 0,
top: 0,
width: 120,
},
{
addedTime: Date.now() - 60 * 1000,
hasRemoteAudio: true,
mediaKeysReceived,
isBlocked,
}
)}
/>
);
}

View file

@ -22,13 +22,15 @@ import {
} from './CallingAudioIndicator';
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { ContactName } from './conversation/ContactName';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
import { Theme } from '../util/theme';
import { isOlderThan } from '../util/timestamp';
import type { CallingImageDataCache } from './CallManager';
import { usePrevious } from '../hooks/usePrevious';
const MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES = 10000;
const MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES = 60000;
@ -38,6 +40,7 @@ type BasePropsType = {
getFrameBuffer: () => Buffer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
isActiveSpeakerInSpeakerView: boolean;
isCallReconnecting: boolean;
onClickRaisedHand?: () => void;
@ -70,6 +73,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
const {
getFrameBuffer,
getGroupCallVideoFrameSource,
imageDataCache,
i18n,
onClickRaisedHand,
onVisibilityChanged,
@ -81,7 +85,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
const {
acceptedMessageRequest,
addedTime,
avatarPath,
avatarUrl,
color,
demuxId,
hasRemoteAudio,
@ -101,9 +105,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
!props.isInPip ? props.audioLevel > 0 : false,
SPEAKING_LINGER_MS
);
const previousSharingScreen = usePrevious(sharingScreen, sharingScreen);
const isImageDataCached =
sharingScreen && imageDataCache.current?.has(demuxId);
const [hasReceivedVideoRecently, setHasReceivedVideoRecently] =
useState(false);
useState(isImageDataCached);
const [isWide, setIsWide] = useState<boolean>(
videoAspectRatio ? videoAspectRatio >= 1 : true
);
@ -132,6 +139,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
onVisibilityChanged?.(demuxId, isVisible);
}, [demuxId, isVisible, onVisibilityChanged]);
useEffect(() => {
if (sharingScreen !== previousSharingScreen) {
imageDataCache.current?.delete(demuxId);
}
}, [demuxId, imageDataCache, previousSharingScreen, sharingScreen]);
const wantsToShowVideo = hasRemoteVideo && !isBlocked && isVisible;
const hasVideoToShow = wantsToShowVideo && hasReceivedVideoRecently;
const showMissingMediaKeys = Boolean(
@ -173,46 +186,74 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
// This frame buffer is shared by all participants, so it may contain pixel data
// for other participants, or pixel data from a previous frame. That's why we
// return early and use the `frameWidth` and `frameHeight`.
let frameWidth: number | undefined;
let frameHeight: number | undefined;
let imageData = imageDataRef.current;
const frameBuffer = getFrameBuffer();
const frameDimensions = videoFrameSource.receiveVideoFrame(
frameBuffer,
MAX_FRAME_WIDTH,
MAX_FRAME_HEIGHT
);
if (!frameDimensions) {
return;
if (frameDimensions) {
[frameWidth, frameHeight] = frameDimensions;
if (
frameWidth < 2 ||
frameHeight < 2 ||
frameWidth > MAX_FRAME_WIDTH ||
frameHeight > MAX_FRAME_HEIGHT
) {
return;
}
if (
imageData?.width !== frameWidth ||
imageData?.height !== frameHeight
) {
imageData = new ImageData(frameWidth, frameHeight);
imageDataRef.current = imageData;
}
imageData.data.set(
frameBuffer.subarray(0, frameWidth * frameHeight * 4)
);
// Screen share is at a slow FPS so updates slowly if we PiP then restore.
// Cache the image data so we can quickly show the most recent frame.
if (sharingScreen) {
imageDataCache.current?.set(demuxId, imageData);
}
} else if (sharingScreen && !imageData) {
// Try to use the screenshare cache the first time we show
const cachedImageData = imageDataCache.current?.get(demuxId);
if (cachedImageData) {
frameWidth = cachedImageData.width;
frameHeight = cachedImageData.height;
imageDataRef.current = cachedImageData;
imageData = cachedImageData;
}
}
const [frameWidth, frameHeight] = frameDimensions;
if (
frameWidth < 2 ||
frameHeight < 2 ||
frameWidth > MAX_FRAME_WIDTH ||
frameHeight > MAX_FRAME_HEIGHT
) {
if (!frameWidth || !frameHeight || !imageData) {
return;
}
canvasEl.width = frameWidth;
canvasEl.height = frameHeight;
let imageData = imageDataRef.current;
if (
imageData?.width !== frameWidth ||
imageData?.height !== frameHeight
) {
imageData = new ImageData(frameWidth, frameHeight);
imageDataRef.current = imageData;
}
imageData.data.set(frameBuffer.subarray(0, frameWidth * frameHeight * 4));
canvasContext.putImageData(imageData, 0, 0);
lastReceivedVideoAt.current = Date.now();
setHasReceivedVideoRecently(true);
setIsWide(frameWidth > frameHeight);
}, [getFrameBuffer, videoFrameSource, sharingScreen, isCallReconnecting]);
}, [
demuxId,
imageDataCache,
isCallReconnecting,
sharingScreen,
videoFrameSource,
getFrameBuffer,
]);
useEffect(() => {
if (!hasRemoteVideo) {
@ -304,8 +345,6 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
}
let noVideoNode: ReactNode;
let errorDialogTitle: ReactNode;
let errorDialogBody = '';
if (!hasVideoToShow) {
const showDialogButton = (
<button
@ -322,21 +361,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
noVideoNode = (
<>
<i className="module-ongoing-call__group-call-remote-participant__error-icon module-ongoing-call__group-call-remote-participant__error-icon--blocked" />
<div className="module-ongoing-call__group-call-remote-participant__error">
{i18n('icu:calling__blocked-participant', { name: title })}
</div>
{showDialogButton}
</>
);
errorDialogTitle = (
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
<Intl
i18n={i18n}
id="icu:calling__you-have-blocked"
components={{
name: <ContactName key="name" title={title} />,
}}
/>
</div>
);
errorDialogBody = i18n('icu:calling__block-info');
} else if (showMissingMediaKeys) {
noVideoNode = (
<>
@ -347,23 +377,11 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
{showDialogButton}
</>
);
errorDialogTitle = (
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
<Intl
i18n={i18n}
id="icu:calling__missing-media-keys"
components={{
name: <ContactName key="name" title={title} />,
}}
/>
</div>
);
errorDialogBody = i18n('icu:calling__missing-media-keys-info');
} else {
noVideoNode = (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}
@ -379,6 +397,56 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
}
}
// Error dialog maintains state, so if you have it open and the underlying
// error changes or resolves, you can keep reading the same dialog info.
const [errorDialogTitle, setErrorDialogTitle] = useState<ReactNode | null>(
null
);
const [errorDialogBody, setErrorDialogBody] = useState<string>('');
useEffect(() => {
if (hasVideoToShow || showErrorDialog) {
return;
}
if (isBlocked) {
setErrorDialogTitle(
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
<I18n
i18n={i18n}
id="icu:calling__block-info-title"
components={{
name: <ContactName key="name" title={title} />,
}}
/>
</div>
);
setErrorDialogBody(i18n('icu:calling__block-info'));
} else if (showMissingMediaKeys) {
setErrorDialogTitle(
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
<I18n
i18n={i18n}
id="icu:calling__missing-media-keys"
components={{
name: <ContactName key="name" title={title} />,
}}
/>
</div>
);
setErrorDialogBody(i18n('icu:calling__missing-media-keys-info'));
} else {
setErrorDialogTitle(null);
setErrorDialogBody('');
}
}, [
hasVideoToShow,
i18n,
isBlocked,
showErrorDialog,
showMissingMediaKeys,
title,
]);
return (
<>
{showErrorDialog && (
@ -436,11 +504,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
ref={canvasEl => {
remoteVideoRef.current = canvasEl;
if (canvasEl) {
canvasContextRef.current = canvasEl.getContext('2d', {
alpha: false,
desynchronized: true,
storage: 'discardable',
} as CanvasRenderingContext2DSettings);
canvasContextRef.current = canvasEl.getContext('2d');
} else {
canvasContextRef.current = null;
}
@ -449,7 +513,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
)}
{noVideoNode && (
<CallBackgroundBlur
avatarPath={avatarPath}
avatarUrl={isBlocked ? undefined : avatarUrl}
className="module-ongoing-call__group-call-remote-participant-background"
>
{noVideoNode}

View file

@ -27,6 +27,7 @@ import * as log from '../logging/log';
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
import { SizeObserver } from '../hooks/useSizeObserver';
import { strictAssert } from '../util/assert';
import type { CallingImageDataCache } from './CallManager';
const SMALL_TILES_MIN_HEIGHT = 80;
const LARGE_TILES_MIN_HEIGHT = 200;
@ -60,6 +61,7 @@ type PropsType = {
callViewMode: CallViewMode;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
isCallReconnecting: boolean;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
setGroupCallVideoRequest: (
@ -110,6 +112,7 @@ enum VideoRequestMode {
export function GroupCallRemoteParticipants({
callViewMode,
getGroupCallVideoFrameSource,
imageDataCache,
i18n,
isCallReconnecting,
remoteParticipants,
@ -343,6 +346,7 @@ export function GroupCallRemoteParticipants({
<GroupCallRemoteParticipant
key={tile.demuxId}
getFrameBuffer={getFrameBuffer}
imageDataCache={imageDataCache}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
onClickRaisedHand={onClickRaisedHand}
height={gridParticipantHeight}
@ -510,6 +514,7 @@ export function GroupCallRemoteParticipants({
<GroupCallOverflowArea
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
isCallReconnecting={isCallReconnecting}
onClickRaisedHand={onClickRaisedHand}
@ -632,7 +637,7 @@ function stableParticipantComparator(
}
type ParticipantsInPageType<
T extends { videoAspectRatio: number } = ParticipantTileType
T extends { videoAspectRatio: number } = ParticipantTileType,
> = {
rows: Array<Array<T>>;
numParticipants: number;

View file

@ -110,7 +110,7 @@ function Contacts({
<li key={contact.id} className="module-GroupDialog__contacts__contact">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType={contact.type}
@ -118,7 +118,7 @@ function Contacts({
noteToSelf={contact.isMe}
theme={theme}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
unblurredAvatarUrl={contact.unblurredAvatarUrl}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
i18n={i18n}

View file

@ -36,13 +36,14 @@ const contact3: ConversationType = getDefaultConversation({
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeInvited: Boolean(overrideProps.areWeInvited),
conversationId: '123',
droppedMembers: overrideProps.droppedMembers || [contact3, contact1],
droppedMembers: overrideProps.droppedMembers,
droppedMemberCount: overrideProps.droppedMemberCount || 0,
getPreferredBadge: () => undefined,
hasMigrated: Boolean(overrideProps.hasMigrated),
i18n,
invitedMembers: overrideProps.invitedMembers || [contact2],
migrate: action('migrate'),
invitedMembers: overrideProps.invitedMembers,
invitedMemberCount: overrideProps.invitedMemberCount || 0,
onMigrate: action('onMigrate'),
onClose: action('onClose'),
theme: ThemeType.light,
});
@ -76,23 +77,41 @@ export function MigratedYouAreInvited(): JSX.Element {
);
}
export function NotYetMigratedMultipleDroppedAndInvitedMembers(): JSX.Element {
export function MigratedMultipleDroppedAndInvitedMember(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [contact3, contact1, contact2],
invitedMembers: [contact2, contact3, contact1],
hasMigrated: true,
droppedMembers: [contact1],
droppedMemberCount: 1,
invitedMembers: [contact2],
invitedMemberCount: 1,
})}
/>
);
}
export function NotYetMigratedNoMembers(): JSX.Element {
export function MigratedMultipleDroppedAndInvitedMembers(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [],
invitedMembers: [],
hasMigrated: true,
droppedMembers: [contact3, contact1, contact2],
droppedMemberCount: 3,
invitedMembers: [contact2, contact3, contact1],
invitedMemberCount: 3,
})}
/>
);
}
export function MigratedNoMembers(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
hasMigrated: true,
droppedMemberCount: 0,
invitedMemberCount: 0,
})}
/>
);
@ -102,7 +121,65 @@ export function NotYetMigratedJustDroppedMember(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
invitedMembers: [],
droppedMembers: [contact1],
droppedMemberCount: 1,
})}
/>
);
}
export function NotYetMigratedJustDroppedMembers(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [contact1, contact2],
droppedMemberCount: 2,
})}
/>
);
}
export function NotYetMigratedDropped1(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMemberCount: 1,
invitedMemberCount: 0,
})}
/>
);
}
export function NotYetMigratedDropped2(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMemberCount: 2,
invitedMemberCount: 0,
})}
/>
);
}
export function MigratedJustCountIs1(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
hasMigrated: true,
droppedMemberCount: 1,
invitedMemberCount: 1,
})}
/>
);
}
export function MigratedJustCountIs2(): JSX.Element {
return (
<GroupV1MigrationDialog
{...createProps({
hasMigrated: true,
droppedMemberCount: 2,
invitedMemberCount: 2,
})}
/>
);

View file

@ -10,61 +10,38 @@ import { sortByTitle } from '../util/sortByTitle';
import { missingCaseError } from '../util/missingCaseError';
export type DataPropsType = {
conversationId: string;
readonly areWeInvited: boolean;
readonly droppedMembers: Array<ConversationType>;
readonly droppedMembers?: Array<ConversationType>;
readonly droppedMemberCount: number;
readonly hasMigrated: boolean;
readonly invitedMembers: Array<ConversationType>;
readonly invitedMembers?: Array<ConversationType>;
readonly invitedMemberCount: number;
readonly getPreferredBadge: PreferredBadgeSelectorType;
readonly i18n: LocalizerType;
readonly theme: ThemeType;
};
type ActionsPropsType =
| {
initiateMigrationToGroupV2: (conversationId: string) => unknown;
closeGV2MigrationDialog: () => unknown;
}
| {
readonly migrate: () => unknown;
readonly onClose: () => unknown;
};
type ActionsPropsType = Readonly<{
onMigrate: () => unknown;
onClose: () => unknown;
}>;
export type PropsType = DataPropsType & ActionsPropsType;
export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
React.memo(function GroupV1MigrationDialogInner(props: PropsType) {
const {
areWeInvited,
conversationId,
droppedMembers,
getPreferredBadge,
hasMigrated,
i18n,
invitedMembers,
theme,
} = props;
let migrateHandler;
if ('migrate' in props) {
migrateHandler = props.migrate;
} else if ('initiateMigrationToGroupV2' in props) {
migrateHandler = () => props.initiateMigrationToGroupV2(conversationId);
} else {
throw new Error(
'GroupV1MigrationDialog: No conversationId or migration function'
);
}
let closeHandler;
if ('onClose' in props) {
closeHandler = props.onClose;
} else if ('closeGV2MigrationDialog' in props) {
closeHandler = props.closeGV2MigrationDialog;
} else {
throw new Error('GroupV1MigrationDialog: No close function provided');
}
React.memo(function GroupV1MigrationDialogInner({
areWeInvited,
droppedMembers,
droppedMemberCount,
getPreferredBadge,
hasMigrated,
i18n,
invitedMembers,
invitedMemberCount,
theme,
onClose,
onMigrate,
}: PropsType) {
const title = hasMigrated
? i18n('icu:GroupV1--Migration--info--title')
: i18n('icu:GroupV1--Migration--migrate--title');
@ -82,13 +59,13 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
};
if (hasMigrated) {
primaryButtonText = i18n('icu:Confirmation--confirm');
onClickPrimaryButton = closeHandler;
onClickPrimaryButton = onClose;
} else {
primaryButtonText = i18n('icu:GroupV1--Migration--migrate');
onClickPrimaryButton = migrateHandler;
onClickPrimaryButton = onMigrate;
secondaryButtonProps = {
secondaryButtonText: i18n('icu:cancel'),
onClickSecondaryButton: closeHandler,
onClickSecondaryButton: onClose,
};
}
@ -96,7 +73,7 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
<GroupDialog
i18n={i18n}
onClickPrimaryButton={onClickPrimaryButton}
onClose={closeHandler}
onClose={onClose}
primaryButtonText={primaryButtonText}
title={title}
{...secondaryButtonProps}
@ -115,6 +92,7 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
getPreferredBadge,
i18n,
members: invitedMembers,
count: invitedMemberCount,
hasMigrated,
kind: 'invited',
theme,
@ -123,6 +101,7 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
getPreferredBadge,
i18n,
members: droppedMembers,
count: droppedMemberCount,
hasMigrated,
kind: 'dropped',
theme,
@ -137,21 +116,50 @@ function renderMembers({
getPreferredBadge,
i18n,
members,
count,
hasMigrated,
kind,
theme,
}: Readonly<{
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
members: Array<ConversationType>;
members?: Array<ConversationType>;
count: number;
hasMigrated: boolean;
kind: 'invited' | 'dropped';
theme: ThemeType;
}>): React.ReactNode {
if (!members.length) {
if (count === 0) {
return null;
}
if (!members) {
if (kind === 'invited') {
return (
<GroupDialog.Paragraph>
{i18n('icu:GroupV1--Migration--info--invited--count', { count })}
</GroupDialog.Paragraph>
);
}
if (hasMigrated) {
return (
<GroupDialog.Paragraph>
{i18n('icu:GroupV1--Migration--info--removed--after--count', {
count,
})}
</GroupDialog.Paragraph>
);
}
return (
<GroupDialog.Paragraph>
{i18n('icu:GroupV1--Migration--info--removed--before--count', {
count,
})}
</GroupDialog.Paragraph>
);
}
let text: string;
switch (kind) {
case 'invited':
@ -164,13 +172,13 @@ function renderMembers({
if (hasMigrated) {
text =
members.length === 1
? i18n('icu:GroupV1--Migration--info--removed--before--one')
: i18n('icu:GroupV1--Migration--info--removed--before--many');
? i18n('icu:GroupV1--Migration--info--removed--after--one')
: i18n('icu:GroupV1--Migration--info--removed--after--many');
} else {
text =
members.length === 1
? i18n('icu:GroupV1--Migration--info--removed--after--one')
: i18n('icu:GroupV1--Migration--info--removed--after--many');
? i18n('icu:GroupV1--Migration--info--removed--before--one')
: i18n('icu:GroupV1--Migration--info--removed--before--many');
}
break;
default:

View file

@ -30,21 +30,18 @@ function focusRef(el: HTMLElement | null) {
}
}
export const GroupV2JoinDialog = React.memo(function GroupV2JoinDialogInner(
props: PropsType
) {
export const GroupV2JoinDialog = React.memo(function GroupV2JoinDialogInner({
approvalRequired,
avatar,
groupDescription,
i18n,
join,
memberCount,
onClose,
title,
}: PropsType) {
const [isWorking, setIsWorking] = React.useState(false);
const [isJoining, setIsJoining] = React.useState(false);
const {
approvalRequired,
avatar,
groupDescription,
i18n,
join,
memberCount,
onClose,
title,
} = props;
const joinString = approvalRequired
? i18n('icu:GroupV2--join--request-to-join-button')
@ -73,7 +70,7 @@ export const GroupV2JoinDialog = React.memo(function GroupV2JoinDialogInner(
<div className="module-group-v2-join-dialog__avatar">
<Avatar
acceptedMessageRequest={false}
avatarPath={avatar ? avatar.url : undefined}
avatarUrl={avatar ? avatar.url : undefined}
badge={undefined}
blur={AvatarBlur.NoBlur}
loading={avatar && !avatar.url}

View file

@ -4,16 +4,16 @@
import * as React from 'react';
import type { ComponentMeta } from '../storybook/types';
import type { Props } from './Intl';
import { Intl } from './Intl';
import type { Props } from './I18n';
import { I18n } from './I18n';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Intl',
component: Intl,
title: 'Components/I18n',
component: I18n,
args: {
i18n,
id: 'icu:ok',
@ -24,14 +24,14 @@ export default {
export function NoReplacements(
args: Props<'icu:deleteAndRestart'>
): JSX.Element {
return <Intl {...args} id="icu:deleteAndRestart" />;
return <I18n {...args} id="icu:deleteAndRestart" />;
}
export function SingleStringReplacement(
args: Props<'icu:leftTheGroup'>
): JSX.Element {
return (
<Intl {...args} id="icu:leftTheGroup" components={{ name: 'Theodora' }} />
<I18n {...args} id="icu:leftTheGroup" components={{ name: 'Theodora' }} />
);
}
@ -39,7 +39,7 @@ export function SingleTagReplacement(
args: Props<'icu:leftTheGroup'>
): JSX.Element {
return (
<Intl
<I18n
{...args}
id="icu:leftTheGroup"
components={{
@ -57,7 +57,7 @@ export function MultipleStringReplacement(
args: Props<'icu:changedRightAfterVerify'>
): JSX.Element {
return (
<Intl
<I18n
{...args}
id="icu:changedRightAfterVerify"
components={{ name1: 'Fred', name2: 'The Fredster' }}
@ -69,7 +69,7 @@ export function MultipleTagReplacement(
args: Props<'icu:changedRightAfterVerify'>
): JSX.Element {
return (
<Intl
<I18n
{...args}
id="icu:changedRightAfterVerify"
components={{ name1: <b>Fred</b>, name2: <b>The Fredster</b> }}
@ -81,7 +81,7 @@ export function Emoji(
args: Props<'icu:Message__reaction-emoji-label--you'>
): JSX.Element {
return (
<Intl
<I18n
{...args}
id="icu:Message__reaction-emoji-label--you"
components={{ emoji: '😛' }}

View file

@ -21,14 +21,14 @@ export type Props<Key extends keyof ICUJSXMessageParamsByKeyType> = {
components: ICUJSXMessageParamsByKeyType[Key];
});
export function Intl<Key extends keyof ICUJSXMessageParamsByKeyType>({
export function I18n<Key extends keyof ICUJSXMessageParamsByKeyType>({
components,
id,
// Indirection for linter/migration tooling
i18n: localizer,
}: Props<Key>): JSX.Element | null {
if (!id) {
log.error('Error: Intl id prop not provided');
log.error('Error: <I18n> id prop not provided');
return null;
}

View file

@ -19,9 +19,14 @@ export default {
args: {
i18n,
hasInitialLoadCompleted: false,
isAlpha: false,
isCustomizingPreferredReactions: false,
},
} satisfies Meta<PropsType>;
argTypes: {
daysAgo: { control: { type: 'number' } },
isAlpha: { control: { type: 'boolean' } },
},
} satisfies Meta<PropsType & { daysAgo?: number }>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType & { daysAgo?: number }> = ({
@ -32,7 +37,7 @@ const Template: StoryFn<PropsType & { daysAgo?: number }> = ({
const [dayOffset, setDayOffset] = useState(0);
useEffect(() => {
if (daysAgo === undefined) {
if (!daysAgo) {
setDayOffset(0);
return noop;
}
@ -64,3 +69,8 @@ const Template: StoryFn<PropsType & { daysAgo?: number }> = ({
};
export const Default = Template.bind({});
export const FourDaysAgo = Template.bind({});
FourDaysAgo.args = {
daysAgo: 4,
};

View file

@ -3,6 +3,7 @@
import type { ReactNode } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import classNames from 'classnames';
import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log';
import { SECOND, DAY } from '../util/durations';
@ -13,6 +14,7 @@ export type PropsType = {
envelopeTimestamp: number | undefined;
hasInitialLoadCompleted: boolean;
i18n: LocalizerType;
isAlpha: boolean;
isCustomizingPreferredReactions: boolean;
navTabsCollapsed: boolean;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
@ -23,11 +25,14 @@ export type PropsType = {
renderStoriesTab: () => JSX.Element;
};
const PART_COUNT = 16;
export function Inbox({
firstEnvelopeTimestamp,
envelopeTimestamp,
hasInitialLoadCompleted,
i18n,
isAlpha,
isCustomizingPreferredReactions,
navTabsCollapsed,
onToggleNavTabsCollapse,
@ -89,7 +94,7 @@ export function Inbox({
}, [hasInitialLoadCompleted]);
if (!internalHasInitialLoadCompleted) {
let loadingProgress = 0;
let loadingProgress = 100;
if (
firstEnvelopeTimestamp !== undefined &&
envelopeTimestamp !== undefined
@ -122,13 +127,40 @@ export function Inbox({
}
}
let logo: JSX.Element;
if (isAlpha) {
const parts = new Array<JSX.Element>();
parts.push(
<i key="base" className="Inbox__logo__part Inbox__logo__part--base" />
);
for (let i = 0; i < PART_COUNT; i += 1) {
const isVisible = i <= (loadingProgress * PART_COUNT) / 100;
parts.push(
<i
key={i}
className={classNames({
Inbox__logo__part: true,
'Inbox__logo__part--animated':
firstEnvelopeTimestamp !== undefined && loadingProgress !== 0,
'Inbox__logo__part--segment': true,
'Inbox__logo__part--visible': isVisible,
})}
/>
);
}
logo = <div className="Inbox__logo">{parts}</div>;
} else {
logo = <div className="module-splash-screen__logo module-img--150" />;
}
return (
<div className="app-loading-screen">
<div className="module-title-bar-drag-area" />
<div className="module-splash-screen__logo module-img--150" />
{logo}
{envelopeTimestamp === undefined ? (
<div className="container">
<div className="dot-container">
<span className="dot" />
<span className="dot" />
<span className="dot" />

View file

@ -25,7 +25,7 @@ const commonProps = {
},
conversation: getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
@ -38,7 +38,7 @@ const commonProps = {
const directConversation = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
avatarUrl: undefined,
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
@ -46,7 +46,7 @@ const directConversation = getDefaultConversation({
});
const groupConversation = getDefaultConversation({
avatarPath: undefined,
avatarUrl: undefined,
name: 'Tahoe Trip',
title: 'Tahoe Trip',
type: 'group',

View file

@ -5,7 +5,7 @@ import type { ReactChild } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Avatar, AvatarSize } from './Avatar';
import { Tooltip } from './Tooltip';
import { Intl } from './Intl';
import { I18n } from './I18n';
import { Theme } from '../util/theme';
import { getParticipantName } from '../util/callingGetParticipantName';
import { ContactName } from './conversation/ContactName';
@ -28,7 +28,7 @@ export type PropsType = {
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'avatarUrl'
| 'color'
| 'id'
| 'isMe'
@ -124,7 +124,7 @@ function GroupCallMessage({
switch (otherMembersRung.length) {
case 0:
return (
<Intl
<I18n
id="icu:incomingGroupCall__ringing-you"
i18n={i18n}
components={{ ringer: ringerNode }}
@ -132,7 +132,7 @@ function GroupCallMessage({
);
case 1:
return (
<Intl
<I18n
id="icu:incomingGroupCall__ringing-1-other"
i18n={i18n}
components={{
@ -143,7 +143,7 @@ function GroupCallMessage({
);
case 2:
return (
<Intl
<I18n
id="icu:incomingGroupCall__ringing-2-others"
i18n={i18n}
components={{
@ -155,7 +155,7 @@ function GroupCallMessage({
);
case 3:
return (
<Intl
<I18n
id="icu:incomingGroupCall__ringing-3-others"
i18n={i18n}
components={{
@ -167,7 +167,7 @@ function GroupCallMessage({
);
default:
return (
<Intl
<I18n
id="icu:incomingGroupCall__ringing-many"
i18n={i18n}
components={{
@ -194,7 +194,7 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
const {
id: conversationId,
acceptedMessageRequest,
avatarPath,
avatarUrl,
color,
isMe,
phoneNumber,
@ -275,7 +275,7 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
<div className="IncomingCallBar__conversation--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}

View file

@ -18,22 +18,27 @@ import { useRefMerger } from '../hooks/useRefMerger';
import { byteLength } from '../Bytes';
export type PropsType = {
autoFocus?: boolean;
countBytes?: (value: string) => number;
countLength?: (value: string) => number;
disabled?: boolean;
disableSpellcheck?: boolean;
expandable?: boolean;
forceTextarea?: boolean;
hasClearButton?: boolean;
i18n: LocalizerType;
icon?: ReactNode;
id?: string;
maxByteCount?: number;
maxLengthCount?: number;
moduleClassName?: string;
onChange: (value: string) => unknown;
onBlur?: () => unknown;
onEnter?: () => unknown;
placeholder: string;
value?: string;
whenToShowRemainingCount?: number;
whenToWarnRemainingCount?: number;
children?: ReactNode;
};
@ -59,22 +64,27 @@ export const Input = forwardRef<
PropsType
>(function InputInner(
{
autoFocus,
countBytes = byteLength,
countLength = grapheme.count,
disabled,
disableSpellcheck,
expandable,
forceTextarea,
hasClearButton,
i18n,
icon,
id,
maxByteCount = 0,
maxLengthCount = 0,
moduleClassName,
onChange,
onBlur,
onEnter,
placeholder,
value = '',
whenToShowRemainingCount = Infinity,
whenToWarnRemainingCount = Infinity,
children,
},
ref
@ -195,16 +205,21 @@ export const Input = forwardRef<
const lengthCount = maxLengthCount ? countLength(value) : -1;
const getClassName = getClassNamesFor('Input', moduleClassName);
const isTextarea = expandable || forceTextarea;
const inputProps = {
autoFocus,
className: classNames(
getClassName('__input'),
icon && getClassName('__input--with-icon'),
isLarge && getClassName('__input--large'),
expandable && getClassName('__input--expandable')
isTextarea && getClassName('__input--textarea')
),
disabled: Boolean(disabled),
id,
spellCheck: !disableSpellcheck,
onChange: handleChange,
onBlur,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
placeholder,
@ -228,7 +243,12 @@ export const Input = forwardRef<
) : null;
const lengthCountElement = lengthCount >= whenToShowRemainingCount && (
<div className={getClassName('__remaining-count')}>
<div
className={classNames(getClassName('__remaining-count'), {
[getClassName('__remaining-count--warn')]:
lengthCount >= whenToWarnRemainingCount,
})}
>
{maxLengthCount - lengthCount}
</div>
);
@ -242,7 +262,7 @@ export const Input = forwardRef<
)}
>
{icon ? <div className={getClassName('__icon')}>{icon}</div> : null}
{expandable ? (
{isTextarea || forceTextarea ? (
<textarea dir="auto" rows={1} {...inputProps} />
) : (
<input dir="auto" {...inputProps} />

View file

@ -62,6 +62,7 @@ const defaultConversations: Array<ConversationType> = [
];
const defaultSearchProps = {
isSearchingGlobally: true,
searchConversation: undefined,
searchDisabled: false,
searchTerm: 'hello',
@ -145,13 +146,14 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
composeReplaceAvatar: action('composeReplaceAvatar'),
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
createGroup: action('createGroup'),
endConversationSearch: action('endConversationSearch'),
endSearch: action('endSearch'),
getPreferredBadge: () => undefined,
hasFailedStorySends: false,
hasPendingUpdate: false,
i18n,
isMacOS: false,
preferredWidthFromStorage: 320,
regionCode: 'US',
challengeStatus: 'idle',
crashReportCount: 0,
@ -201,6 +203,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
i18n={i18n}
socketStatus={SocketStatus.CLOSED}
isOnline={false}
isOutage={false}
manualReconnect={action('manualReconnect')}
{...overrideProps.dialogNetworkStatus}
{...props}
@ -264,6 +267,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
toast={undefined}
megaphone={undefined}
containerWidthBreakpoint={containerWidthBreakpoint}
isInFullScreenCall={false}
/>
),
selectedConversationId: undefined,
@ -608,6 +612,7 @@ export function ArchiveNoArchivedConversations(): JSX.Element {
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: [],
isSearchingGlobally: false,
searchConversation: undefined,
searchTerm: '',
startSearchCounter: 0,
@ -624,6 +629,7 @@ export function ArchiveArchivedConversations(): JSX.Element {
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: defaultConversations,
isSearchingGlobally: false,
searchConversation: undefined,
searchTerm: '',
startSearchCounter: 0,
@ -640,6 +646,7 @@ export function ArchiveSearchingAConversation(): JSX.Element {
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: defaultConversations,
isSearchingGlobally: false,
searchConversation: undefined,
searchTerm: '',
startSearchCounter: 0,

View file

@ -104,7 +104,6 @@ export type PropsType = {
preferredWidthFromStorage: number;
selectedConversationId: undefined | string;
targetedMessageId: undefined | string;
regionCode: string | undefined;
challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void;
crashReportCount: number;
@ -121,6 +120,8 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void;
endConversationSearch: () => void;
endSearch: () => void;
navTabsCollapsed: boolean;
openUsernameReservationModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
@ -184,6 +185,8 @@ export function LeftPane({
composeSaveAvatarToDisk,
crashReportCount,
createGroup,
endConversationSearch,
endSearch,
getPreferredBadge,
hasExpiredDialog,
hasFailedStorySends,
@ -635,7 +638,7 @@ export function LeftPane({
return (
<NavSidebar
title="Chats"
title={i18n('icu:LeftPane--chats')}
hideHeader={hideHeader}
i18n={i18n}
otherTabsUnreadStats={otherTabsUnreadStats}
@ -706,6 +709,8 @@ export function LeftPane({
{helper.getSearchInput({
clearConversationSearch,
clearSearch,
endConversationSearch,
endSearch,
i18n,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);

Some files were not shown because too many files have changed in this diff Show more