Merge branch 'main' into HEAD
This commit is contained in:
commit
d57d0cea19
1135 changed files with 264116 additions and 302492 deletions
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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) {
|
||||
|
|
56
ts/components/AutoSizeTextArea.tsx
Normal file
56
ts/components/AutoSizeTextArea.tsx
Normal 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 />;
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}')`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
28
ts/components/CallLinkAddNameModal.stories.tsx
Normal file
28
ts/components/CallLinkAddNameModal.stories.tsx
Normal 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} />;
|
||||
}
|
120
ts/components/CallLinkAddNameModal.tsx
Normal file
120
ts/components/CallLinkAddNameModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
149
ts/components/CallLinkDetails.tsx
Normal file
149
ts/components/CallLinkDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
ts/components/CallLinkEditModal.stories.tsx
Normal file
32
ts/components/CallLinkEditModal.stories.tsx
Normal 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} />;
|
||||
}
|
200
ts/components/CallLinkEditModal.tsx
Normal file
200
ts/components/CallLinkEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
ts/components/CallLinkRestrictionsSelect.tsx
Normal file
44
ts/components/CallLinkRestrictionsSelect.tsx
Normal 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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>,
|
||||
|
|
91
ts/components/CallingPendingParticipants.stories.tsx
Normal file
91
ts/components/CallingPendingParticipants.stories.tsx
Normal 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),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
352
ts/components/CallingPendingParticipants.tsx
Normal file
352
ts/components/CallingPendingParticipants.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>(),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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} ·{' '}
|
||||
<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}
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
|
|
59
ts/components/ConfirmLeaveCallModal.tsx
Normal file
59
ts/components/ConfirmLeaveCallModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -38,7 +38,7 @@ const contactPillProps = (
|
|||
): ContactPillPropsType => ({
|
||||
...(overrideProps ??
|
||||
getDefaultConversation({
|
||||
avatarPath: gifUrl,
|
||||
avatarUrl: gifUrl,
|
||||
firstName: 'John',
|
||||
id: 'abc123',
|
||||
isMe: false,
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -193,6 +193,7 @@ export function CustomizingPreferredReactionsModal({
|
|||
onClose={() => {
|
||||
deselectDraftEmoji();
|
||||
}}
|
||||
wasInvokedFromKeyboard={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
78
ts/components/DeleteMessagesModal.stories.tsx
Normal file
78
ts/components/DeleteMessagesModal.stories.tsx
Normal 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,
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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],
|
||||
|
|
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal file
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal 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} />;
|
||||
}
|
190
ts/components/EditNicknameAndNoteModal.tsx
Normal file
190
ts/components/EditNicknameAndNoteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: '😛' }}
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue