signal-desktop/ts/components/CallingRaisedHandsList.tsx
2024-07-11 12:44:09 -07:00

273 lines
8.1 KiB
TypeScript

// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { animated, useSpring } from '@react-spring/web';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import type { ConversationsByDemuxIdType } from '../types/Calling';
import type { ServiceIdString } from '../types/ServiceId';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost';
import { drop } from '../util/drop';
import * as log from '../logging/log';
import { usePrevious } from '../hooks/usePrevious';
export type PropsType = {
readonly i18n: LocalizerType;
readonly onClose: () => void;
readonly onLowerMyHand: () => void;
readonly localDemuxId: number | undefined;
readonly conversationsByDemuxId: ConversationsByDemuxIdType;
readonly raisedHands: Set<number>;
readonly localHandRaised: boolean;
};
export function CallingRaisedHandsList({
i18n,
onClose,
onLowerMyHand,
localDemuxId,
conversationsByDemuxId,
raisedHands,
localHandRaised,
}: PropsType): JSX.Element | null {
const ourServiceId: ServiceIdString | undefined = localDemuxId
? conversationsByDemuxId.get(localDemuxId)?.serviceId
: undefined;
const participants = React.useMemo<Array<ConversationType>>(() => {
const serviceIds: Set<ServiceIdString> = new Set();
const conversations: Array<ConversationType> = [];
raisedHands.forEach(demuxId => {
const conversation = conversationsByDemuxId.get(demuxId);
if (!conversation) {
log.warn(
'CallingRaisedHandsList: Failed to get conversationsByDemuxId for demuxId',
{ demuxId }
);
return;
}
const { serviceId } = conversation;
if (serviceId) {
if (serviceIds.has(serviceId)) {
return;
}
serviceIds.add(serviceId);
}
conversations.push(conversation);
});
return conversations;
}, [raisedHands, conversationsByDemuxId]);
return (
<ModalHost
modalName="CallingRaisedHandsList"
moduleClassName="CallingRaisedHandsList"
onClose={onClose}
>
<div className="CallingRaisedHandsList module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{i18n('icu:CallingRaisedHandsList__Title', {
count: participants.length,
})}
{participants.length > 1 ? (
<span className="CallingRaisedHandsList__TitleHint">
{' '}
{i18n('icu:CallingRaisedHandsList__TitleHint')}
</span>
) : null}
</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">
{participants.map((participant: ConversationType, index: number) => (
<li
className="module-calling-participants-list__contact"
key={participant.serviceId ?? index}
>
<div className="CallingRaisedHandsList__AvatarAndName 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_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}
/>
)}
</div>
{localHandRaised &&
ourServiceId &&
participant.serviceId === ourServiceId && (
<button
className="CallingRaisedHandsList__LowerMyHandLink"
type="button"
onClick={onLowerMyHand}
>
{i18n('icu:CallControls__RaiseHands--lower')}
</button>
)}
<div className="module-calling-participants-list__status-icon CallingRaisedHandsList__NameHandIcon" />
</li>
))}
</ul>
</div>
</ModalHost>
);
}
const BUTTON_OPACITY_SPRING_CONFIG = {
mass: 1,
tension: 210,
friction: 20,
precision: 0.01,
clamp: true,
} as const;
const BUTTON_SCALE_SPRING_CONFIG = {
mass: 1.5,
tension: 230,
friction: 8,
precision: 0.02,
velocity: 0.0025,
} as const;
export type CallingRaisedHandsListButtonPropsType = {
i18n: LocalizerType;
raisedHandsCount: number;
syncedLocalHandRaised: boolean;
onClick: () => void;
};
export function CallingRaisedHandsListButton({
i18n,
syncedLocalHandRaised,
raisedHandsCount,
onClick,
}: CallingRaisedHandsListButtonPropsType): JSX.Element | null {
const [isVisible, setIsVisible] = React.useState(raisedHandsCount > 0);
const [opacitySpringProps, opacitySpringApi] = useSpring(
{
from: { opacity: 0 },
to: { opacity: 1 },
config: BUTTON_OPACITY_SPRING_CONFIG,
},
[]
);
const [scaleSpringProps, scaleSpringApi] = useSpring(
{
from: { scale: 0.9 },
to: { scale: 1 },
config: BUTTON_SCALE_SPRING_CONFIG,
},
[]
);
const prevRaisedHandsCount = usePrevious(raisedHandsCount, raisedHandsCount);
const prevSyncedLocalHandRaised = usePrevious(
syncedLocalHandRaised,
syncedLocalHandRaised
);
const onRestAfterAnimateOut = React.useCallback(() => {
if (!raisedHandsCount) {
setIsVisible(false);
}
}, [raisedHandsCount]);
React.useEffect(() => {
if (raisedHandsCount > prevRaisedHandsCount) {
setIsVisible(true);
opacitySpringApi.stop();
drop(Promise.all(opacitySpringApi.start({ opacity: 1 })));
scaleSpringApi.stop();
drop(
Promise.all(
scaleSpringApi.start({
from: { scale: 0.99 },
to: { scale: 1 },
config: { velocity: 0.0025 },
})
)
);
} else if (raisedHandsCount === 0) {
opacitySpringApi.stop();
drop(
Promise.all(
opacitySpringApi.start({
to: { opacity: 0 },
onRest: () => onRestAfterAnimateOut,
})
)
);
}
}, [
raisedHandsCount,
prevRaisedHandsCount,
opacitySpringApi,
scaleSpringApi,
onRestAfterAnimateOut,
setIsVisible,
]);
if (!isVisible) {
return null;
}
// When the last hands are lowered, maintain the last count while fading out to prevent
// abrupt label changes.
let shownSyncedLocalHandRaised: boolean = syncedLocalHandRaised;
let shownRaisedHandsCount: number = raisedHandsCount;
if (raisedHandsCount === 0 && prevRaisedHandsCount) {
shownRaisedHandsCount = prevRaisedHandsCount;
shownSyncedLocalHandRaised = prevSyncedLocalHandRaised;
}
return (
<animated.button
className="CallingRaisedHandsList__Button"
onClick={onClick}
style={{ ...opacitySpringProps, ...scaleSpringProps }}
type="button"
>
<span className="CallingRaisedHandsList__ButtonIcon" />
{shownSyncedLocalHandRaised ? (
<>
{i18n('icu:you')}
{shownRaisedHandsCount > 1 &&
` + ${String(shownRaisedHandsCount - 1)}`}
</>
) : (
shownRaisedHandsCount
)}
</animated.button>
);
}