Read and write preferred reactions to storage

This commit is contained in:
Evan Hahn 2021-09-15 13:59:51 -05:00 committed by GitHub
parent 4e3b64ef64
commit 20be8a11fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 208 additions and 71 deletions

View File

@ -136,4 +136,5 @@ message AccountRecord {
optional uint32 universalExpireTimer = 17;
optional bool primarySendsSms = 18;
optional string e164 = 19;
repeated string preferredReactionEmoji = 20;
}

View File

@ -1,24 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from './constants';
import { convertShortName } from '../components/emoji/lib';
import { isValidReactionEmoji } from './isValidReactionEmoji';
const PREFERRED_REACTION_EMOJI_COUNT =
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.length;
export function getPreferredReactionEmoji(
storedValue: unknown,
skinTone: number
): Array<string> {
const isStoredValueValid =
Array.isArray(storedValue) &&
storedValue.length === PREFERRED_REACTION_EMOJI_COUNT &&
storedValue.every(isValidReactionEmoji);
return isStoredValueValid
? storedValue
: DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName =>
convertShortName(shortName, skinTone)
);
}

View File

@ -0,0 +1,56 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { times } from 'lodash';
import * as log from '../logging/log';
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from './constants';
import { convertShortName } from '../components/emoji/lib';
import { isValidReactionEmoji } from './isValidReactionEmoji';
const MAX_STORED_LENGTH = 20;
const MAX_ITEM_LENGTH = 20;
const PREFERRED_REACTION_EMOJI_COUNT =
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.length;
export function getPreferredReactionEmoji(
storedValue: unknown,
skinTone: number
): Array<string> {
const storedValueAsArray: Array<unknown> = Array.isArray(storedValue)
? storedValue
: [];
return times(PREFERRED_REACTION_EMOJI_COUNT, index => {
const storedItem: unknown = storedValueAsArray[index];
if (isValidReactionEmoji(storedItem)) {
return storedItem;
}
const fallbackShortName: undefined | string =
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES[index];
if (!fallbackShortName) {
log.error(
'Index is out of range. Is the preferred count larger than the list of fallbacks?'
);
return '❤️';
}
const fallbackEmoji = convertShortName(fallbackShortName, skinTone);
if (!fallbackEmoji) {
log.error(
'No fallback emoji. Does the fallback list contain an invalid short name?'
);
return '❤️';
}
return fallbackEmoji;
});
}
export const canBeSynced = (value: unknown): value is Array<string> =>
Array.isArray(value) &&
value.length <= MAX_STORED_LENGTH &&
value.every(
item => typeof item === 'string' && item.length <= MAX_ITEM_LENGTH
);

View File

