Phased rollout by country code; starting w/ desktop.stories remote flag

This commit is contained in:
Scott Nonnenberg 2022-10-20 14:02:22 -07:00 committed by GitHub
parent e14c3241c5
commit 1c89168301
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 318 additions and 25 deletions

View file

@ -5,6 +5,12 @@ import { get, throttle } from 'lodash';
import type { WebAPIType } from './textsecure/WebAPI';
import * as log from './logging/log';
import type { UUIDStringType } from './types/UUID';
import { parseIntOrThrow } from './util/parseIntOrThrow';
import * as Bytes from './Bytes';
import { hash, uuidToBytes } from './Crypto';
import { HashType } from './types/Crypto';
import { getCountryCode } from './types/PhoneNumber';
export type ConfigKeyType =
| 'desktop.announcementGroup'
@ -135,3 +141,86 @@ export function isEnabled(name: ConfigKeyType): boolean {
export function getValue(name: ConfigKeyType): string | undefined {
return get(config, [name, 'value'], undefined);
}
// See isRemoteConfigBucketEnabled in selectors/items.ts
export function isBucketValueEnabled(
name: ConfigKeyType,
e164: string | undefined,
uuid: UUIDStringType | undefined
): boolean {
return innerIsBucketValueEnabled(name, getValue(name), e164, uuid);
}
export function innerIsBucketValueEnabled(
name: ConfigKeyType,
flagValue: unknown,
e164: string | undefined,
uuid: UUIDStringType | undefined
): boolean {
if (e164 == null || uuid == null) {
return false;
}
const countryCode = getCountryCode(e164);
if (countryCode == null) {
return false;
}
if (typeof flagValue !== 'string') {
return false;
}
const remoteConfigValue = getCountryCodeValue(countryCode, flagValue, name);
if (remoteConfigValue == null) {
return false;
}
const bucketValue = getBucketValue(uuid, name);
return bucketValue < remoteConfigValue;
}
export function getCountryCodeValue(
countryCode: number,
flagValue: string,
flagName: string
): number | undefined {
const logId = `getCountryCodeValue/${flagName}`;
if (flagValue.length === 0) {
return undefined;
}
const countryCodeString = countryCode.toString();
const items = flagValue.split(',');
let wildcard: number | undefined;
for (const item of items) {
const [code, value] = item.split(':');
if (code == null || value == null) {
log.warn(`${logId}: '${code}:${value}' entry was invalid`);
continue;
}
const parsedValue = parseIntOrThrow(
value,
`${logId}: Country code '${code}' had an invalid number '${value}'`
);
if (code === '*') {
wildcard = parsedValue;
} else if (countryCodeString === code) {
return parsedValue;
}
}
return wildcard;
}
export function getBucketValue(uuid: UUIDStringType, flagName: string): number {
const hashInput = Bytes.concatenate([
Bytes.fromString(`${flagName}.`),
uuidToBytes(uuid),
]);
const hashResult = hash(HashType.size256, hashInput);
const buffer = Buffer.from(hashResult.slice(0, 8));
return Number(buffer.readBigUint64BE() % 1_000_000n);
}

View file

@ -5,17 +5,19 @@ import { createSelector } from 'reselect';
import { isInteger } from 'lodash';
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
import { innerIsBucketValueEnabled } from '../../RemoteConfig';
import type { ConfigKeyType, ConfigMapType } from '../../RemoteConfig';
import type { StateType } from '../reducer';
import type { ItemsStateType } from '../ducks/items';
import type {
ConversationColorType,
CustomColorType,
} from '../../types/Colors';
import type { UUIDStringType } from '../../types/UUID';
import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
import { isBeta } from '../../util/version';
import { getUserNumber, getUserACI } from './user';
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
@ -53,6 +55,17 @@ const isRemoteConfigFlagEnabled = (
key: ConfigKeyType
): boolean => Boolean(config[key]?.enabled);
// See isBucketValueEnabled in RemoteConfig.ts
const isRemoteConfigBucketEnabled = (
config: Readonly<ConfigMapType>,
name: ConfigKeyType,
e164: string | undefined,
uuid: UUIDStringType | undefined
): boolean => {
const flagValue = config[name]?.value;
return innerIsBucketValueEnabled(name, flagValue, e164, uuid);
};
const getRemoteConfig = createSelector(
getItems,
(state: ItemsStateType): ConfigMapType => state.remoteConfig || {}
@ -64,16 +77,41 @@ export const getUsernamesEnabled = createSelector(
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames')
);
// Note: types/Stories is the other place this check is done
// Note: ts/util/stories is the other place this check is done
export const getStoriesEnabled = createSelector(
getItems,
getRemoteConfig,
(state: ItemsStateType, remoteConfig: ConfigMapType): boolean =>
!state.hasStoriesDisabled &&
(isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser') ||
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories') ||
(isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories.beta') &&
isBeta(window.getVersion())))
getUserNumber,
getUserACI,
(
state: ItemsStateType,
remoteConfig: ConfigMapType,
e164: string | undefined,
aci: UUIDStringType | undefined
): boolean => {
if (state.hasStoriesDisabled) {
return false;
}
if (
isRemoteConfigBucketEnabled(remoteConfig, 'desktop.stories', e164, aci)
) {
return true;
}
if (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser')) {
return true;
}
if (
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories.beta') &&
isBeta(window.getVersion())
) {
return true;
}
return false;
}
);
export const getDefaultConversationColor = createSelector(

View file

@ -0,0 +1,97 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
getCountryCodeValue,
getBucketValue,
innerIsBucketValueEnabled,
} from '../RemoteConfig';
describe('RemoteConfig', () => {
const uuid = '15b9729c-51ea-4ddb-b516-652befe78062';
describe('#innerIsBucketValueEnabled', () => {
// Note: bucketValue is 497941 for 'desktop.stories' key
it('returns true for 100% wildcard', () => {
assert.strictEqual(
innerIsBucketValueEnabled(
'desktop.stories',
'*:1000000',
'+12125550000',
uuid
),
true
);
});
it('returns true for 50% on country code 1', () => {
assert.strictEqual(
innerIsBucketValueEnabled(
'desktop.stories',
'1:500000',
'+12125550000',
uuid
),
true
);
});
it('returns false for 40% on country code 1', () => {
assert.strictEqual(
innerIsBucketValueEnabled(
'desktop.stories',
'1:400000',
'+12125550000',
uuid
),
false
);
});
});
describe('#getCountryCodeValue', () => {
it('returns undefined for empty value', () => {
assert.strictEqual(getCountryCodeValue(1, '', 'flagName'), undefined);
});
it('throws for malformed flag', () => {
assert.throws(
() => getCountryCodeValue(1, 'hi:::', 'flagName'),
"invalid number ''"
);
});
it('throws for non-integer value', () => {
assert.throws(
() => getCountryCodeValue(1, '1:cd', 'flagName'),
"invalid number 'cd'"
);
});
it('returns wildcard value if no other codes', () => {
assert.strictEqual(getCountryCodeValue(1, '*:56,2:74', 'flagName'), 56);
});
it('returns value for specific codes, instead of wildcard', () => {
assert.strictEqual(getCountryCodeValue(1, '*:56,1:74', 'flagName'), 74);
});
it('returns undefined if no wildcard or specific value', () => {
assert.strictEqual(
getCountryCodeValue(1, '2:56,3:74', 'flagName'),
undefined
);
});
});
describe('#getBucketValue', () => {
it('returns undefined for empty value', () => {
const flagName = 'research.megaphone.1';
assert.strictEqual(getBucketValue(uuid, flagName), 243315);
});
});
});

View file

@ -0,0 +1,24 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getCountryCode } from '../../types/PhoneNumber';
describe('types/PhoneNumber', () => {
describe('#getCountryCode', () => {
it('returns expected country codes', () => {
assert.strictEqual(getCountryCode('+12125550000'), 1, 'United States');
assert.strictEqual(getCountryCode('+442012341234'), 44, 'United Kingdom');
assert.strictEqual(getCountryCode('+37060112345'), 370, 'Lithuania');
});
it('returns undefined for missing phone number', () => {
assert.strictEqual(getCountryCode(undefined), undefined);
});
it('returns undefined for invalid phone number', () => {
assert.strictEqual(getCountryCode('+2343d23'), undefined);
});
});
});

View file

@ -115,7 +115,7 @@ import { generateBlurHash } from '../util/generateBlurHash';
import { TEXT_ATTACHMENT } from '../types/MIME';
import type { SendTypesType } from '../util/handleMessageSend';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoriesBlocked } from '../types/Stories';
import { getStoriesBlocked } from '../util/stories';
const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32;

