Phased rollout by country code; starting w/ desktop.stories remote flag
This commit is contained in:
parent
e14c3241c5
commit
1c89168301
10 changed files with 318 additions and 25 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
97
ts/test-both/RemoteConfig_test.ts
Normal file
97
ts/test-both/RemoteConfig_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
24
ts/test-both/types/PhoneNumber_test.ts
Normal file
24
ts/test-both/types/PhoneNumber_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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?: {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
35
ts/util/stories.ts
Normal 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();
|
Loading…
Add table
Reference in a new issue