@ -35,6 +35,7 @@ import {
} from '../util/universalExpireTimer';
import { ourProfileKeyService } from './ourProfileKey';
import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation';
import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji';
import { UUID } from '../types/UUID';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf';
@ -201,6 +202,13 @@ export async function toAccountRecord(
accountRecord.e164 = accountE164;
}
const rawPreferredReactionEmoji = window.storage.get(
'preferredReactionEmoji'
);
if (preferredReactionEmoji.canBeSynced(rawPreferredReactionEmoji)) {
accountRecord.preferredReactionEmoji = rawPreferredReactionEmoji;
}
const universalExpireTimer = getUniversalExpireTimer();
if (universalExpireTimer) {
accountRecord.universalExpireTimer = Number(universalExpireTimer);
@ -836,6 +844,7 @@ export async function mergeAccountRecord(
primarySendsSms,
universalExpireTimer,
e164: accountE164,
preferredReactionEmoji: rawPreferredReactionEmoji,
} = accountRecord;
window.storage.put('read-receipt-setting', Boolean(readReceipts));
@ -861,6 +870,10 @@ export async function mergeAccountRecord(
window.storage.user.setNumber(accountE164);
}
if (preferredReactionEmoji.canBeSynced(rawPreferredReactionEmoji)) {
window.storage.put('preferredReactionEmoji', rawPreferredReactionEmoji);
}
setUniversalExpireTimer(universalExpireTimer || 0);
const PHONE_NUMBER_SHARING_MODE_ENUM =

View File

@ -9,7 +9,7 @@ import { replaceIndex } from '../../util/replaceIndex';
import { useBoundActions } from '../../util/hooks';
import type { StateType as RootStateType } from '../reducer';
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../../reactions/constants';
import { getPreferredReactionEmoji } from '../../reactions/getPreferredReactionEmoji';
import { getPreferredReactionEmoji } from '../../reactions/preferredReactionEmoji';
import { getEmojiSkinTone } from '../selectors/items';
import { convertShortName } from '../../components/emoji/lib';
@ -165,15 +165,25 @@ function savePreferredReactions(): ThunkAction<
return;
}
let succeeded = false;
dispatch({ type: SAVE_PREFERRED_REACTIONS_PENDING });
try {
await window.storage.put(
'preferredReactionEmoji',
draftPreferredReactions
);
dispatch({ type: SAVE_PREFERRED_REACTIONS_FULFILLED });
succeeded = true;
} catch (err: unknown) {
log.warn(Errors.toLogFormat(err));
}
if (succeeded) {
dispatch({ type: SAVE_PREFERRED_REACTIONS_FULFILLED });
window.ConversationController.getOurConversationOrThrow().captureChange(
'preferredReactionEmoji'
);
} else {
dispatch({ type: SAVE_PREFERRED_REACTIONS_REJECTED });
}
};

View File

@ -13,7 +13,7 @@ import {
CustomColorType,
DEFAULT_CONVERSATION_COLOR,
} from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/getPreferredReactionEmoji';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
export const getItems = (state: StateType): ItemsStateType => state.items;

View File

@ -1,43 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../../reactions/constants';
import { getPreferredReactionEmoji } from '../../reactions/getPreferredReactionEmoji';
describe('getPreferredReactionEmoji', () => {
it('returns the default set if passed anything invalid', () => {
[
// Invalid types
undefined,
null,
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.join(','),
// Invalid lengths
[],
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.slice(0, 3),
[...DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES, '✨'],
// Non-strings in the array
['❤️', '👍', undefined, '😂', '😮', '😢'],
['❤️', '👍', 99, '😂', '😮', '😢'],
// Invalid emoji
['❤️', '👍', 'x', '😂', '😮', '😢'],
['❤️', '👍', 'garbage!!', '😂', '😮', '😢'],
['❤️', '👍', '✨✨', '😂', '😮', '😢'],
].forEach(input => {
assert.deepStrictEqual(getPreferredReactionEmoji(input, 2), [
'❤️',
'👍🏼',
'👎🏼',
'😂',
'😮',
'😢',
]);
});
});
it('returns a custom set if passed a valid value', () => {
const input = ['✨', '❇️', '🎇', '🦈', '💖', '🅿️'];
assert.deepStrictEqual(getPreferredReactionEmoji(input, 3), input);
});
});

View File