View file

@ -3,6 +3,8 @@
import memoizee from 'memoizee';
import { instance, PhoneNumberFormat } from '../util/libphonenumberInstance';
import * as log from '../logging/log';
import * as Errors from './errors';
function _format(
phoneNumber: string,
@ -25,6 +27,27 @@ function _format(
}
}
export function getCountryCode(
phoneNumber: string | undefined
): number | undefined {
try {
if (phoneNumber == null) {
return undefined;
}
if (!isValidNumber(phoneNumber)) {
return undefined;
}
return instance.parse(phoneNumber).getCountryCode();
} catch (error) {
const errorText = Errors.toLogFormat(error);
log.info(
`getCountryCode: Failed to get country code from ${phoneNumber}: ${errorText}`
);
return undefined;
}
}
export function isValidNumber(
phoneNumber: string,
options?: {

View file

@ -9,8 +9,6 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStatus } from '../messages/MessageSendState';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { UUIDStringType } from './UUID';
import { isEnabled } from '../RemoteConfig';
import { isBeta } from '../util/version';
export type ReplyType = {
author: Pick<
@ -159,15 +157,3 @@ export enum StorySendMode {
Always = 'Always',
Never = 'Never',
}
// Note: selectors/items is the other place this check is done
export const getStoriesAvailable = (): boolean =>
isEnabled('desktop.stories') ||
isEnabled('desktop.internalUser') ||
(isEnabled('desktop.stories.beta') && isBeta(window.getVersion()));
export const getStoriesDisabled = (): boolean =>
window.Events.getHasStoriesDisabled();
export const getStoriesBlocked = (): boolean =>
!getStoriesAvailable() || getStoriesDisabled();

View file

@ -5,7 +5,7 @@ import { webFrame } from 'electron';
import type { AudioDevice } from 'ringrtc';
import * as React from 'react';
import { noop } from 'lodash';
import { getStoriesAvailable } from '../types/Stories';
import { getStoriesAvailable } from './stories';
import type { ZoomFactorType } from '../types/Storage.d';
import type {

View file

@ -8,7 +8,8 @@ import type { UUIDStringType } from '../types/UUID';
import * as log from '../logging/log';
import dataInterface from '../sql/Client';
import { DAY, SECOND } from './durations';
import { getStoriesBlocked, MY_STORIES_ID } from '../types/Stories';
import { MY_STORIES_ID } from '../types/Stories';
import { getStoriesBlocked } from './stories';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
import { SendStatus } from '../messages/MessageSendState';

35
ts/util/stories.ts Normal file
View file

@ -0,0 +1,35 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isBucketValueEnabled, isEnabled } from '../RemoteConfig';
import { UUIDKind } from '../types/UUID';
import { isBeta } from './version';
// Note: selectors/items is the other place this check is done
export const getStoriesAvailable = (): boolean => {
if (
isBucketValueEnabled(
'desktop.stories',
window.textsecure.storage.user.getNumber(),
window.textsecure.storage.user.getUuid(UUIDKind.ACI)?.toString()
)
) {
return true;
}
if (isEnabled('desktop.internalUser')) {
return true;
}
if (isEnabled('desktop.stories.beta') && isBeta(window.getVersion())) {
return true;
}
return false;
};
export const getStoriesDisabled = (): boolean =>
window.Events.getHasStoriesDisabled();
export const getStoriesBlocked = (): boolean =>
!getStoriesAvailable() || getStoriesDisabled();