diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index e3fec7ed49..ba09294266 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -136,4 +136,5 @@ message AccountRecord { optional uint32 universalExpireTimer = 17; optional bool primarySendsSms = 18; optional string e164 = 19; + repeated string preferredReactionEmoji = 20; } diff --git a/ts/reactions/getPreferredReactionEmoji.ts b/ts/reactions/getPreferredReactionEmoji.ts deleted file mode 100644 index 1073af82a8..0000000000 --- a/ts/reactions/getPreferredReactionEmoji.ts +++ /dev/null @@ -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 { - 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) - ); -} diff --git a/ts/reactions/preferredReactionEmoji.ts b/ts/reactions/preferredReactionEmoji.ts new file mode 100644 index 0000000000..757a195e0a --- /dev/null +++ b/ts/reactions/preferredReactionEmoji.ts @@ -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 { + const storedValueAsArray: Array = 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 => + Array.isArray(value) && + value.length <= MAX_STORED_LENGTH && + value.every( + item => typeof item === 'string' && item.length <= MAX_ITEM_LENGTH + ); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 6771c0d99c..0506c02cf3 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -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 = diff --git a/ts/state/ducks/preferredReactions.ts b/ts/state/ducks/preferredReactions.ts index 37bc997742..fd993f31b9 100644 --- a/ts/state/ducks/preferredReactions.ts +++ b/ts/state/ducks/preferredReactions.ts @@ -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 }); } }; diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index bec0c6a7bb..8f4a2c4c40 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -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; diff --git a/ts/test-both/reactions/getPreferredReactionEmoji_test.ts b/ts/test-both/reactions/getPreferredReactionEmoji_test.ts deleted file mode 100644 index e34f03e760..0000000000 --- a/ts/test-both/reactions/getPreferredReactionEmoji_test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/ts/test-both/reactions/preferredReactionEmoji_test.ts b/ts/test-both/reactions/preferredReactionEmoji_test.ts new file mode 100644 index 0000000000..7e01e5c57a --- /dev/null +++ b/ts/test-both/reactions/preferredReactionEmoji_test.ts @@ -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)); + }); + }); + }); +}); diff --git a/ts/test-both/state/ducks/preferredReactions_test.ts b/ts/test-both/state/ducks/preferredReactions_test.ts index 30673d28cb..55601fb901 100644 --- a/ts/test-both/state/ducks/preferredReactions_test.ts +++ b/ts/test-both/state/ducks/preferredReactions_test.ts @@ -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', () => {