Read and write preferred reactions to storage
This commit is contained in:
parent
4e3b64ef64
commit
20be8a11fe
9 changed files with 208 additions and 71 deletions
|
@ -136,4 +136,5 @@ message AccountRecord {
|
||||||
optional uint32 universalExpireTimer = 17;
|
optional uint32 universalExpireTimer = 17;
|
||||||
optional bool primarySendsSms = 18;
|
optional bool primarySendsSms = 18;
|
||||||
optional string e164 = 19;
|
optional string e164 = 19;
|
||||||
|
repeated string preferredReactionEmoji = 20;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
56
ts/reactions/preferredReactionEmoji.ts
Normal file
56
ts/reactions/preferredReactionEmoji.ts
Normal 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
|
||||||
|
);
|
|
@ -35,6 +35,7 @@ import {
|
||||||
} from '../util/universalExpireTimer';
|
} from '../util/universalExpireTimer';
|
||||||
import { ourProfileKeyService } from './ourProfileKey';
|
import { ourProfileKeyService } from './ourProfileKey';
|
||||||
import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation';
|
import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation';
|
||||||
|
import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji';
|
||||||
import { UUID } from '../types/UUID';
|
import { UUID } from '../types/UUID';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
@ -201,6 +202,13 @@ export async function toAccountRecord(
|
||||||
accountRecord.e164 = accountE164;
|
accountRecord.e164 = accountE164;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawPreferredReactionEmoji = window.storage.get(
|
||||||
|
'preferredReactionEmoji'
|
||||||
|
);
|
||||||
|
if (preferredReactionEmoji.canBeSynced(rawPreferredReactionEmoji)) {
|
||||||
|
accountRecord.preferredReactionEmoji = rawPreferredReactionEmoji;
|
||||||
|
}
|
||||||
|
|
||||||
const universalExpireTimer = getUniversalExpireTimer();
|
const universalExpireTimer = getUniversalExpireTimer();
|
||||||
if (universalExpireTimer) {
|
if (universalExpireTimer) {
|
||||||
accountRecord.universalExpireTimer = Number(universalExpireTimer);
|
accountRecord.universalExpireTimer = Number(universalExpireTimer);
|
||||||
|
@ -836,6 +844,7 @@ export async function mergeAccountRecord(
|
||||||
primarySendsSms,
|
primarySendsSms,
|
||||||
universalExpireTimer,
|
universalExpireTimer,
|
||||||
e164: accountE164,
|
e164: accountE164,
|
||||||
|
preferredReactionEmoji: rawPreferredReactionEmoji,
|
||||||
} = accountRecord;
|
} = accountRecord;
|
||||||
|
|
||||||
window.storage.put('read-receipt-setting', Boolean(readReceipts));
|
window.storage.put('read-receipt-setting', Boolean(readReceipts));
|
||||||
|
@ -861,6 +870,10 @@ export async function mergeAccountRecord(
|
||||||
window.storage.user.setNumber(accountE164);
|
window.storage.user.setNumber(accountE164);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preferredReactionEmoji.canBeSynced(rawPreferredReactionEmoji)) {
|
||||||
|
window.storage.put('preferredReactionEmoji', rawPreferredReactionEmoji);
|
||||||
|
}
|
||||||
|
|
||||||
setUniversalExpireTimer(universalExpireTimer || 0);
|
setUniversalExpireTimer(universalExpireTimer || 0);
|
||||||
|
|
||||||
const PHONE_NUMBER_SHARING_MODE_ENUM =
|
const PHONE_NUMBER_SHARING_MODE_ENUM =
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { replaceIndex } from '../../util/replaceIndex';
|
||||||
import { useBoundActions } from '../../util/hooks';
|
import { useBoundActions } from '../../util/hooks';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../../reactions/constants';
|
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 { getEmojiSkinTone } from '../selectors/items';
|
||||||
import { convertShortName } from '../../components/emoji/lib';
|
import { convertShortName } from '../../components/emoji/lib';
|
||||||
|
|
||||||
|
@ -165,15 +165,25 @@ function savePreferredReactions(): ThunkAction<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let succeeded = false;
|
||||||
|
|
||||||
dispatch({ type: SAVE_PREFERRED_REACTIONS_PENDING });
|
dispatch({ type: SAVE_PREFERRED_REACTIONS_PENDING });
|
||||||
try {
|
try {
|
||||||
await window.storage.put(
|
await window.storage.put(
|
||||||
'preferredReactionEmoji',
|
'preferredReactionEmoji',
|
||||||
draftPreferredReactions
|
draftPreferredReactions
|
||||||
);
|
);
|
||||||
dispatch({ type: SAVE_PREFERRED_REACTIONS_FULFILLED });
|
succeeded = true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
log.warn(Errors.toLogFormat(err));
|
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 });
|
dispatch({ type: SAVE_PREFERRED_REACTIONS_REJECTED });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
CustomColorType,
|
CustomColorType,
|
||||||
DEFAULT_CONVERSATION_COLOR,
|
DEFAULT_CONVERSATION_COLOR,
|
||||||
} from '../../types/Colors';
|
} 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;
|
export const getItems = (state: StateType): ItemsStateType => state.items;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
82
ts/test-both/reactions/preferredReactionEmoji_test.ts
Normal file
82
ts/test-both/reactions/preferredReactionEmoji_test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -243,13 +243,33 @@ describe('preferred reactions duck', () => {
|
||||||
describe('savePreferredReactions', () => {
|
describe('savePreferredReactions', () => {
|
||||||
const { savePreferredReactions } = actions;
|
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 storagePutStub: sinon.SinonStub;
|
||||||
|
let captureChangeStub: sinon.SinonStub;
|
||||||
|
let oldConversationController: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storagePutStub = sinonSandbox.stub(window.storage, 'put').resolves();
|
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', () => {
|
describe('thunk', () => {
|
||||||
it('saves the preferred reaction emoji to storage', async () => {
|
it('saves the preferred reaction emoji to local storage', async () => {
|
||||||
await savePreferredReactions()(
|
await savePreferredReactions()(
|
||||||
sinon.spy(),
|
sinon.spy(),
|
||||||
() => getRootState(stateWithOpenCustomizationModal),
|
() => 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 () => {
|
it('on success, dispatches a pending action followed by a fulfilled action', async () => {
|
||||||
const dispatch = sinon.spy();
|
const dispatch = sinon.spy();
|
||||||
await savePreferredReactions()(
|
await savePreferredReactions()(
|
||||||
|
@ -299,6 +329,18 @@ describe('preferred reactions duck', () => {
|
||||||
type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_REJECTED',
|
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', () => {
|
describe('SAVE_PREFERRED_REACTIONS_FULFILLED', () => {
|
||||||
|
|
Loading…
Reference in a new issue