// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { sample, shuffle, times } from 'lodash'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { ActiveCallReactionsType, ActiveGroupCallType, GroupCallRemoteParticipantType, } from '../types/Calling'; import { CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; import { generateAci } from '../types/ServiceId'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import type { PropsType } from './CallScreen'; import { CallScreen as UnwrappedCallScreen } from './CallScreen'; import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants'; import { setupI18n } from '../util/setupI18n'; import { missingCaseError } from '../util/missingCaseError'; import { getDefaultConversation, getDefaultConversationWithServiceId, } from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import enMessages from '../../_locales/en/messages.json'; import { CallingToastProvider, useCallingToasts } from './CallingToast'; const MAX_PARTICIPANTS = 75; const LOCAL_DEMUX_ID = 1; const i18n = setupI18n('en', enMessages); const conversation = getDefaultConversation({ id: '3051234567', avatarPath: undefined, color: AvatarColors[0], title: 'Rick Sanchez', name: 'Rick Sanchez', phoneNumber: '3051234567', profileName: 'Rick Sanchez', }); type OverridePropsBase = { hasLocalAudio?: boolean; hasLocalVideo?: boolean; localAudioLevel?: number; viewMode?: CallViewMode; reactions?: ActiveCallReactionsType; }; type DirectCallOverrideProps = OverridePropsBase & { callMode: CallMode.Direct; callState?: CallState; hasRemoteVideo?: boolean; }; type GroupCallOverrideProps = OverridePropsBase & { callMode: CallMode.Group; connectionState?: GroupCallConnectionState; peekedParticipants?: Array; raisedHands?: Set; remoteParticipants?: Array; remoteAudioLevel?: number; }; const createActiveDirectCallProp = ( overrideProps: DirectCallOverrideProps ) => ({ callMode: CallMode.Direct as CallMode.Direct, conversation, callState: overrideProps.callState ?? CallState.Accepted, peekedParticipants: [] as [], remoteParticipants: [ { hasRemoteVideo: overrideProps.hasRemoteVideo ?? false, presenting: false, title: 'test', }, ] as [ { hasRemoteVideo: boolean; presenting: boolean; title: string; } ], }); const getConversationsByDemuxId = (overrideProps: GroupCallOverrideProps) => { const conversationsByDemuxId = new Map( overrideProps.remoteParticipants?.map((participant, index) => [ participant.demuxId, getDefaultConversationWithServiceId({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, title: `Participant ${index + 1}`, }), ]) ); conversationsByDemuxId.set(LOCAL_DEMUX_ID, conversation); return conversationsByDemuxId; }; const getRaisedHands = (overrideProps: GroupCallOverrideProps) => { if (!overrideProps.remoteParticipants) { return; } return new Set( overrideProps.remoteParticipants .filter(participant => participant.isHandRaised) .map(participant => participant.demuxId) ); }; 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, maxDevices: 5, deviceCount: (overrideProps.remoteParticipants || []).length, groupMembers: overrideProps.remoteParticipants || [], // Because remote participants are a superset, we can use them in place of peeked // participants. isConversationTooBigToRing: false, peekedParticipants: overrideProps.peekedParticipants || overrideProps.remoteParticipants || [], raisedHands: overrideProps.raisedHands || getRaisedHands(overrideProps) || new Set(), remoteParticipants: overrideProps.remoteParticipants || [], remoteAudioLevels: new Map( overrideProps.remoteParticipants?.map((_participant, index) => [ index, overrideProps.remoteAudioLevel ?? 0, ]) ), reactions: overrideProps.reactions || [], }); const createActiveCallProp = ( overrideProps: DirectCallOverrideProps | GroupCallOverrideProps ) => { const baseResult = { joinedAt: Date.now(), conversation, hasLocalAudio: overrideProps.hasLocalAudio ?? false, hasLocalVideo: overrideProps.hasLocalVideo ?? false, localAudioLevel: overrideProps.localAudioLevel ?? 0, viewMode: overrideProps.viewMode ?? CallViewMode.Overflow, outgoingRing: true, pip: false, settingsDialogOpen: false, showParticipantsList: false, }; switch (overrideProps.callMode) { case CallMode.Direct: return { ...baseResult, ...createActiveDirectCallProp(overrideProps) }; case CallMode.Group: return { ...baseResult, ...createActiveGroupCallProp(overrideProps) }; default: throw missingCaseError(overrideProps); } }; const createProps = ( overrideProps: DirectCallOverrideProps | GroupCallOverrideProps = { callMode: CallMode.Direct as CallMode.Direct, } ): PropsType => ({ activeCall: createActiveCallProp(overrideProps), changeCallView: action('change-call-view'), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getPresentingSources: action('get-presenting-sources'), hangUpActiveCall: action('hang-up'), i18n, isGroupCallRaiseHandEnabled: true, isGroupCallReactionsEnabled: true, me: getDefaultConversation({ color: AvatarColors[1], id: '6146087e-f7ef-457e-9a8d-47df1fdd6b25', name: 'Morty Smith', profileName: 'Morty Smith', title: 'Morty Smith', serviceId: generateAci(), }), openSystemPreferencesAction: action('open-system-preferences-action'), renderEmojiPicker: () => <>EmojiPicker, renderReactionPicker: () =>
, sendGroupCallRaiseHand: action('send-group-call-raise-hand'), sendGroupCallReaction: action('send-group-call-reaction'), setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), stickyControls: false, switchToPresentationView: action('switch-to-presentation-view'), switchFromPresentationView: action('switch-from-presentation-view'), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleScreenRecordingPermissionsDialog: action( 'toggle-screen-recording-permissions-dialog' ), toggleSettings: action('toggle-settings'), }); function CallScreen(props: ReturnType): JSX.Element { return ( ); } export default { title: 'Components/CallScreen', argTypes: {}, args: {}, } satisfies Meta; export function Default(): JSX.Element { return ; } export function PreRing(): JSX.Element { return ( ); } export function Ringing(): JSX.Element { return ( ); } export function Reconnecting(): JSX.Element { return ( ); } export function Ended(): JSX.Element { return ( ); } export function HasLocalAudio(): JSX.Element { return ( ); } export function HasLocalVideo(): JSX.Element { return ( ); } export function HasRemoteVideo(): JSX.Element { return ( ); } export function GroupCall1(): JSX.Element { return ( ); } export function GroupCallYourHandRaised(): JSX.Element { return ( ); } // We generate these upfront so that the list is stable when you move the slider. const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ aci: generateAci(), demuxId: index, hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, isHandRaised: (index - 3) % 10 === 0, presenting: false, sharingScreen: false, videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6, ...getDefaultConversationWithServiceId({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, title: `Participant ${index + 1}`, }), })); export function GroupCallManyPaginated(): JSX.Element { const props = createProps({ callMode: CallMode.Group, remoteParticipants: allRemoteParticipants, viewMode: CallViewMode.Paginated, }); return ; } export function GroupCallManyPaginatedEveryoneTalking(): JSX.Element { const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants: allRemoteParticipants, viewMode: CallViewMode.Paginated, }) ); const activeCall = useMakeEveryoneTalk( props.activeCall as ActiveGroupCallType ); return ; } export function GroupCallManyOverflow(): JSX.Element { return ( ); } export function GroupCallManyOverflowEveryoneTalking(): JSX.Element { const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants: allRemoteParticipants, viewMode: CallViewMode.Overflow, }) ); const activeCall = useMakeEveryoneTalk( props.activeCall as ActiveGroupCallType ); return ; } export function GroupCallSpeakerView(): JSX.Element { return ( ); } export function GroupCallReconnecting(): JSX.Element { return ( ); } export function GroupCall0(): JSX.Element { return ( ); } export function GroupCallSomeoneIsSharingScreen(): JSX.Element { return ( ({ ...participant, presenting: index === 1, sharingScreen: index === 1, })), })} /> ); } export function GroupCallSomeoneIsSharingScreenAndYoureReconnecting(): JSX.Element { return ( ({ ...participant, presenting: index === 1, sharingScreen: index === 1, })), })} /> ); } export function GroupCallSomeoneStoppedSharingScreen(): JSX.Element { const [remoteParticipants, setRemoteParticipants] = React.useState( allRemoteParticipants.slice(0, 5).map((participant, index) => ({ ...participant, presenting: index === 1, sharingScreen: index === 1, })) ); React.useEffect(() => { setTimeout( () => setRemoteParticipants(allRemoteParticipants.slice(0, 5)), 1000 ); }); return ( ); } function ToastEmitter(): null { const { showToast } = useCallingToasts(); const toastCount = React.useRef(0); React.useEffect(() => { const interval = setInterval(() => { const autoClose = toastCount.current % 2 === 0; showToast({ key: Date.now().toString(), content: `${ autoClose ? 'Disappearing' : 'Non-disappearing' } toast sent: ${Date.now()}`, dismissable: true, autoClose, }); toastCount.current += 1; }, 1500); return () => clearInterval(interval); }, [showToast]); return null; } export function CallScreenToastAPalooza(): JSX.Element { return ( ); } function useMakeEveryoneTalk( activeCall: ActiveGroupCallType, frequency = 2000 ) { const [call, setCall] = React.useState(activeCall); React.useEffect(() => { const interval = setInterval(() => { const idxToStartSpeaking = Math.floor( Math.random() * call.remoteParticipants.length ); const demuxIdToStartSpeaking = ( call.remoteParticipants[ idxToStartSpeaking ] as GroupCallRemoteParticipantType ).demuxId; const remoteAudioLevels = new Map(); for (const [demuxId] of call.remoteAudioLevels.entries()) { if (demuxId === demuxIdToStartSpeaking) { remoteAudioLevels.set(demuxId, 1); } else { remoteAudioLevels.set(demuxId, 0); } } setCall(state => ({ ...state, remoteParticipants: state.remoteParticipants.map((part, idx) => { return { ...part, hasRemoteAudio: idx === idxToStartSpeaking ? true : part.hasRemoteAudio, speakerTime: idx === idxToStartSpeaking ? Date.now() : (part as GroupCallRemoteParticipantType).speakerTime, }; }), remoteAudioLevels, })); }, frequency); return () => clearInterval(interval); }, [frequency, call]); return call; } export function GroupCallReactions(): JSX.Element { const remoteParticipants = allRemoteParticipants.slice(0, 5); const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants, viewMode: CallViewMode.Overflow, }) ); const activeCall = useReactionsEmitter( props.activeCall as ActiveGroupCallType ); return ; } export function GroupCallReactionsSpam(): JSX.Element { const remoteParticipants = allRemoteParticipants.slice(0, 5); const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants, viewMode: CallViewMode.Overflow, }) ); const activeCall = useReactionsEmitter( props.activeCall as ActiveGroupCallType, 250 ); return ; } export function GroupCallReactionsBurstInOrder(): JSX.Element { const timestamp = Date.now(); const remoteParticipants = allRemoteParticipants.slice(0, 5); const reactions = remoteParticipants.map((participant, i) => { const { demuxId } = participant; const value = DEFAULT_PREFERRED_REACTION_EMOJI[ i % DEFAULT_PREFERRED_REACTION_EMOJI.length ]; return { timestamp, demuxId, value }; }); const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants, viewMode: CallViewMode.Overflow, reactions, }) ); return ; } function useReactionsEmitter( activeCall: ActiveGroupCallType, frequency = 2000, removeAfter = 5000 ) { const [call, setCall] = React.useState(activeCall); React.useEffect(() => { const interval = setInterval(() => { setCall(state => { const timeNow = Date.now(); const expireAt = timeNow - removeAfter; const participantIndex = Math.floor( Math.random() * call.remoteParticipants.length ); const { demuxId } = call.remoteParticipants[participantIndex]; const reactions: ActiveCallReactionsType = [ ...(state.reactions ?? []).filter( ({ timestamp }) => timestamp > expireAt ), { timestamp: timeNow, demuxId, value: sample(DEFAULT_PREFERRED_REACTION_EMOJI) as string, }, ]; return { ...state, reactions, }; }); }, frequency); return () => clearInterval(interval); }, [frequency, removeAfter, call]); return call; } export function GroupCallHandRaising(): JSX.Element { const remoteParticipants = allRemoteParticipants.slice(0, 10); const [props] = React.useState( createProps({ callMode: CallMode.Group, remoteParticipants, viewMode: CallViewMode.Overflow, }) ); const activeCall = useHandRaiser(props.activeCall as ActiveGroupCallType); return ; } // Every [frequency] ms, all hands are lowered and [random min to max] random hands // are raised function useHandRaiser( activeCall: ActiveGroupCallType, frequency = 3000, min = 0, max = 5 ) { const [call, setCall] = React.useState(activeCall); React.useEffect(() => { const interval = setInterval(() => { setCall(state => { const participantsCount = call.remoteParticipants.length; const usableMax = Math.min(max, participantsCount); const raiseCount = Math.floor(min + (usableMax - min) * Math.random()); const participantIndices = shuffle( Array.from(Array(participantsCount).keys()) ).slice(0, raiseCount); const participantIndicesSet = new Set(participantIndices); const remoteParticipants = [...call.remoteParticipants].map( (participant, index) => { return { ...participant, isHandRaised: participantIndicesSet.has(index), }; } ); const raisedHands = new Set( participantIndices.map( index => call.remoteParticipants[index].demuxId ) ); return { ...state, remoteParticipants, raisedHands, }; }); }, frequency); return () => clearInterval(interval); }, [frequency, call, max, min]); return call; }