@ -0,0 +1,82 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
canBeSynced,
getPreferredReactionEmoji,
} from '../../reactions/preferredReactionEmoji';
describe('preferred reaction emoji utilities', () => {
describe('getPreferredReactionEmoji', () => {
const defaultsForSkinTone2 = ['❤️', '👍🏼', '👎🏼', '😂', '😮', '😢'];
it('returns the default set if passed a non-array', () => {
[undefined, null, '❤️👍🏼👎🏼😂😮😢'].forEach(input => {
assert.deepStrictEqual(
getPreferredReactionEmoji(input, 2),
defaultsForSkinTone2
);
});
});
it('returns the default set if passed an empty array', () => {
assert.deepStrictEqual(
getPreferredReactionEmoji([], 2),
defaultsForSkinTone2
);
});
it('falls back to defaults if passed an array that is too short', () => {
const input = ['✨', '❇️'];
const expected = ['✨', '❇️', '👎🏽', '😂', '😮', '😢'];
assert.deepStrictEqual(getPreferredReactionEmoji(input, 3), expected);
});
it('falls back to defaults when passed an array with some invalid values', () => {
const input = ['✨', 'invalid', '🎇', '🦈', undefined, ''];
const expected = ['✨', '👍🏼', '🎇', '🦈', '😮', '😢'];
assert.deepStrictEqual(getPreferredReactionEmoji(input, 2), expected);
});
it('returns a custom set if passed a valid value', () => {
const input = ['✨', '❇️', '🎇', '🦈', '💖', '🅿️'];
assert.deepStrictEqual(getPreferredReactionEmoji(input, 3), input);
});
it('only returns the first few emoji if passed a value that is too long', () => {
const expected = ['✨', '❇️', '🎇', '🦈', '💖', '🅿️'];
const input = [...expected, '💅', '💅', '💅', '💅'];
assert.deepStrictEqual(getPreferredReactionEmoji(input, 3), expected);
});
});
describe('canBeSynced', () => {
it('returns false for non-arrays', () => {
assert.isFalse(canBeSynced(undefined));
assert.isFalse(canBeSynced(null));
assert.isFalse(canBeSynced('❤️👍🏼👎🏼😂😮😢'));
});
it('returns false for arrays that are too long', () => {
assert.isFalse(canBeSynced(Array(21).fill('🦊')));
});
it('returns false for arrays that have items that are too long', () => {
const input = ['✨', '❇️', 'x'.repeat(21), '🦈', '💖', '🅿️'];
assert.isFalse(canBeSynced(input));
});
it('returns true for valid values', () => {
[
[],
['💅'],
['✨', '❇️', '🎇', '🦈', '💖', '🅿️'],
['this', 'array', 'has', 'no', 'emoji', 'but', "that's", 'okay'],
].forEach(input => {
assert.isTrue(canBeSynced(input));
});
});
});
});

View File

@ -243,13 +243,33 @@ describe('preferred reactions duck', () => {
describe('savePreferredReactions', () => {
const { savePreferredReactions } = actions;
// We want to create a fake ConversationController for testing purposes, and we need
// to sidestep typechecking to do that.
/* eslint-disable @typescript-eslint/no-explicit-any */
let storagePutStub: sinon.SinonStub;
let captureChangeStub: sinon.SinonStub;
let oldConversationController: any;
beforeEach(() => {
storagePutStub = sinonSandbox.stub(window.storage, 'put').resolves();
oldConversationController = window.ConversationController;
captureChangeStub = sinonSandbox.stub();
window.ConversationController = {
getOurConversationOrThrow: (): any => ({
captureChange: captureChangeStub,
}),
} as any;
});
afterEach(() => {
window.ConversationController = oldConversationController;
});
describe('thunk', () => {
it('saves the preferred reaction emoji to storage', async () => {
it('saves the preferred reaction emoji to local storage', async () => {
await savePreferredReactions()(
sinon.spy(),
() => getRootState(stateWithOpenCustomizationModal),
@ -264,6 +284,16 @@ describe('preferred reactions duck', () => {
);
});
it('on success, enqueues a storage service upload', async () => {
await savePreferredReactions()(
sinon.spy(),
() => getRootState(stateWithOpenCustomizationModal),
null
);
sinon.assert.calledOnce(captureChangeStub);
});
it('on success, dispatches a pending action followed by a fulfilled action', async () => {
const dispatch = sinon.spy();
await savePreferredReactions()(
@ -299,6 +329,18 @@ describe('preferred reactions duck', () => {
type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_REJECTED',
});
});
it('on failure, does not enqueue a storage service upload', async () => {
storagePutStub.rejects(new Error('something went wrong'));
await savePreferredReactions()(
sinon.spy(),
() => getRootState(stateWithOpenCustomizationModal),
null
);
sinon.assert.notCalled(captureChangeStub);
});
});
describe('SAVE_PREFERRED_REACTIONS_FULFILLED', () => {