diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 739983805b3..b629bfaf18f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3686,6 +3686,26 @@ "messageformat": "Remove this person from the call", "description": "Button in the in-call info popup for call link calls showing all participants. The action is to remove the participant from the call." }, + "icu:CallingAdhocCallInfo__UnknownContactLabel": { + "messageformat": "{count, plural, one {# person} other {# people}}", + "description": "Label showing number of unknown contacts in the in-call participant info popup for call links." + }, + "icu:CallingAdhocCallInfo__UnknownContactLabel--in-addition": { + "messageformat": "+{count, number} more", + "description": "Label showing number of unknown contacts in the in-call participant info popup for call links, when known contacts are also present in the call." + }, + "icu:CallingAdhocCallInfo__UnknownContactInfoButton": { + "messageformat": "More info about new contacts", + "description": "Aria label for info button in the in-call participant info popup for call links when unknown contacts are in the call." + }, + "icu:CallingAdhocCallInfo__UnknownContactInfoDialogBody": { + "messageformat": "Before joining a call you can only see the names of phone contacts, people you’re in a group with, or people you’ve chatted with 1:1. You’ll see all names and photos once you’ve joined the call.", + "description": "Text for an info dialog which can be opened from the in-call participant list, which is available in call links when unknown contacts are in the call" + }, + "icu:CallingAdhocCallInfo__UnknownContactInfoDialogOk": { + "messageformat": "Got it", + "description": "Button text for info dialog which can be opened from the in-call participant list, which is available in call links when unknown contacts are in the call." + }, "icu:callingDeviceSelection__label--video": { "messageformat": "Video", "description": "Label for video input selector" diff --git a/stylesheets/components/CallingAdhocCallInfo.scss b/stylesheets/components/CallingAdhocCallInfo.scss index 0b9013260b6..e7f7881e6a1 100644 --- a/stylesheets/components/CallingAdhocCallInfo.scss +++ b/stylesheets/components/CallingAdhocCallInfo.scss @@ -52,10 +52,10 @@ .CallingAdhocCallInfo__MenuItemIcon { background: $color-gray-65; display: flex; - width: 32px; - height: 32px; + width: 36px; + height: 36px; margin-inline-end: 8px; - border-radius: 32px; + border-radius: 36px; align-items: center; justify-content: center; } @@ -93,3 +93,47 @@ margin-inline: 8px; background: $color-white; } + +.CallingAdhocCallInfo__UnknownContactInfoButton { + @include button-reset; + @include color-svg('../images/icons/v3/info/info.svg', $color-white); + display: flex; + flex: none; + width: 16px; + height: 16px; + margin-inline: 8px; +} + +.CallingAdhocCallInfo__UnknownContactInfoButton:focus { + @include keyboard-mode { + background: $color-ultramarine; + } +} + +.CallingAdhocCallInfo__UnknownContactInfoDialog__body { + padding-block-start: 22px; + padding-block-end: 8px; +} + +.CallingAdhocCallInfo__UnknownContactAvatarSet { + height: 36px; +} + +.CallingAdhocCallInfo__UnknownContactAvatar { + &:not(:first-child) { + margin-inline-start: -24px; + } + + .module-Avatar__contents { + outline: 2px solid; + outline-color: $color-gray-80; + } +} + +.CallingAdhocCallInfo + .module-calling-participants-list__contact:hover + .CallingAdhocCallInfo__UnknownContactAvatar + .module-Avatar__contents { + // Should match background of .module-calling-participants-list__contact:hover + outline-color: $color-gray-62; +} diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 3e013308cef..5052acf6578 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -38,6 +38,7 @@ export enum AvatarSize { TWENTY = 20, TWENTY_FOUR = 24, TWENTY_EIGHT = 28, + THIRTY = 30, THIRTY_TWO = 32, THIRTY_SIX = 36, FORTY = 40, @@ -86,6 +87,7 @@ export type Props = { const BADGE_PLACEMENT_BY_SIZE = new Map([ [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 }], @@ -159,7 +161,10 @@ export function Avatar({ const initials = getInitials(title); const hasImage = !noteToSelf && avatarPath && !imageBroken; const shouldUseInitials = - !hasImage && conversationType === 'direct' && Boolean(initials); + !hasImage && + conversationType === 'direct' && + Boolean(initials) && + title !== i18n('icu:unknownContact'); let contentsChildren: ReactNode; if (loading) { diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index b616216e414..06f636b3373 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -7,6 +7,7 @@ import type { Meta } from '@storybook/react'; import type { PropsType } from './CallManager'; import { CallManager } from './CallManager'; import { + type ActiveGroupCallType, CallEndedReason, CallMode, CallState, @@ -25,6 +26,12 @@ import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGr import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; import { StorySendMode } from '../types/Stories'; +import { + FAKE_CALL_LINK, + getDefaultCallLinkConversation, +} from '../test-both/helpers/fakeCallLink'; +import { allRemoteParticipants } from './CallScreen.stories'; +import { getPlaceholderContact } from '../state/selectors/conversations'; const i18n = setupI18n('en', enMessages); @@ -42,6 +49,11 @@ const getConversation = () => lastUpdated: Date.now(), }); +const getUnknownContact = (): ConversationType => ({ + ...getPlaceholderContact(), + serviceId: generateAci(), +}); + const getCommonActiveCallData = () => ({ conversation: getConversation(), joinedAt: Date.now(), @@ -69,12 +81,13 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ denyUser: action('deny-user'), getGroupCallVideoFrameSource: (_: string, demuxId: number) => fakeGetGroupCallVideoFrameSource(demuxId), + getIsSharingPhoneNumberWithEverybody: () => false, getPresentingSources: action('get-presenting-sources'), hangUpActiveCall: action('hang-up-active-call'), hasInitialLoadCompleted: true, i18n, incomingCall: null, - callLink: undefined, + callLink: storyProps.callLink ?? undefined, me: { ...getDefaultConversation({ color: AvatarColors[0], @@ -113,6 +126,38 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ pauseVoiceNotePlayer: action('pause-audio-player'), }); +const getActiveCallForCallLink = ( + overrideProps: Partial = {} +): 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: GroupCallConnectionState.NotConnected, + conversationsByDemuxId: new Map(), + deviceCount: 0, + joinState: GroupCallJoinState.NotJoined, + localDemuxId: 1, + maxDevices: 5, + groupMembers: [], + isConversationTooBigToRing: false, + peekedParticipants: + overrideProps.peekedParticipants ?? allRemoteParticipants.slice(0, 3), + remoteParticipants: overrideProps.remoteParticipants ?? [], + pendingParticipants: [], + raisedHands: new Set(), + remoteAudioLevels: new Map(), + }; +}; + export default { title: 'Components/CallManager', argTypes: {}, @@ -226,3 +271,93 @@ export function CallRequestNeeded(): JSX.Element { /> ); } + +export function CallLinkLobbyParticipantsKnown(): JSX.Element { + return ( + + ); +} + +export function CallLinkLobbyParticipants1Unknown(): JSX.Element { + return ( + + ); +} + +export function CallLinkLobbyParticipants1Known1Unknown(): JSX.Element { + return ( + + ); +} + +export function CallLinkLobbyParticipants1Known2Unknown(): JSX.Element { + return ( + + ); +} + +export function CallLinkLobbyParticipants1Known12Unknown(): JSX.Element { + const peekedParticipants: Array = [ + allRemoteParticipants[0], + ]; + for (let n = 12; n > 0; n -= 1) { + peekedParticipants.push(getUnknownContact()); + } + return ( + + ); +} + +export function CallLinkLobbyParticipants3Unknown(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 422bcadee2e..30f38a3a36f 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -53,7 +53,6 @@ import * as log from '../logging/log'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl'; -import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode'; import { usePrevious } from '../hooks/usePrevious'; import { copyCallLink } from '../util/copyLinksWithToast'; @@ -90,6 +89,7 @@ export type PropsType = { conversationId: string, demuxId: number ) => VideoFrameSource; + getIsSharingPhoneNumberWithEverybody: () => boolean; getPresentingSources: () => void; incomingCall: DirectIncomingCall | GroupIncomingCall | null; renderDeviceSelection: () => JSX.Element; @@ -146,7 +146,6 @@ type ActiveCallManagerPropsType = { | 'declineCall' | 'hasInitialLoadCompleted' | 'incomingCall' - | 'isConversationTooBigToRin' | 'notifyForCall' | 'playRingtone' | 'setIsCallActive' @@ -165,6 +164,7 @@ function ActiveCallManager({ denyUser, hangUpActiveCall, i18n, + getIsSharingPhoneNumberWithEverybody, getGroupCallVideoFrameSource, getPresentingSources, me, @@ -349,7 +349,9 @@ function ActiveCallManager({ isAdhocJoinRequestPending={isAdhocJoinRequestPending} isCallFull={isCallFull} isConversationTooBigToRing={isConvoTooBigToRing} - isSharingPhoneNumberWithEverybody={isSharingPhoneNumberWithEverybody()} + getIsSharingPhoneNumberWithEverybody={ + getIsSharingPhoneNumberWithEverybody + } me={me} onCallCanceled={cancelActiveCall} onJoinCall={joinActiveCall} @@ -501,6 +503,7 @@ export function CallManager({ i18n, incomingCall, isConversationTooBigToRing, + getIsSharingPhoneNumberWithEverybody, me, notifyForCall, openSystemPreferencesAction, @@ -589,6 +592,9 @@ export function CallManager({ getPresentingSources={getPresentingSources} hangUpActiveCall={hangUpActiveCall} i18n={i18n} + getIsSharingPhoneNumberWithEverybody={ + getIsSharingPhoneNumberWithEverybody + } me={me} openSystemPreferencesAction={openSystemPreferencesAction} pauseVoiceNotePlayer={pauseVoiceNotePlayer} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 321c3577df9..d6e6a09f98e 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -235,6 +235,7 @@ export default { title: 'Components/CallScreen', argTypes: {}, args: {}, + excludeStories: ['allRemoteParticipants'], } satisfies Meta; export function Default(): JSX.Element { @@ -378,7 +379,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 { diff --git a/ts/components/CallingAdhocCallInfo.tsx b/ts/components/CallingAdhocCallInfo.tsx index 4d5c501806d..28e5a4fd3a4 100644 --- a/ts/components/CallingAdhocCallInfo.tsx +++ b/ts/components/CallingAdhocCallInfo.tsx @@ -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'; @@ -17,6 +16,12 @@ import type { ConversationType } from '../state/ducks/conversations'; import { ModalHost } from './ModalHost'; import { isInSystemContacts } from '../util/isInSystemContacts'; import type { RemoveClientType } from '../state/ducks/calling'; +import { AvatarColors } from '../types/Colors'; +import { Button } from './Button'; +import { Modal } from './Modal'; +import { Theme } from '../util/theme'; + +const MAX_UNKNOWN_AVATARS_COUNT = 3; type ParticipantType = ConversationType & { hasRemoteAudio?: boolean; @@ -37,6 +42,99 @@ export type PropsType = { readonly removeClient: ((payload: RemoveClientType) => void) | null; }; +type UnknownContactsPropsType = { + readonly i18n: LocalizerType; + readonly isInAdditionToKnownContacts: boolean; + readonly participants: Array; + 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) % + AvatarColors.length + : 0; + return ( + + ); + }, + [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 ( +
  • +
    +
    + {visibleParticipants.map((participant, key) => + renderUnknownAvatar({ participant, key, size: avatarSize }) + )} +
    + {i18n( + isInAdditionToKnownContacts + ? 'icu:CallingAdhocCallInfo__UnknownContactLabel--in-addition' + : 'icu:CallingAdhocCallInfo__UnknownContactLabel', + { count: participants.length } + )} +
    +
    +
    +
  • + ); +} + export function CallingAdhocCallInfo({ i18n, isCallLinkAdmin, @@ -46,141 +144,188 @@ export function CallingAdhocCallInfo({ onCopyCallLink, removeClient, }: PropsType): JSX.Element | null { - const sortedParticipants = React.useMemo>( - () => sortByTitle(participants), + const [isUnknownContactDialogVisible, setIsUnknownContactDialogVisible] = + React.useState(false); + const hideUnknownContactDialog = React.useCallback( + () => setIsUnknownContactDialogVisible(false), + [setIsUnknownContactDialogVisible] + ); + + const [knownParticipants, unknownParticipants] = React.useMemo< + [Array, Array] + >( + () => + partition(participants, (participant: ParticipantType) => + Boolean(participant.titleNoDefault) + ), [participants] ); + const sortedParticipants = React.useMemo>( + () => sortByTitle(knownParticipants), + [knownParticipants] + ); + + const renderParticipant = React.useCallback( + (participant: ParticipantType, key: React.Key) => ( +
  • +
    + + {ourServiceId && participant.serviceId === ourServiceId ? ( + + {i18n('icu:you')} + + ) : ( + <> + + {isInSystemContacts(participant) ? ( + + {' '} + + + ) : null} + + )} +
    + + + + {isCallLinkAdmin && + removeClient && + participant.demuxId && + !(ourServiceId && participant.serviceId === ourServiceId) ? ( +
  • + ), + [i18n, isCallLinkAdmin, ourServiceId, removeClient] + ); return ( - -
    -
    -
    - {participants.length - ? i18n('icu:calling__in-this-call', { - people: participants.length, - }) - : i18n('icu:calling__in-this-call--zero')} + <> + {isUnknownContactDialogVisible ? ( + + {i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogOk')} + + } + onClose={hideUnknownContactDialog} + theme={Theme.Dark} + > + {i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogBody')} + + ) : null} + +
    +
    +
    + {participants.length + ? i18n('icu:calling__in-this-call', { + people: participants.length, + }) + : i18n('icu:calling__in-this-call--zero')} +
    +
    +
      + {sortedParticipants.map(renderParticipant)} + {unknownParticipants.length > 0 && ( + + setIsUnknownContactDialogVisible(true) + } + /> + )} +
    +
    +
    +
    -
    -
      - {sortedParticipants.map( - (participant: ParticipantType, index: number) => ( -
    • -
      - - {ourServiceId && participant.serviceId === ourServiceId ? ( - - {i18n('icu:you')} - - ) : ( - <> - - {isInSystemContacts(participant) ? ( - - {' '} - - - ) : null} - - )} -
      - - - - {isCallLinkAdmin && - removeClient && - participant.demuxId && - !(ourServiceId && participant.serviceId === ourServiceId) ? ( -
    • - ) - )} -
    -
    -
    - -
    -
    - + + ); } diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index 983ee1c8355..c164b3ab0d1 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -70,8 +70,8 @@ const createProps = (overrideProps: Partial = {}): PropsType => { isAdhocJoinRequestPending: overrideProps.isAdhocJoinRequestPending ?? false, isConversationTooBigToRing: false, isCallFull: overrideProps.isCallFull ?? false, - isSharingPhoneNumberWithEverybody: - overrideProps.isSharingPhoneNumberWithEverybody ?? false, + getIsSharingPhoneNumberWithEverybody: + overrideProps.getIsSharingPhoneNumberWithEverybody ?? (() => false), me: overrideProps.me || getDefaultConversation({ diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index d49cc90ff97..eaa2507d306 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -51,6 +51,7 @@ export type PropsType = { | 'type' | 'unblurredAvatarPath' >; + getIsSharingPhoneNumberWithEverybody: () => boolean; groupMembers?: Array< Pick< ConversationType, @@ -64,7 +65,6 @@ export type PropsType = { isAdhocJoinRequestPending: boolean; isConversationTooBigToRing: boolean; isCallFull?: boolean; - isSharingPhoneNumberWithEverybody: boolean; me: Readonly< Pick >; @@ -94,7 +94,7 @@ export function CallingLobby({ isAdhocJoinRequestPending, isCallFull = false, isConversationTooBigToRing, - isSharingPhoneNumberWithEverybody, + getIsSharingPhoneNumberWithEverybody, me, onCallCanceled, onJoinCall, @@ -333,7 +333,7 @@ export function CallingLobby({
    ) : (
    - {isSharingPhoneNumberWithEverybody + {getIsSharingPhoneNumberWithEverybody() ? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing') : i18n('icu:CallingLobby__CallLinkNotice')}
    diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 9bacf5fab1d..f99c95fe333 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -53,6 +53,7 @@ import { getIntl } from '../selectors/user'; import { SmartCallingDeviceSelection } from './CallingDeviceSelection'; import { renderEmojiPicker } from './renderEmojiPicker'; import { renderReactionPicker } from './renderReactionPicker'; +import { isSharingPhoneNumberWithEverybody as getIsSharingPhoneNumberWithEverybody } from '../../util/phoneNumberSharingMode'; function renderDeviceSelection(): JSX.Element { return ; @@ -468,6 +469,9 @@ export const SmartCallManager = memo(function SmartCallManager() { declineCall={declineCall} denyUser={denyUser} getGroupCallVideoFrameSource={getGroupCallVideoFrameSource} + getIsSharingPhoneNumberWithEverybody={ + getIsSharingPhoneNumberWithEverybody + } getPresentingSources={getPresentingSources} hangUpActiveCall={hangUpActiveCall} hasInitialLoadCompleted={hasInitialLoadCompleted}