Don't access RemoteConfig directly from 'dumb' components

This commit is contained in:
Scott Nonnenberg 2022-10-24 13:46:36 -07:00 committed by GitHub
parent e79380b37c
commit 0134990275
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 352 additions and 353 deletions

View file

@ -8,7 +8,7 @@ import {
getStickersPath,
getTempPath,
getDraftPath,
} from '../ts/util/attachments';
} from './attachments';
let initialized = false;

View file

@ -1,25 +1,77 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join, relative } from 'path';
import { join, relative, normalize } from 'path';
import fastGlob from 'fast-glob';
import glob from 'glob';
import pify from 'pify';
import fse from 'fs-extra';
import { map } from 'lodash';
import { map, isString } from 'lodash';
import normalizePath from 'normalize-path';
import { isPathInside } from '../ts/util/isPathInside';
import {
getPath,
getStickersPath,
getBadgesPath,
getDraftPath,
getTempPath,
createDeleter,
} from '../ts/util/attachments';
const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex';
const BADGES_PATH = 'badges.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const UPDATE_CACHE_PATH = 'update-cache';
const DRAFT_PATH = 'drafts.noindex';
export * from '../ts/util/attachments';
const CACHED_PATHS = new Map<string, string>();
const createPathGetter =
(subpath: string) =>
(userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
const naivePath = join(userDataPath, subpath);
const cached = CACHED_PATHS.get(naivePath);
if (cached) {
return cached;
}
let result = naivePath;
if (fse.pathExistsSync(naivePath)) {
result = fse.realpathSync(naivePath);
}
CACHED_PATHS.set(naivePath, result);
return result;
};
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
export const getBadgesPath = createPathGetter(BADGES_PATH);
export const getDraftPath = createPathGetter(DRAFT_PATH);
export const getPath = createPathGetter(PATH);
export const getStickersPath = createPathGetter(STICKER_PATH);
export const getTempPath = createPathGetter(TEMP_PATH);
export const getUpdateCachePath = createPathGetter(UPDATE_CACHE_PATH);
export const createDeleter = (
root: string
): ((relativePath: string) => Promise<void>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.remove(absolutePath);
};
};
export const getAllAttachments = async (
userDataPath: string

View file

@ -17,7 +17,7 @@ import {
getStickersPath,
getTempPath,
getUpdateCachePath,
} from '../ts/util/attachments';
} from './attachments';
type CallbackType = (response: string | ProtocolResponse) => void;

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEnabled } from '../RemoteConfig';
import { getEnvironment, Environment } from '../environment';
import { isBeta } from '../util/version';
export function shouldShowBadges(): boolean {
if (
isEnabled('desktop.showUserBadges2') ||
isEnabled('desktop.internalUser') ||
getEnvironment() === Environment.Staging ||
getEnvironment() === Environment.Development ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Boolean((window as any).STORYBOOK_ENV)
) {
return true;
}
if (isEnabled('desktop.showUserBadges.beta') && isBeta(window.getVersion())) {
return true;
}
return false;
}

View file

@ -25,7 +25,6 @@ import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath
import { getInitials } from '../util/getInitials';
import { isBadgeVisible } from '../badges/isBadgeVisible';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
import { shouldShowBadges } from '../badges/shouldShowBadges';
export enum AvatarBlur {
NoBlur,
@ -248,14 +247,7 @@ export const Avatar: FunctionComponent<Props> = ({
let badgeNode: ReactNode;
const badgeSize = _getBadgeSize(size);
if (
badge &&
theme &&
!noteToSelf &&
badgeSize &&
isBadgeVisible(badge) &&
shouldShowBadges()
) {
if (badge && theme && !noteToSelf && badgeSize && isBadgeVisible(badge)) {
const badgePlacement = _getBadgePlacement(size);
const badgeTheme =
theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark;

View file

@ -151,6 +151,7 @@ export const OngoingGroupCall = (): JSX.Element => (
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
@ -234,6 +235,7 @@ export const GroupCallSafetyNumberChanged = (): JSX.Element => (
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),

View file

@ -197,6 +197,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
let groupMembers:
| undefined
| Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
let isConversationTooBigToRing = false;
switch (activeCall.callMode) {
case CallMode.Direct: {
@ -222,6 +223,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
case CallMode.Group: {
showCallLobby = activeCall.joinState !== GroupCallJoinState.Joined;
isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
isConversationTooBigToRing = activeCall.isConversationTooBigToRing;
({ groupMembers } = activeCall);
break;
}
@ -242,6 +244,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
isGroupCall={activeCall.callMode === CallMode.Group}
isGroupCallOutboundRingEnabled={isGroupCallOutboundRingEnabled}
isCallFull={isCallFull}
isConversationTooBigToRing={isConversationTooBigToRing}
me={me}
onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall}

View file

@ -101,6 +101,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
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 || [],
remoteParticipants: overrideProps.remoteParticipants || [],

View file

@ -59,6 +59,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
i18n,
isGroupCall,
isGroupCallOutboundRingEnabled: true,
isConversationTooBigToRing: false,
isCallFull: boolean('isCallFull', overrideProps.isCallFull || false),
me:
overrideProps.me ||

View file

@ -23,7 +23,6 @@ import type { LocalizerType } from '../types/Util';
import { useIsOnline } from '../hooks/useIsOnline';
import * as KeyboardLayout from '../services/keyboardLayout';
import type { ConversationType } from '../state/ducks/conversations';
import { isConversationTooBigToRing } from '../conversations/isConversationTooBigToRing';
export type PropsType = {
availableCameras: Array<MediaDeviceInfo>;
@ -46,6 +45,7 @@ export type PropsType = {
hasLocalAudio: boolean;
hasLocalVideo: boolean;
i18n: LocalizerType;
isConversationTooBigToRing: boolean;
isGroupCall: boolean;
isGroupCallOutboundRingEnabled: boolean;
isCallFull?: boolean;
@ -73,6 +73,7 @@ export const CallingLobby = ({
isGroupCall = false,
isGroupCallOutboundRingEnabled,
isCallFull = false,
isConversationTooBigToRing,
me,
onCallCanceled,
onJoinCall,
@ -166,8 +167,6 @@ export const CallingLobby = ({
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const isGroupTooLargeToRing = isConversationTooBigToRing(conversation);
const isRingButtonVisible: boolean =
isGroupCall &&
isGroupCallOutboundRingEnabled &&
@ -177,7 +176,7 @@ export const CallingLobby = ({
let preCallInfoRingMode: RingMode;
if (isGroupCall) {
preCallInfoRingMode =
outgoingRing && !isGroupTooLargeToRing
outgoingRing && !isConversationTooBigToRing
? RingMode.WillRing
: RingMode.WillNotRing;
} else {
@ -189,7 +188,7 @@ export const CallingLobby = ({
| CallingButtonType.RING_ON
| CallingButtonType.RING_OFF;
if (isRingButtonVisible) {
if (isGroupTooLargeToRing) {
if (isConversationTooBigToRing) {
ringButtonType = CallingButtonType.RING_DISABLED;
} else if (outgoingRing) {
ringButtonType = CallingButtonType.RING_ON;

View file

@ -130,6 +130,7 @@ export const GroupCall = (): JSX.Element => {
connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
deviceCount: 0,

View file

@ -54,6 +54,12 @@ export default {
General: UsernameReservationError.General,
},
},
maxUsername: {
defaultValue: 20,
},
minUsername: {
defaultValue: 3,
},
discriminator: {
type: { name: 'string', required: false },
defaultValue: undefined,

View file

@ -7,12 +7,7 @@ import classNames from 'classnames';
import type { LocalizerType } from '../types/Util';
import type { UsernameReservationType } from '../types/Username';
import { missingCaseError } from '../util/missingCaseError';
import {
getNickname,
getDiscriminator,
getMinNickname,
getMaxNickname,
} from '../util/Username';
import { getNickname, getDiscriminator } from '../types/Username';
import {
UsernameReservationState,
UsernameReservationError,
@ -30,6 +25,8 @@ export type PropsDataType = Readonly<{
reservation?: UsernameReservationType;
error?: UsernameReservationError;
state: UsernameReservationState;
minNickname: number;
maxNickname: number;
}>;
export type ActionPropsDataType = Readonly<{
@ -53,6 +50,8 @@ export const EditUsernameModalBody = ({
currentUsername,
reserveUsername,
confirmUsername,
minNickname,
maxNickname,
reservation,
setUsernameReservationError,
error,
@ -103,12 +102,12 @@ export const EditUsernameModalBody = ({
}
if (error === UsernameReservationError.NotEnoughCharacters) {
return i18n('ProfileEditor--username--check-character-min', {
min: getMinNickname(),
min: minNickname,
});
}
if (error === UsernameReservationError.TooManyCharacters) {
return i18n('ProfileEditor--username--check-character-max', {
max: getMaxNickname(),
max: maxNickname,
});
}
if (error === UsernameReservationError.CheckStartingCharacter) {
@ -125,7 +124,7 @@ export const EditUsernameModalBody = ({
return;
}
throw missingCaseError(error);
}, [error, i18n]);
}, [error, i18n, minNickname, maxNickname]);
useEffect(() => {
// Initial effect run

View file

@ -881,6 +881,8 @@ export const ChooseGroupMembersPartialPhoneNumber = (): JSX.Element => (
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
groupSizeRecommendedLimit: 151,
groupSizeHardLimit: 1001,
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
@ -903,6 +905,8 @@ export const ChooseGroupMembersValidPhoneNumber = (): JSX.Element => (
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
groupSizeRecommendedLimit: 151,
groupSizeHardLimit: 1001,
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
@ -925,6 +929,8 @@ export const ChooseGroupMembersUsername = (): JSX.Element => (
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
groupSizeRecommendedLimit: 151,
groupSizeHardLimit: 1001,
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,

View file

@ -92,6 +92,8 @@ function renderEditUsernameModalBody(props: {
return (
<EditUsernameModalBody
i18n={i18n}
minNickname={3}
maxNickname={20}
state={UsernameReservationState.Open}
error={undefined}
setUsernameReservationError={action('setUsernameReservationError')}

View file

@ -51,6 +51,8 @@ const createProps = (
makeRequest: async (conversationIds: ReadonlyArray<string>) => {
action('onMakeRequest')(conversationIds);
},
maxGroupSize: 1001,
maxRecommendedGroupSize: 151,
requestState: RequestState.Inactive,
renderChooseGroupMembersModal: props => {
const { selectedConversationIds } = props;

View file

@ -12,10 +12,6 @@ import {
} from '../../AddGroupMemberErrorDialog';
import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal';
import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../../groups/limits';
import {
toggleSelectedContactForGroupAddition,
OneTimeModalState,
@ -31,6 +27,8 @@ type PropsType = {
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void;
requestState: RequestState;
maxGroupSize: number;
maxRecommendedGroupSize: number;
renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType
@ -46,6 +44,8 @@ enum Stage {
}
type StateType = {
maxGroupSize: number;
maxRecommendedGroupSize: number;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
searchTerm: string;
@ -116,8 +116,8 @@ function reducer(
return {
...state,
...toggleSelectedContactForGroupAddition(action.conversationId, {
maxGroupSize: getMaximumNumberOfContacts(),
maxRecommendedGroupSize: getRecommendedMaximumNumberOfContacts(),
maxGroupSize: state.maxGroupSize,
maxRecommendedGroupSize: state.maxRecommendedGroupSize,
maximumGroupSizeModalState: state.maximumGroupSizeModalState,
numberOfContactsAlreadyInGroup: action.numberOfContactsAlreadyInGroup,
recommendedGroupSizeModalState: state.recommendedGroupSizeModalState,
@ -141,13 +141,12 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
i18n,
onClose,
makeRequest,
maxGroupSize,
maxRecommendedGroupSize,
requestState,
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
}) => {
const maxGroupSize = getMaximumNumberOfContacts();
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
const isGroupAlreadyFull = numberOfContactsAlreadyInGroup >= maxGroupSize;
const isGroupAlreadyOverRecommendedMaximum =
@ -163,6 +162,8 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
},
dispatch,
] = useReducer(reducer, {
maxGroupSize,
maxRecommendedGroupSize,
maximumGroupSizeModalState: isGroupAlreadyFull
? OneTimeModalState.Showing
: OneTimeModalState.NeverShown,
@ -260,11 +261,3 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
throw missingCaseError(stage);
}
};
function getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151);
}
function getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001);
}

View file

@ -14,7 +14,7 @@ import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
import { getUsernameFromSearch } from '../../../../util/Username';
import { getUsernameFromSearch } from '../../../../types/Username';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
import { missingCaseError } from '../../../../util/missingCaseError';

View file

@ -62,6 +62,8 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
isMe: i === 2,
}),
})),
maxGroupSize: 1001,
maxRecommendedGroupSize: 151,
pendingApprovalMemberships: times(8, () => ({
member: getDefaultConversation(),
})),

View file

@ -74,6 +74,8 @@ export type StateProps = {
isGroup: boolean;
loadRecentMediaItems: (limit: number) => void;
groupsInCommon: Array<ConversationType>;
maxGroupSize: number;
maxRecommendedGroupSize: number;
memberships: Array<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
@ -141,6 +143,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
isGroup,
loadRecentMediaItems,
memberships,
maxGroupSize,
maxRecommendedGroupSize,
onBlock,
onLeave,
onOutgoingAudioCallInConversation,
@ -272,6 +276,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setAddGroupMembersRequestState(RequestState.InactiveWithError);
}
}}
maxGroupSize={maxGroupSize}
maxRecommendedGroupSize={maxRecommendedGroupSize}
onClose={() => {
setModalState(ModalState.NothingOpen);
setEditGroupAttributesRequestState(RequestState.Inactive);

View file

@ -7,7 +7,6 @@ import React, { useCallback } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType } from '../../types/Util';
import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
import type { ShowConversationType } from '../../state/ducks/conversations';
@ -26,6 +25,7 @@ export type Props = PropsData & PropsHousekeeping;
export const UsernameSearchResultListItem: FunctionComponent<Props> = ({
i18n,
isFetchingUsername,
lookupConversationWithoutUuid,
username,
showUserNotFoundModal,
setIsFetchingUUID,
@ -48,11 +48,12 @@ export const UsernameSearchResultListItem: FunctionComponent<Props> = ({
showConversation({ conversationId });
}
}, [
username,
showUserNotFoundModal,
isFetchingUsername,
lookupConversationWithoutUuid,
setIsFetchingUUID,
showConversation,
isFetchingUsername,
showUserNotFoundModal,
username,
]);
return (

View file

@ -18,7 +18,7 @@ import {
} from '../AddGroupMemberErrorDialog';
import { Button } from '../Button';
import type { LocalizerType } from '../../types/Util';
import { getUsernameFromSearch } from '../../util/Username';
import { getUsernameFromSearch } from '../../types/Username';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
@ -26,14 +26,12 @@ import {
isFetchingByUsername,
isFetchingByE164,
} from '../../util/uuidFetchState';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
export type LeftPaneChooseGroupMembersPropsType = {
uuidFetchState: UUIDFetchStateType;
candidateContacts: ReadonlyArray<ConversationType>;
groupSizeRecommendedLimit: number;
groupSizeHardLimit: number;
isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean;
isUsernamesEnabled: boolean;
@ -53,6 +51,10 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
private readonly isShowingRecommendedGroupSizeModal: boolean;
private readonly groupSizeRecommendedLimit: number;
private readonly groupSizeHardLimit: number;
private readonly searchTerm: string;
private readonly phoneNumber: ParsedE164Type | undefined;
@ -70,6 +72,8 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal,
isUsernamesEnabled,
groupSizeRecommendedLimit,
groupSizeHardLimit,
searchTerm,
regionCode,
selectedContacts,
@ -78,6 +82,8 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
super();
this.uuidFetchState = uuidFetchState;
this.groupSizeRecommendedLimit = groupSizeRecommendedLimit - 1;
this.groupSizeHardLimit = groupSizeHardLimit - 1;
this.candidateContacts = candidateContacts;
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
@ -194,7 +200,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
modalNode = (
<AddGroupMemberErrorDialog
i18n={i18n}
maximumNumberOfContacts={this.getMaximumNumberOfContacts()}
maximumNumberOfContacts={this.groupSizeHardLimit}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
onClose={closeMaximumGroupSizeModal}
/>
@ -203,7 +209,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
modalNode = (
<AddGroupMemberErrorDialog
i18n={i18n}
recommendedMaximumNumberOfContacts={this.getRecommendedMaximumNumberOfContacts()}
recommendedMaximumNumberOfContacts={this.groupSizeRecommendedLimit}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
onClose={closeRecommendedGroupSizeModal}
/>
@ -393,20 +399,12 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}
private hasSelectedMaximumNumberOfContacts(): boolean {
return this.selectedContacts.length >= this.getMaximumNumberOfContacts();
return this.selectedContacts.length >= this.groupSizeHardLimit;
}
private hasExceededMaximumNumberOfContacts(): boolean {
// It should be impossible to reach this state. This is here as a failsafe.
return this.selectedContacts.length > this.getMaximumNumberOfContacts();
}
private getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151) - 1;
}
private getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001) - 1;
return this.selectedContacts.length > this.groupSizeHardLimit;
}
}

View file

@ -14,7 +14,7 @@ import type { LocalizerType } from '../../types/Util';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
import { missingCaseError } from '../../util/missingCaseError';
import { getUsernameFromSearch } from '../../util/Username';
import { getUsernameFromSearch } from '../../types/Username';
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
import {
isFetchingByUsername,

View file

@ -36,6 +36,7 @@ import {
} from '../../services/notifications';
import * as log from '../../logging/log';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing';
function renderDeviceSelection(): JSX.Element {
return <SmartCallingDeviceSelection />;
@ -260,6 +261,7 @@ const mapStateToActiveCallProp = (
conversationsWithSafetyNumberChanges,
deviceCount: peekInfo.deviceCount,
groupMembers,
isConversationTooBigToRing: isConversationTooBigToRing(conversation),
joinState: call.joinState,
maxDevices: peekInfo.maxDevices,
peekedParticipants,

View file

@ -30,6 +30,10 @@ import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembers
import { SmartChooseGroupMembersModal } from './ChooseGroupMembersModal';
import type { SmartConfirmAdditionsModalPropsType } from './ConfirmAdditionsModal';
import { SmartConfirmAdditionsModal } from './ConfirmAdditionsModal';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
export type SmartConversationDetailsProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
@ -114,6 +118,9 @@ const mapStateToProps = (
const groupsInCommonSorted = sortBy(groupsInCommon, 'title');
const maxGroupSize = getGroupSizeHardLimit(1001);
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
return {
...props,
areWeASubscriber: getAreWeASubscriber(state),
@ -129,6 +136,8 @@ const mapStateToProps = (
i18n: getIntl(state),
isAdmin,
...groupMemberships,
maxGroupSize,
maxRecommendedGroupSize,
userAvatarData: conversation.avatars || [],
hasGroupLink,
groupsInCommon: groupsInCommonSorted,

View file

@ -6,6 +6,7 @@ import { mapDispatchToProps } from '../actions';
import type { PropsDataType } from '../../components/EditUsernameModalBody';
import { EditUsernameModalBody } from '../../components/EditUsernameModalBody';
import { getMinNickname, getMaxNickname } from '../../util/Username';
import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
@ -23,6 +24,8 @@ function mapStateToProps(state: StateType): PropsDataType {
return {
i18n,
currentUsername: username,
minNickname: getMinNickname(),
maxNickname: getMaxNickname(),
state: getUsernameReservationState(state),
reservation: getUsernameReservationObject(state),
error: getUsernameReservationError(state),

View file

@ -49,6 +49,10 @@ import {
isEditingAvatar,
} from '../selectors/conversations';
import type { WidthBreakpoint } from '../../components/_util';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
import { SmartMainHeader } from './MainHeader';
@ -148,6 +152,8 @@ const getModeSpecificProps = (
return {
mode: LeftPaneMode.ChooseGroupMembers,
candidateContacts: getFilteredCandidateContactsForNewGroup(state),
groupSizeRecommendedLimit: getGroupSizeRecommendedLimit(),
groupSizeHardLimit: getGroupSizeHardLimit(),
isShowingRecommendedGroupSizeModal:
getRecommendedGroupSizeModalState(state) ===
OneTimeModalState.Showing,

View file

@ -9,7 +9,6 @@ import { ContactCheckboxDisabledReason } from '../../../components/conversationL
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { LeftPaneChooseGroupMembersHelper } from '../../../components/leftPane/LeftPaneChooseGroupMembersHelper';
import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub';
describe('LeftPaneChooseGroupMembersHelper', () => {
const defaults = {
@ -18,22 +17,13 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
groupSizeRecommendedLimit: 22,
groupSizeHardLimit: 33,
searchTerm: '',
regionCode: 'US',
selectedContacts: [],
};
beforeEach(async () => {
await updateRemoteConfig([
{ name: 'global.groupsv2.maxGroupSize', value: '22', enabled: true },
{
name: 'global.groupsv2.groupSizeHardLimit',
value: '33',
enabled: true,
},
]);
});
describe('getBackAction', () => {
it('returns the "show composer" action', () => {
const startComposing = sinon.fake();

View file

@ -0,0 +1,54 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as Username from '../../types/Username';
describe('Username', () => {
describe('getUsernameFromSearch', () => {
const { getUsernameFromSearch } = Username;
it('matches invalid username searches', () => {
assert.strictEqual(getUsernameFromSearch('use'), 'use');
assert.strictEqual(
getUsernameFromSearch('username9012345678901234567'),
'username9012345678901234567'
);
});
it('matches valid username searches', () => {
assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername');
assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12');
assert.strictEqual(getUsernameFromSearch('user'), 'user');
assert.strictEqual(
getUsernameFromSearch('username901234567890123456'),
'username901234567890123456'
);
});
it('matches valid and invalid usernames with @ prefix', () => {
assert.strictEqual(getUsernameFromSearch('@username!'), 'username!');
assert.strictEqual(getUsernameFromSearch('@1username'), '1username');
assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('@username.34'), 'username.34');
assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername');
});
it('matches valid and invalid usernames with @ suffix', () => {
assert.strictEqual(getUsernameFromSearch('username!@'), 'username!');
assert.strictEqual(getUsernameFromSearch('1username@'), '1username');
assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34');
assert.strictEqual(getUsernameFromSearch('username.34@'), 'username.34');
assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername');
});
it('does not match something that looks like a phone number', () => {
assert.isUndefined(getUsernameFromSearch('+'));
assert.isUndefined(getUsernameFromSearch('2223'));
assert.isUndefined(getUsernameFromSearch('+3'));
assert.isUndefined(getUsernameFromSearch('+234234234233'));
});
});
});

View file

@ -6,52 +6,6 @@ import { assert } from 'chai';
import * as Username from '../../util/Username';
describe('Username', () => {
describe('getUsernameFromSearch', () => {
const { getUsernameFromSearch } = Username;
it('matches invalid username searches', () => {
assert.strictEqual(getUsernameFromSearch('use'), 'use');
assert.strictEqual(
getUsernameFromSearch('username9012345678901234567'),
'username9012345678901234567'
);
});
it('matches valid username searches', () => {
assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername');
assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12');
assert.strictEqual(getUsernameFromSearch('user'), 'user');
assert.strictEqual(
getUsernameFromSearch('username901234567890123456'),
'username901234567890123456'
);
});
it('matches valid and invalid usernames with @ prefix', () => {
assert.strictEqual(getUsernameFromSearch('@username!'), 'username!');
assert.strictEqual(getUsernameFromSearch('@1username'), '1username');
assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('@username.34'), 'username.34');
assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername');
});
it('matches valid and invalid usernames with @ suffix', () => {
assert.strictEqual(getUsernameFromSearch('username!@'), 'username!');
assert.strictEqual(getUsernameFromSearch('1username@'), '1username');
assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34');
assert.strictEqual(getUsernameFromSearch('username.34@'), 'username.34');
assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername');
});
it('does not match something that looks like a phone number', () => {
assert.isUndefined(getUsernameFromSearch('+'));
assert.isUndefined(getUsernameFromSearch('2223'));
assert.isUndefined(getUsernameFromSearch('+3'));
assert.isUndefined(getUsernameFromSearch('+234234234233'));
});
});
describe('isValidUsername', () => {
const { isValidUsername } = Username;

View file

@ -15,7 +15,6 @@ import { blobToArrayBuffer } from 'blob-util';
import type { LoggerType } from './Logging';
import * as MIME from './MIME';
import * as log from '../logging/log';
import { toLogFormat } from './errors';
import { SignalService } from '../protobuf';
import {
@ -24,11 +23,7 @@ import {
} from '../util/GoogleChrome';
import type { LocalizerType } from './Util';
import { ThemeType } from './Util';
import { scaleImageToLevel } from '../util/scaleImageToLevel';
import * as GoogleChrome from '../util/GoogleChrome';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { getValue } from '../RemoteConfig';
import { isRecord } from '../util/isRecord';
import { ReadStatus } from '../messages/MessageReadStatus';
import type { MessageStatusType } from '../components/conversation/Message';
@ -249,73 +244,6 @@ export function isValid(
return true;
}
// Upgrade steps
// NOTE: This step strips all EXIF metadata from JPEG images as
// part of re-encoding the image:
export async function autoOrientJPEG(
attachment: AttachmentType,
{ logger }: { logger: LoggerType },
{
sendHQImages = false,
isIncoming = false,
}: {
sendHQImages?: boolean;
isIncoming?: boolean;
} = {}
): Promise<AttachmentType> {
if (isIncoming && !MIME.isJPEG(attachment.contentType)) {
return attachment;
}
if (!canBeTranscoded(attachment)) {
return attachment;
}
// If we haven't downloaded the attachment yet, we won't have the data.
// All images go through handleImageAttachment before being sent and thus have
// already been scaled to level, oriented, stripped of exif data, and saved
// in high quality format. If we want to send the image in HQ we can return
// the attachment as-is. Otherwise we'll have to further scale it down.
if (!attachment.data || sendHQImages) {
return attachment;
}
const dataBlob = new Blob([attachment.data], {
type: attachment.contentType,
});
try {
const { blob: xcodedDataBlob } = await scaleImageToLevel(
dataBlob,
attachment.contentType,
isIncoming
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const xcodedAttachment = {
// `digest` is no longer valid for auto-oriented image data, so we discard it:
...omit(attachment, 'digest'),
data: new Uint8Array(xcodedDataArrayBuffer),
size: xcodedDataArrayBuffer.byteLength,
};
return xcodedAttachment;
} catch (error: unknown) {
const errorString =
isRecord(error) && 'stack' in error ? error.stack : error;
logger.error(
'autoOrientJPEG: Failed to rotate/scale attachment',
errorString
);
return attachment;
}
}
const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D';
const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E';
const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD';
@ -1047,23 +975,6 @@ export const getFileExtension = (
}
};
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
export const getMaximumAttachmentSize = (): number => {
try {
return parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
);
} catch (error) {
log.warn(
'Failed to parse integer out of global.attachments.maxBytes feature flag'
);
return DEFAULT_MAX;
}
};
export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => {
if (theme === ThemeType.dark) {
return 'L05OQnoffQofoffQfQfQfQfQfQfQ';

View file

@ -72,6 +72,7 @@ type ActiveGroupCallType = ActiveCallBaseType & {
maxDevices: number;
deviceCount: number;
groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
isConversationTooBigToRing: boolean;
peekedParticipants: Array<ConversationType>;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
remoteAudioLevels: Map<number, number>;

View file

@ -5,8 +5,8 @@ import { isFunction, isObject, isString, omit } from 'lodash';
import * as Contact from './EmbeddedContact';
import type { AttachmentType, AttachmentWithHydratedData } from './Attachment';
import { autoOrientJPEG } from '../util/attachments';
import {
autoOrientJPEG,
captureDimensionsAndScreenshot,
hasData,
migrateDataToFileSystem,

View file

@ -11,3 +11,41 @@ export enum ReserveUsernameError {
Unprocessable = 'Unprocessable',
Conflict = 'Conflict',
}
export function getUsernameFromSearch(searchTerm: string): string | undefined {
// Search term contains username if it:
// - Is a valid username with or without a discriminator
// - Starts with @
// - Ends with @
const match = searchTerm.match(
/^(?:(?<valid>[a-z_][0-9a-z_]*(?:\.\d*)?)|@(?<start>.*?)@?|@?(?<end>.*?)?@)$/
);
if (!match) {
return undefined;
}
const { groups } = match;
if (!groups) {
return undefined;
}
return (groups.valid || groups.start || groups.end) ?? undefined;
}
export function getNickname(username: string): string | undefined {
const match = username.match(/^(.*?)(?:\.|$)/);
if (!match) {
return undefined;
}
return match[1];
}
export function getDiscriminator(username: string): string {
const match = username.match(/(\..*)$/);
if (!match) {
return '';
}
return match[1];
}

View file

@ -25,7 +25,7 @@ import type { BrowserWindow } from 'electron';
import { app, ipcMain } from 'electron';
import * as durations from '../util/durations';
import { getTempPath, getUpdateCachePath } from '../util/attachments';
import { getTempPath, getUpdateCachePath } from '../../app/attachments';
import { DialogType } from '../types/Dialogs';
import * as Errors from '../types/errors';
import { isAlpha, isBeta, isStaging } from '../util/version';

View file

@ -39,41 +39,3 @@ export function isValidUsername(username: string): boolean {
const [, nickname] = match;
return isValidNickname(nickname);
}
export function getUsernameFromSearch(searchTerm: string): string | undefined {
// Search term contains username if it:
// - Is a valid username with or without a discriminator
// - Starts with @
// - Ends with @
const match = searchTerm.match(
/^(?:(?<valid>[a-z_][0-9a-z_]*(?:\.\d*)?)|@(?<start>.*?)@?|@?(?<end>.*?)?@)$/
);
if (!match) {
return undefined;
}
const { groups } = match;
if (!groups) {
return undefined;
}
return (groups.valid || groups.start || groups.end) ?? undefined;
}
export function getNickname(username: string): string | undefined {
const match = username.match(/^(.*?)(?:\.|$)/);
if (!match) {
return undefined;
}
return match[1];
}
export function getDiscriminator(username: string): string {
const match = username.match(/(\..*)$/);
if (!match) {
return '';
}
return match[1];
}

View file

@ -1,71 +1,99 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isString } from 'lodash';
import { join, normalize } from 'path';
import fse from 'fs-extra';
import { omit } from 'lodash';
import { blobToArrayBuffer } from 'blob-util';
import * as log from '../logging/log';
import { getValue } from '../RemoteConfig';
import { isPathInside } from './isPathInside';
import { parseIntOrThrow } from './parseIntOrThrow';
import { scaleImageToLevel } from './scaleImageToLevel';
import { isRecord } from './isRecord';
import type { AttachmentType } from '../types/Attachment';
import { canBeTranscoded } from '../types/Attachment';
import type { LoggerType } from '../types/Logging';
import * as MIME from '../types/MIME';
const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex';
const BADGES_PATH = 'badges.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const UPDATE_CACHE_PATH = 'update-cache';
const DRAFT_PATH = 'drafts.noindex';
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
const CACHED_PATHS = new Map<string, string>();
export const getMaximumAttachmentSize = (): number => {
try {
return parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
);
} catch (error) {
log.warn(
'Failed to parse integer out of global.attachments.maxBytes feature flag'
);
return DEFAULT_MAX;
}
};
const createPathGetter =
(subpath: string) =>
(userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
const naivePath = join(userDataPath, subpath);
const cached = CACHED_PATHS.get(naivePath);
if (cached) {
return cached;
}
let result = naivePath;
if (fse.pathExistsSync(naivePath)) {
result = fse.realpathSync(naivePath);
}
CACHED_PATHS.set(naivePath, result);
return result;
};
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
export const getBadgesPath = createPathGetter(BADGES_PATH);
export const getDraftPath = createPathGetter(DRAFT_PATH);
export const getPath = createPathGetter(PATH);
export const getStickersPath = createPathGetter(STICKER_PATH);
export const getTempPath = createPathGetter(TEMP_PATH);
export const getUpdateCachePath = createPathGetter(UPDATE_CACHE_PATH);
export const createDeleter = (
root: string
): ((relativePath: string) => Promise<void>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
// Upgrade steps
// NOTE: This step strips all EXIF metadata from JPEG images as
// part of re-encoding the image:
export async function autoOrientJPEG(
attachment: AttachmentType,
{ logger }: { logger: LoggerType },
{
sendHQImages = false,
isIncoming = false,
}: {
sendHQImages?: boolean;
isIncoming?: boolean;
} = {}
): Promise<AttachmentType> {
if (isIncoming && !MIME.isJPEG(attachment.contentType)) {
return attachment;
}
return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
if (!canBeTranscoded(attachment)) {
return attachment;
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.remove(absolutePath);
};
};
// If we haven't downloaded the attachment yet, we won't have the data.
// All images go through handleImageAttachment before being sent and thus have
// already been scaled to level, oriented, stripped of exif data, and saved
// in high quality format. If we want to send the image in HQ we can return
// the attachment as-is. Otherwise we'll have to further scale it down.
if (!attachment.data || sendHQImages) {
return attachment;
}
const dataBlob = new Blob([attachment.data], {
type: attachment.contentType,
});
try {
const { blob: xcodedDataBlob } = await scaleImageToLevel(
dataBlob,
attachment.contentType,
isIncoming
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const xcodedAttachment = {
// `digest` is no longer valid for auto-oriented image data, so we discard it:
...omit(attachment, 'digest'),
data: new Uint8Array(xcodedDataArrayBuffer),
size: xcodedDataArrayBuffer.byteLength,
};
return xcodedAttachment;
} catch (error: unknown) {
const errorString =
isRecord(error) && 'stack' in error ? error.stack : error;
logger.error(
'autoOrientJPEG: Failed to rotate/scale attachment',
errorString
);
return attachment;
}
}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import { getMaximumAttachmentSize } from '../types/Attachment';
import { getMaximumAttachmentSize } from './attachments';
import { showToast } from './showToast';
import { ToastFileSize } from '../components/ToastFileSize';

View file

@ -8,7 +8,7 @@ import type {
AttachmentDraftType,
InMemoryAttachmentDraftType,
} from '../types/Attachment';
import { getMaximumAttachmentSize } from '../types/Attachment';
import { getMaximumAttachmentSize } from './attachments';
import { AttachmentToastType } from '../types/AttachmentToastType';
import { fileToBytes } from './fileToBytes';
import { handleImageAttachment } from './handleImageAttachment';

View file

@ -14,7 +14,7 @@ import { isPathInside } from '../util/isPathInside';
import { writeWindowsZoneIdentifier } from '../util/windowsZoneIdentifier';
import { isWindows } from '../OS';
export * from '../util/attachments';
export * from '../../app/attachments';
type FSAttrType = {
set: (path: string, attribute: string, value: string) => Promise<void>;