Show notifications when a user's profile name changes

This commit is contained in:
Scott Nonnenberg 2020-07-29 16:20:05 -07:00
parent 2f015863ca
commit d75eee015f
44 changed files with 749 additions and 194 deletions

View File

@ -346,12 +346,16 @@
"description": "When there are multiple previously-verified group members with safety number changes, a banner will be shown. The list of contacts with safety number changes is shown, and this text introduces that list."
},
"changedRightAfterVerify": {
"message": "The safety number you are trying to verify has changed. Please review your new safety number with $name$. Remember, this change could mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal.",
"message": "The safety number you are trying to verify has changed. Please review your new safety number with $name1$. Remember, this change could mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal.",
"description": "Shown on the safety number screen when the user has selected to verify/unverify a contact's safety number, and we immediately discover a safety number change",
"placeholders": {
"name": {
"name1": {
"content": "$1",
"example": "Bob"
},
"name2": {
"content": "$2",
"example": "Bob"
}
}
},
@ -360,12 +364,16 @@
"description": "Shown on confirmation dialog when user attempts to send a message"
},
"identityKeyErrorOnSend": {
"message": "Your safety number with $name$ has changed. This could either mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
"placeholders": {
"name": {
"name1": {
"content": "$1",
"example": "Bob"
},
"name2": {
"content": "$2",
"example": "Bob"
}
}
},
@ -549,7 +557,7 @@
"message": "When including a non-image attachment, the limit is one attachment per message.",
"description": "An error popup when the user has attempted to add an attachment"
},
"cannotMixImageAdnNonImageAttachments": {
"cannotMixImageAndNonImageAttachments": {
"message": "You cannot mix non-image and image attachments in one message.",
"description": "An error popup when the user has attempted to add an attachment"
},
@ -687,7 +695,7 @@
"content": "$1",
"example": "dog"
},
"searchTerm": {
"conversationName": {
"content": "$2",
"example": "Friends"
}
@ -1378,7 +1386,7 @@
"description": "Brief message shown when trying to message a blocked group"
},
"youChangedTheTimer": {
"message": "You set the disappearing message timer to $time$",
"message": "You set the disappearing message time to $time$.",
"description": "Message displayed when you change the message expiration timer in a conversation.",
"placeholders": {
"time": {
@ -1388,7 +1396,7 @@
}
},
"timerSetOnSync": {
"message": "Updated disappearing message timer to $time$",
"message": "Updated the disappearing message time to $time$.",
"description": "Message displayed when timer is set on initial link of desktop device.",
"placeholders": {
"time": {
@ -1398,7 +1406,7 @@
}
},
"theyChangedTheTimer": {
"message": "$name$ set the disappearing message timer to $time$",
"message": "$name$ set the disappearing message time to $time$.",
"description": "Message displayed when someone else changes the message expiration timer in a conversation.",
"placeholders": {
"name": {
@ -1516,7 +1524,7 @@
"description": "Displayed in the left pane when the timer is turned off"
},
"disabledDisappearingMessages": {
"message": "$name$ disabled disappearing messages",
"message": "$name$ disabled disappearing messages.",
"description": "Displayed in the conversation list when the timer is turned off",
"placeholders": {
"name": {
@ -1526,7 +1534,7 @@
}
},
"youDisabledDisappearingMessages": {
"message": "You disabled disappearing messages",
"message": "You disabled disappearing messages.",
"description": "Displayed in the conversation list when the timer is turned off"
},
"timerSetTo": {
@ -1555,6 +1563,38 @@
"message": "Enable incoming calls",
"description": "Description for incoming calls setting"
},
"contactChangedProfileName": {
"message": "$sender$ changed their profile name from $oldProfile$ to $newProfile$.",
"description": "Description for incoming calls setting",
"placeholders": {
"sender": {
"content": "$1",
"example": "Bob"
},
"oldProfile": {
"content": "$2",
"example": ".x8Skillz8x."
},
"newProfile": {
"content": "$3",
"example": "Bob Smith"
}
}
},
"changedProfileName": {
"message": "$oldProfile$ changed their profile name to $newProfile$.",
"description": "Shown when a contact not in your address book changes their profile name",
"placeholders": {
"oldProfile": {
"content": "$2",
"example": ".x8Skillz8x."
},
"newProfile": {
"content": "$3",
"example": "Bob Smith"
}
}
},
"safetyNumberChanged": {
"message": "Safety Number has changed",
"description": "A notification shown in the conversation when a contact reinstalls"
@ -1578,10 +1618,10 @@
"description": "Label on button included with safety number change notification in the conversation"
},
"yourSafetyNumberWith": {
"message": "Your safety number with $name$:",
"message": "Your safety number with $name1$:",
"description": "Heading for safety number view",
"placeholders": {
"name": {
"name1": {
"content": "$1",
"example": "John"
}
@ -2353,27 +2393,27 @@
"description": "Shown in reaction viewer as the title for the 'all' category"
},
"MessageRequests--message-direct": {
"message": "Do you want to let $name$ message you? They won't know you've seen their message until you accept.",
"message": "Let $name$ message you and share your name and photo with them? They wont know youve seen their messages until you accept.",
"description": "Shown as the message for a message request in a direct message",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
"example": "Cayce"
}
}
},
"MessageRequests--message-direct-blocked": {
"message": "Unblock $name$ to message and call each other.",
"message": "Let $name$ message you and share your name and photo with them? You won't receive any messages until you unblock them.",
"description": "Shown as the message for a message request in a direct message with a blocked account",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
"example": "Cayce"
}
}
},
"MessageRequests--message-group": {
"message": "Do you want to join $group$? They won't know you've seen their message until you accept.",
"message": "Join this group and share your name and photo with its members? They wont know youve seen their messages until you accept.",
"description": "Shown as the message for a message request in a group",
"placeholders": {
"name": {
@ -2383,7 +2423,7 @@
}
},
"MessageRequests--message-group-blocked": {
"message": "Unblock to allow group members to add you to this group again.",
"message": "Unblock this group and share your name and photo with its members? You won't receive any messages until you unblock them.",
"description": "Shown as the message for a message request in a blocked group"
},
"MessageRequests--block": {

View File

@ -479,6 +479,7 @@
typingContact: typingContact ? typingContact.format() : null,
lastUpdated: this.get('timestamp'),
name: this.get('name'),
firstName: this.get('profileName'),
profileName: this.getProfileName(),
timestamp,
inboxPosition,
@ -1081,9 +1082,43 @@
id,
})
);
this.trigger('newmessage', model);
},
async addProfileChange(profileChange, conversationId) {
const message = {
conversationId: this.id,
type: 'profile-change',
sent_at: Date.now(),
received_at: Date.now(),
unread: true,
changedId: conversationId || this.id,
profileChange,
};
const id = await window.Signal.Data.saveMessage(message, {
Message: Whisper.Message,
});
const model = MessageController.register(
id,
new Whisper.Message({
...message,
id,
})
);
this.trigger('newmessage', model);
if (this.isPrivate()) {
ConversationController.getAllGroupsInvolvingId(this.id).then(groups => {
_.forEach(groups, group => {
group.addProfileChange(profileChange, this.id);
});
});
}
},
async onReadMessage(message, readAt) {
// We mark as read everything older than this message - to clean up old stuff
// still marked unread in the database. If the user generally doesn't read in
@ -2489,8 +2524,28 @@
);
// encode
const profileFamilyName = family ? stringFromBytes(family) : null;
const profileName = given ? stringFromBytes(given) : null;
const profileFamilyName = family ? stringFromBytes(family) : null;
// check for changes
const oldName = this.getProfileName();
const newName = Util.combineNames(profileName, profileFamilyName);
const hadPreviousName = Boolean(oldName);
// Note that we compare the combined names to ensure that we don't present the exact
// same before/after string, even if someone is moving from just first name to
// first/last name in their profile data.
const nameChanged = oldName !== newName;
if (!this.isMe() && hadPreviousName && nameChanged) {
const change = {
type: 'name',
oldName,
newName,
};
this.addProfileChange(change);
}
// set
this.set({ profileName, profileFamilyName });

View File

@ -183,6 +183,11 @@
type: 'callHistory',
data: this.getPropsForCallHistory(),
};
} else if (this.isProfileChange()) {
return {
type: 'profileChange',
data: this.getPropsForProfileChange(),
};
}
return {
@ -364,6 +369,9 @@
isCallHistory() {
return this.get('type') === 'call-history';
},
isProfileChange() {
return this.get('type') === 'profile-change';
},
// Props for each message type
getPropsForUnsupportedMessage() {
@ -508,6 +516,16 @@
callHistoryDetails: this.get('callHistoryDetails'),
};
},
getPropsForProfileChange() {
const change = this.get('profileChange');
const changedId = this.get('changedId');
return {
changedContact: this.findAndFormatContact(changedId),
change,
};
},
getAttachmentsForMessage() {
const sticker = this.get('sticker');
if (sticker && sticker.data) {
@ -856,6 +874,17 @@
if (this.isUnsupportedMessage()) {
return i18n('message--getDescription--unsupported-message');
}
if (this.isProfileChange()) {
const change = this.get('profileChange');
const changedId = this.get('changedId');
const changedContact = this.findAndFormatContact(changedId);
return Signal.Util.getStringForProfileChange(
change,
changedContact,
i18n
);
}
if (this.isTapToView()) {
if (this.isErased()) {
return i18n('message--getDescription--disappearing-media');
@ -884,7 +913,9 @@
if (groupUpdate.left === 'You') {
return i18n('youLeftTheGroup');
} else if (groupUpdate.left) {
return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left));
return i18n('leftTheGroup', [
this.getNameForNumber(groupUpdate.left),
]);
}
if (!fromContact) {
@ -894,7 +925,7 @@
if (fromContact.isMe()) {
messages.push(i18n('youUpdatedTheGroup'));
} else {
messages.push(i18n('updatedTheGroup', fromContact.getTitle()));
messages.push(i18n('updatedTheGroup', [fromContact.getTitle()]));
}
if (groupUpdate.joined && groupUpdate.joined.length) {
@ -907,10 +938,11 @@
if (joinedContacts.length > 1) {
messages.push(
i18n(
'multipleJoinedTheGroup',
_.map(joinedWithoutMe, contact => contact.getTitle()).join(', ')
)
i18n('multipleJoinedTheGroup', [
_.map(joinedWithoutMe, contact => contact.getTitle()).join(
', '
),
])
);
if (joinedWithoutMe.length < joinedContacts.length) {
@ -925,14 +957,14 @@
messages.push(i18n('youJoinedTheGroup'));
} else {
messages.push(
i18n('joinedTheGroup', joinedContacts[0].getTitle())
i18n('joinedTheGroup', [joinedContacts[0].getTitle()])
);
}
}
}
if (groupUpdate.name) {
messages.push(i18n('titleIsNow', groupUpdate.name));
messages.push(i18n('titleIsNow', [groupUpdate.name]));
}
if (groupUpdate.avatarUpdated) {
messages.push(i18n('updatedGroupAvatar'));
@ -965,18 +997,16 @@
return i18n('disappearingMessagesDisabled');
}
return i18n(
'timerSetTo',
Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer || 0)
);
return i18n('timerSetTo', [
Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer || 0),
]);
}
if (this.isKeyChange()) {
const identifier = this.get('key_changed');
const conversation = this.findContact(identifier);
return i18n(
'safetyNumberChangedGroup',
conversation ? conversation.getTitle() : null
);
return i18n('safetyNumberChangedGroup', [
conversation ? conversation.getTitle() : null,
]);
}
const contacts = this.get('contact');
if (contacts && contacts.length) {

View File

@ -1,5 +1,7 @@
/* eslint-env node */
/* global log */
/* eslint-env node, browser */
// eslint-disable-next-line no-console
const log = typeof window !== 'undefined' ? window.log : console;
exports.setup = (locale, messages) => {
if (!locale) {
@ -17,18 +19,57 @@ exports.setup = (locale, messages) => {
);
return '';
}
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
if (
typeof substitutions === 'string' ||
typeof substitutions === 'number'
) {
throw new Error('You must provide either a map or an array');
}
const { message } = entry;
if (Array.isArray(substitutions)) {
if (!substitutions) {
return message;
} else if (Array.isArray(substitutions)) {
return substitutions.reduce(
(result, substitution) => result.replace(/\$.+?\$/, substitution),
message
);
} else if (substitutions) {
return message.replace(/\$.+?\$/, substitutions);
}
return message;
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
let match = FIND_REPLACEMENTS.exec(message);
let builder = '';
let lastTextIndex = 0;
while (match) {
if (lastTextIndex < match.index) {
builder += message.slice(lastTextIndex, match.index);
}
const placeholderName = match[1];
const value = substitutions[placeholderName];
if (!value) {
log.error(
`i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
);
}
builder += value || '';
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(message);
}
if (lastTextIndex < message.length) {
builder += message.slice(lastTextIndex);
}
return builder;
}
getMessage.getLocale = () => locale;

View File

@ -93,18 +93,18 @@
iconUrl = last.iconUrl;
if (numNotifications === 1) {
if (last.reaction) {
message = i18n('notificationReaction', [
lastMessageTitle,
last.reaction.emoji,
]);
message = i18n('notificationReaction', {
sender: lastMessageTitle,
emoji: last.reaction.emoji,
});
} else {
message = `${i18n('notificationFrom')} ${lastMessageTitle}`;
}
} else if (last.reaction) {
message = i18n('notificationReactionMostRecent', [
lastMessageTitle,
last.reaction.emoji,
]);
message = i18n('notificationReactionMostRecent', {
sender: lastMessageTitle,
emoji: last.reaction.emoji,
});
} else {
message = `${i18n(
'notificationMostRecentFrom'
@ -117,22 +117,22 @@
// eslint-disable-next-line prefer-destructuring
title = last.title;
if (last.reaction) {
message = i18n('notificationReactionMessage', [
last.title,
last.reaction.emoji,
last.message,
]);
message = i18n('notificationReactionMessage', {
sender: last.title,
emoji: last.reaction.emoji,
message: last.message,
});
} else {
// eslint-disable-next-line prefer-destructuring
message = last.message;
}
} else if (last.reaction) {
title = newMessageCountLabel;
message = i18n('notificationReactionMessageMostRecent', [
last.title,
last.reaction.emoji,
last.message,
]);
message = i18n('notificationReactionMessageMostRecent', {
sender: last.title,
emoji: last.reaction.emoji,
message: last.message,
});
} else {
title = newMessageCountLabel;
message = `${i18n('notificationMostRecent')} ${last.message}`;

View File

@ -210,7 +210,7 @@
template: i18n('oneNonImageAtATimeToast'),
});
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
template: i18n('cannotMixImageAdnNonImageAttachments'),
template: i18n('cannotMixImageAndNonImageAttachments'),
});
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: i18n('maximumAttachments'),
@ -1655,7 +1655,7 @@
if (unverified.length > 1) {
message = i18n('multipleNoLongerVerified');
} else {
message = i18n('noLongerVerified', unverified.at(0).getTitle());
message = i18n('noLongerVerified', [unverified.at(0).getTitle()]);
}
// Need to re-add, since unverified set may have changed
@ -2030,10 +2030,10 @@
}
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('identityKeyErrorOnSend', [
contact.getTitle(),
contact.getTitle(),
]),
message: i18n('identityKeyErrorOnSend', {
name1: contact.getTitle(),
name2: contact.getTitle(),
}),
okText: i18n('sendAnyway'),
resolve: async () => {
await contact.updateVerified();

View File

@ -63,7 +63,7 @@
className: 'app-loading-screen',
updateProgress(count) {
if (count > 0) {
const message = i18n('loadingMessages', count.toString());
const message = i18n('loadingMessages', [count.toString()]);
this.$('.message').text(message);
}
},

View File

@ -57,7 +57,10 @@ export const UploadStage = () => {
<div className={styles.base}>
<H2>{i18n('StickerCreator--UploadStage--title')}</H2>
<Text>
{i18n('StickerCreator--UploadStage-uploaded', [complete, total])}
{i18n('StickerCreator--UploadStage-uploaded', {
count: complete,
total,
})}
</Text>
<ProgressBar
count={complete}

View File

@ -2,9 +2,13 @@ import * as React from 'react';
export type I18nFn = (
key: string,
substitutions?: Array<string | number>
substitutions?: Array<string | number> | ReplacementValuesType
) => string;
export type ReplacementValuesType = {
[key: string]: string | number;
};
const I18nContext = React.createContext<I18nFn>(() => 'NO LOCALE LOADED');
export type I18nProps = {
@ -14,11 +18,55 @@ export type I18nProps = {
export const I18n = ({ messages, children }: I18nProps) => {
const getMessage = React.useCallback<I18nFn>(
(key, substitutions = []) =>
substitutions.reduce<string>(
(res, sub) => res.replace(/\$.+?\$/, sub),
messages[key].message
),
(key, substitutions) => {
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
const { message } = messages[key];
if (!substitutions) {
return message;
} else if (Array.isArray(substitutions)) {
return substitutions.reduce(
(result, substitution) =>
result.toString().replace(/\$.+?\$/, substitution.toString()),
message
) as string;
}
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
let match = FIND_REPLACEMENTS.exec(message);
let builder = '';
let lastTextIndex = 0;
while (match) {
if (lastTextIndex < match.index) {
builder += message.slice(lastTextIndex, match.index);
}
const placeholderName = match[1];
const value = substitutions[placeholderName];
if (!value) {
// tslint:disable-next-line no-console
console.error(
`i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
);
}
builder += value || '';
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(message);
}
if (lastTextIndex < message.length) {
builder += message.slice(lastTextIndex);
}
return builder;
},
[messages]
);

View File

@ -8795,6 +8795,45 @@ button.module-image__border-overlay:focus {
padding-right: 0px;
}
// Module: Profile Change Notification
.module-profile-change-notification {
@include font-body-1;
margin-left: 2em;
margin-right: 2em;
text-align: center;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-profile-change-notification--icon {
@include light-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-gray-05
);
}
height: 20px;
width: 20px;
margin-left: auto;
margin-right: auto;
}
/* Third-party module: react-tooltip-lite */
.react-tooltip-lite {

View File

@ -6,20 +6,25 @@ describe('i18n', () => {
assert.strictEqual(i18n('random'), '');
});
it('returns message for given string', () => {
assert.equal(i18n('reportIssue'), 'Report an issue');
assert.equal(i18n('reportIssue'), ['Report an issue']);
});
it('returns message with single substitution', () => {
const actual = i18n('cannotUpdateDetail', 'https://signal.org/download');
const actual = i18n('cannotUpdateDetail', [
'https://signal.org/download',
]);
assert.equal(
actual,
'Signal Desktop failed to update, but there is a new version available. Please go to https://signal.org/download and install the new version manually, then either contact support or file a bug about this problem.'
);
});
it('returns message with multiple substitutions', () => {
const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']);
const actual = i18n('theyChangedTheTimer', {
name: 'Someone',
time: '5 minutes',
});
assert.equal(
actual,
'Someone set the disappearing message timer to 5 minutes'
'Someone set the disappearing message time to 5 minutes.'
);
});
});

View File

@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import { isNumber } from 'lodash';
import { Avatar } from './Avatar';
import { MessageBody } from './conversation/MessageBody';
@ -19,10 +20,10 @@ export type PropsData = {
name?: string;
type: 'group' | 'direct';
avatarPath?: string;
isMe: boolean;
isMe?: boolean;
lastUpdated: number;
unreadCount: number;
unreadCount?: number;
isSelected: boolean;
draftPreview?: string;
@ -80,7 +81,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
public renderUnread() {
const { unreadCount } = this.props;
if (unreadCount > 0) {
if (isNumber(unreadCount) && unreadCount > 0) {
return (
<div className="module-conversation-list-item__unread-count">
{unreadCount}
@ -103,12 +104,14 @@ export class ConversationListItem extends React.PureComponent<Props> {
title,
} = this.props;
const withUnread = isNumber(unreadCount) && unreadCount > 0;
return (
<div className="module-conversation-list-item__header">
<div
className={classNames(
'module-conversation-list-item__header__name',
unreadCount > 0
withUnread
? 'module-conversation-list-item__header__name--with-unread'
: null
)}
@ -128,7 +131,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
<div
className={classNames(
'module-conversation-list-item__header__date',
unreadCount > 0
withUnread
? 'module-conversation-list-item__header__date--has-unread'
: null
)}
@ -137,7 +140,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
timestamp={lastUpdated}
extended={false}
module="module-conversation-list-item__header__timestamp"
withUnread={unreadCount > 0}
withUnread={withUnread}
i18n={i18n}
/>
</div>
@ -158,6 +161,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
return null;
}
const withUnread = isNumber(unreadCount) && unreadCount > 0;
const showingDraft = shouldShowDraft && draftPreview;
const deletedForEveryone = Boolean(
lastMessage && lastMessage.deletedForEveryone
@ -178,7 +182,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
dir="auto"
className={classNames(
'module-conversation-list-item__message__text',
unreadCount > 0
withUnread
? 'module-conversation-list-item__message__text--has-unread'
: null
)}
@ -219,6 +223,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
public render() {
const { unreadCount, onClick, id, isSelected, style } = this.props;
const withUnread = isNumber(unreadCount) && unreadCount > 0;
return (
<button
@ -230,7 +235,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
style={style}
className={classNames(
'module-conversation-list-item',
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
withUnread ? 'module-conversation-list-item--has-unread' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null
)}
data-id={cleanId(id)}

View File

@ -1,7 +1,7 @@
#### No replacements
```jsx
<Intl id="leftTheGroup" i18n={util.i18n} />
<Intl id="deleteAndRestart" i18n={util.i18n} />
```
#### Single string replacement
@ -33,7 +33,10 @@
<Intl
id="changedSinceVerified"
i18n={util.i18n}
components={['Alice', 'Bob']}
components={{
name1: 'Alice',
name2: 'Bob',
}}
/>
```
@ -43,19 +46,23 @@
<Intl
id="changedSinceVerified"
i18n={util.i18n}
components={[
<button
key="external-1"
style={{ backgroundColor: 'blue', color: 'white' }}
>
Alice
</button>,
<button
key="external-2"
style={{ backgroundColor: 'black', color: 'white' }}
>
Bob
</button>,
]}
components={{
name1: (
<button
key="external-1"
style={{ backgroundColor: 'blue', color: 'white' }}
>
Alice
</button>
),
name2: (
<button
key="external-2"
style={{ backgroundColor: 'black', color: 'white' }}
>
Bob
</button>
),
}}
/>
```

View File

@ -1,6 +1,7 @@
import React from 'react';
import { LocalizerType, RenderTextCallbackType } from '../types/Util';
import { ReplacementValuesType } from '../types/I18N';
export type FullJSXType = Array<JSX.Element | string> | JSX.Element | string;
@ -8,7 +9,7 @@ interface Props {
/** The translation string id */
id: string;
i18n: LocalizerType;
components?: Array<FullJSXType>;
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>;
renderText?: RenderTextCallbackType;
}
@ -19,27 +20,53 @@ export class Intl extends React.Component<Props> {
),
};
public getComponent(index: number, key: number): FullJSXType | undefined {
public getComponent(
index: number,
placeholderName: string,
key: number
): FullJSXType | undefined {
const { id, components } = this.props;
if (!components || !components.length || components.length <= index) {
if (!components) {
// tslint:disable-next-line no-console
console.log(
`Error: Intl missing provided components for id ${id}, index ${index}`
`Error: Intl component prop not provided; Metadata: id '${id}', index ${index}, placeholder '${placeholderName}'`
);
return;
}
if (Array.isArray(components)) {
if (!components || !components.length || components.length <= index) {
// tslint:disable-next-line no-console
console.log(
`Error: Intl missing provided component for id '${id}', index ${index}`
);
return;
}
return <React.Fragment key={key}>{components[index]}</React.Fragment>;
}
const value = components[placeholderName];
if (!value) {
// tslint:disable-next-line no-console
console.log(
`Error: Intl missing provided component for id '${id}', placeholder '${placeholderName}'`
);
return;
}
return <React.Fragment key={key}>{components[index]}</React.Fragment>;
return <React.Fragment key={key}>{value}</React.Fragment>;
}
public render() {
const { id, i18n, renderText } = this.props;
const { components, id, i18n, renderText } = this.props;
const text = i18n(id);
const results: Array<any> = [];
const FIND_REPLACEMENTS = /\$[^$]+\$/g;
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
// We have to do this, because renderText is not required in our Props object,
// but it is always provided via defaultProps.
@ -47,6 +74,12 @@ export class Intl extends React.Component<Props> {
return;
}
if (Array.isArray(components) && components.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
let componentIndex = 0;
let key = 0;
let lastTextIndex = 0;
@ -63,7 +96,8 @@ export class Intl extends React.Component<Props> {
key += 1;
}
results.push(this.getComponent(componentIndex, key));
const placeholderName = match[1];
results.push(this.getComponent(componentIndex, placeholderName, key));
componentIndex += 1;
key += 1;

View File

@ -23,7 +23,7 @@ export interface PropsType {
// For display
phoneNumber?: string;
isMe: boolean;
isMe?: boolean;
name?: string;
color?: ColorType;
isVerified?: boolean;

View File

@ -16,6 +16,7 @@ const contactWithAllData = {
avatarPath: undefined,
color: 'signal-blue',
profileName: '-*Smartest Dude*-',
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '(305) 123-4567',
} as ConversationType;
@ -23,6 +24,7 @@ const contactWithAllData = {
const contactWithJustProfile = {
avatarPath: undefined,
color: 'signal-blue',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
phoneNumber: '(305) 123-4567',
@ -33,6 +35,7 @@ const contactWithJustNumber = {
color: 'signal-blue',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
phoneNumber: '(305) 123-4567',
} as ConversationType;
@ -43,6 +46,7 @@ const contactWithNothing = {
profileName: undefined,
name: undefined,
phoneNumber: undefined,
title: 'Unknown contact',
} as ConversationType;
storiesOf('Components/SafetyNumberChangeDialog', module)

View File

@ -52,10 +52,7 @@ const SafetyDialogContents = ({
const shouldShowNumber = Boolean(contact.name || contact.profileName);
return (
<li
className="module-sfn-dialog__contact"
key={contact.phoneNumber}
>
<li className="module-sfn-dialog__contact" key={contact.id}>
<Avatar
avatarPath={contact.avatarPath}
color={contact.color}

View File

@ -14,6 +14,7 @@ import { storiesOf } from '@storybook/react';
const i18n = setupI18n('en', enMessages);
const contactWithAllData = {
title: 'Summer Smith',
name: 'Summer Smith',
phoneNumber: '(305) 123-4567',
isVerified: true,
@ -22,6 +23,7 @@ const contactWithAllData = {
const contactWithJustProfile = {
avatarPath: undefined,
color: 'signal-blue',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
phoneNumber: '(305) 123-4567',
@ -32,6 +34,7 @@ const contactWithJustNumber = {
color: 'signal-blue',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
phoneNumber: '(305) 123-4567',
} as ConversationType;
@ -40,6 +43,7 @@ const contactWithNothing = {
avatarPath: undefined,
color: 'signal-blue',
profileName: undefined,
title: 'Unknown contact',
name: undefined,
phoneNumber: undefined,
} as ConversationType;

View File

@ -36,10 +36,8 @@ export const SafetyNumberViewer = ({
const showNumber = Boolean(contact.name || contact.profileName);
const numberFragment = showNumber ? ` · ${contact.phoneNumber}` : '';
const name = `${contact.title}${numberFragment}`;
const boldName = (key?: number) => (
<span className="module-safety-number__bold-name" key={key}>
{name}
</span>
const boldName = (
<span className="module-safety-number__bold-name">{name}</span>
);
const isVerified = contact.isVerified;
@ -62,20 +60,23 @@ export const SafetyNumberViewer = ({
<Intl
i18n={i18n}
id={safetyNumberChangedKey}
components={[boldName(1), boldName(2)]}
components={{
name1: boldName,
name2: boldName,
}}
/>
</div>
<div className="module-safety-number__number">
{safetyNumber || getPlaceholder()}
</div>
<Intl i18n={i18n} id="verifyHelp" components={[boldName()]} />
<Intl i18n={i18n} id="verifyHelp" components={[boldName]} />
<div className="module-safety-number__verification-status">
{isVerified ? (
<span className="module-safety-number__icon--verified" />
) : (
<span className="module-safety-number__icon--shield" />
)}
<Intl i18n={i18n} id={verifiedStatusKey} components={[boldName()]} />
<Intl i18n={i18n} id={verifiedStatusKey} components={[boldName]} />
</div>
<div className="module-safety-number__verify-container">
<button

View File

@ -554,10 +554,12 @@ export class SearchResults extends React.Component<PropsType, StateType> {
<Intl
id="noSearchResultsInConversation"
i18n={i18n}
components={[
components={{
searchTerm,
<Emojify key="item-1" text={searchConversationName} />,
]}
conversationName: (
<Emojify key="item-1" text={searchConversationName} />
),
}}
/>
) : (
i18n('noSearchResults', [searchTerm])

View File

@ -71,10 +71,10 @@ export const UpdateDialog = ({
<h3>{i18n('cannotUpdate')}</h3>
<span>
<Intl
components={[
<strong key="app">Signal.app</strong>,
<strong key="folder">/Applications</strong>,
]}
components={{
app: <strong key="app">Signal.app</strong>,
folder: <strong key="folder">/Applications</strong>,
}}
i18n={i18n}
id="readOnlyVolume"
/>

View File

@ -1,18 +1,18 @@
import React from 'react';
import { Emojify } from './Emojify';
import { LocalizerType } from '../../types/Util';
import { Emojify } from './Emojify';
export interface Props {
title: string;
phoneNumber?: string;
name?: string;
profileName?: string;
module?: string;
export interface PropsType {
i18n: LocalizerType;
title: string;
module?: string;
name?: string;
phoneNumber?: string;
profileName?: string;
}
export class ContactName extends React.Component<Props> {
export class ContactName extends React.Component<PropsType> {
public render() {
const { module, title } = this.props;
const prefix = module ? module : 'module-contact-name';

View File

@ -35,15 +35,46 @@ const renderMembershipRow = ({
</strong>
));
return (
<div className={className}>
<Intl
i18n={i18n}
id={`ConversationHero--membership-${firstThreeGroups.length}`}
components={firstThreeGroups}
/>
</div>
);
if (firstThreeGroups.length >= 3) {
return (
<div className={className}>
<Intl
i18n={i18n}
id="ConversationHero--membership-3"
components={{
group1: firstThreeGroups[0],
group2: firstThreeGroups[1],
group3: firstThreeGroups[2],
}}
/>
</div>
);
} else if (firstThreeGroups.length >= 2) {
return (
<div className={className}>
<Intl
i18n={i18n}
id="ConversationHero--membership-2"
components={{
group1: firstThreeGroups[0],
group2: firstThreeGroups[1],
}}
/>
</div>
);
} else if (firstThreeGroups.length >= 1) {
return (
<div className={className}>
<Intl
i18n={i18n}
id="ConversationHero--membership-1"
components={{
group: firstThreeGroups[0],
}}
/>
</div>
);
}
}
return null;
@ -87,8 +118,6 @@ export const ConversationHero = ({
...groups.map(g => `g-${g}`),
]);
const displayName =
name || (conversationType === 'group' ? i18n('unknownGroup') : undefined);
const phoneNumberOnly = Boolean(
!name && !profileName && conversationType === 'direct'
);
@ -113,7 +142,7 @@ export const ConversationHero = ({
) : (
<ContactName
title={title}
name={displayName}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
i18n={i18n}

View File

@ -17,6 +17,7 @@ const i18n = setupI18n('en', enMessages);
const getBaseProps = (isGroup = false): MessageRequestActionsProps => ({
i18n,
conversationType: isGroup ? 'group' : 'direct',
firstName: text('firstName', 'Cayce'),
title: isGroup
? text('title', 'NYC Rock Climbers')
: text('title', 'Cayce Bollard'),

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import classNames from 'classnames';
import { ContactName, Props as ContactNameProps } from './ContactName';
import { ContactName, PropsType as ContactNameProps } from './ContactName';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
@ -11,6 +11,7 @@ import { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
firstName?: string;
onAccept(): unknown;
} & Omit<ContactNameProps, 'module' | 'i18n'> &
Omit<
@ -20,18 +21,19 @@ export type Props = {
// tslint:disable-next-line max-func-body-length
export const MessageRequestActions = ({
i18n,
name,
profileName,
phoneNumber,
title,
conversationType,
firstName,
i18n,
isBlocked,
name,
onAccept,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
onAccept,
onUnblock,
phoneNumber,
profileName,
title,
}: Props) => {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
@ -69,7 +71,7 @@ export const MessageRequestActions = ({
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
title={title}
title={firstName || title}
i18n={i18n}
/>
</strong>,

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { ContactName, Props as ContactNameProps } from './ContactName';
import { ContactName, PropsType as ContactNameProps } from './ContactName';
import { ConfirmationModal } from '../ConfirmationModal';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
@ -25,18 +25,18 @@ export type Props = {
// tslint:disable-next-line: max-func-body-length
export const MessageRequestActionsConfirmation = ({
conversationType,
i18n,
name,
profileName,
phoneNumber,
title,
conversationType,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
state,
onChangeState,
onDelete,
onUnblock,
phoneNumber,
profileName,
state,
title,
}: Props) => {
if (state === MessageRequestState.blocking) {
return (

View File

@ -0,0 +1,51 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../\_locales/en/messages.json';
import { ProfileChangeNotification } from './ProfileChangeNotification';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/Conversation/ProfileChangeNotification', module)
.add('From contact', () => {
return (
<ProfileChangeNotification
i18n={i18n}
changedContact={{
id: 'some-guid',
type: 'direct',
title: 'John',
name: 'John',
lastUpdated: Date.now(),
}}
change={{
type: 'name',
oldName: 'John Old',
newName: 'John New',
}}
/>
);
})
.add('From non-contact', () => {
return (
<ProfileChangeNotification
i18n={i18n}
changedContact={{
id: 'some-guid',
type: 'direct',
title: 'John',
lastUpdated: Date.now(),
}}
change={{
type: 'name',
oldName: 'John Old',
newName: 'John New',
}}
/>
);
});

View File

@ -0,0 +1,26 @@
import React from 'react';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import {
getStringForProfileChange,
ProfileNameChangeType,
} from '../../util/getStringForProfileChange';
export interface PropsType {
change: ProfileNameChangeType;
changedContact: ConversationType;
i18n: LocalizerType;
}
export function ProfileChangeNotification(props: PropsType): JSX.Element {
const { change, changedContact, i18n } = props;
const message = getStringForProfileChange(change, changedContact, i18n);
return (
<div className="module-profile-change-notification">
<div className="module-profile-change-notification--icon" />
{message}
</div>
);
}

View File

@ -36,6 +36,10 @@ import {
PropsData as GroupNotificationProps,
} from './GroupNotification';
import { ResetSessionNotification } from './ResetSessionNotification';
import {
ProfileChangeNotification,
PropsType as ProfileChangeNotificationPropsType,
} from './ProfileChangeNotification';
type CallHistoryType = {
type: 'callHistory';
@ -73,16 +77,22 @@ type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
};
type ProfileChangeNotificationType = {
type: 'profileChange';
data: ProfileChangeNotificationPropsType;
};
export type TimelineItemType =
| CallHistoryType
| GroupNotificationType
| LinkNotificationType
| MessageType
| ProfileChangeNotificationType
| ResetSessionNotificationType
| SafetyNumberNotificationType
| TimerNotificationType
| UnsupportedMessageType
| VerificationNotificationType
| GroupNotificationType;
| VerificationNotificationType;
type PropsLocalType = {
conversationId: string;
@ -159,6 +169,10 @@ export class TimelineItem extends React.PureComponent<PropsType> {
notification = (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification {...this.props} {...item.data} i18n={i18n} />
);
} else {
throw new Error('TimelineItem: Unknown type!');
}

View File

@ -43,17 +43,19 @@ export class TimerNotification extends React.Component<Props> {
<Intl
i18n={i18n}
id={changeKey}
components={[
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>,
timespan,
]}
components={{
name: (
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>
),
time: timespan,
}}
/>
);
case 'fromMe':

View File

@ -248,6 +248,7 @@ export class CallingClass {
}
}
// If we return null here, we hang up the call.
private async handleIncomingCall(call: Call): Promise<CallSettings | null> {
if (!this.uxActions || !this.localDeviceId) {
window.log.error('Missing required objects, ignoring incoming call.');

View File

@ -27,6 +27,7 @@ export type ConversationType = {
uuid?: string;
e164?: string;
name?: string;
firstName?: string;
profileName?: string;
avatarPath?: string;
color?: ColorType;
@ -43,11 +44,11 @@ export type ConversationType = {
phoneNumber?: string;
membersCount?: number;
type: 'direct' | 'group';
isMe: boolean;
isMe?: boolean;
lastUpdated: number;
title: string;
unreadCount: number;
isSelected: boolean;
unreadCount?: number;
isSelected?: boolean;
typingContact?: {
avatarPath?: string;
color: string;

View File

@ -21,14 +21,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return {
i18n: getIntl(state),
avatarPath: conversation.avatarPath,
color: conversation.color,
...conversation,
conversationType: conversation.type,
isMe: conversation.isMe,
membersCount: conversation.membersCount,
name: conversation.name,
phoneNumber: conversation.phoneNumber,
profileName: conversation.profileName,
};
};

View File

@ -5,6 +5,7 @@ import {
IncomingMessage,
MessageHistoryUnsyncedMessage,
OutgoingMessage,
ProfileChangeNotificationMessage,
VerifiedChangeMessage,
} from '../../types/Message';
@ -123,5 +124,29 @@ describe('Conversation', () => {
assert.deepEqual(actual, expected);
});
});
context('for profile change message', () => {
it('should update message but not timestamp (to prevent bump to top)', () => {
const input = {
currentTimestamp: 555,
lastMessage: {
type: 'profile-change',
conversationId: 'foo',
sent_at: 666,
timestamp: 666,
} as ProfileChangeNotificationMessage,
lastMessageNotificationText: 'John changed their profile name',
};
const expected = {
lastMessage: 'John changed their profile name',
lastMessageStatus: null,
lastMessageDeletedForEveryone: undefined,
timestamp: 555,
};
const actual = Conversation.createLastMessageUpdate(input);
assert.deepEqual(actual, expected);
});
});
});
});

View File

@ -28,6 +28,7 @@ export const createLastMessageUpdate = ({
const { type, expirationTimerUpdate, deletedForEveryone } = lastMessage;
const isMessageHistoryUnsynced = type === 'message-history-unsynced';
const isProfileChangedMessage = type === 'profile-change';
const isVerifiedChangeMessage = type === 'verified-change';
const isExpireTimerUpdateFromSync = Boolean(
expirationTimerUpdate && expirationTimerUpdate.fromSync
@ -35,6 +36,7 @@ export const createLastMessageUpdate = ({
const shouldUpdateTimestamp = Boolean(
!isMessageHistoryUnsynced &&
!isProfileChangedMessage &&
!isVerifiedChangeMessage &&
!isExpireTimerUpdateFromSync
);

View File

@ -5,7 +5,14 @@ export type LocaleMessagesType = {
};
};
export type ReplacementValuesType<T> = {
[key: string]: T;
};
export type LocaleType = {
i18n: (key: string, placeholders: Array<string>) => string;
i18n: (
key: string,
placeholders: Array<string> | ReplacementValuesType<string>
) => string;
messages: LocaleMessagesType;
};

View File

@ -6,6 +6,7 @@ export type Message = (
| UserMessage
| VerifiedChangeMessage
| MessageHistoryUnsyncedMessage
| ProfileChangeNotificationMessage
) & { deletedForEveryone?: boolean };
export type UserMessage = IncomingMessage | OutgoingMessage;
@ -77,6 +78,14 @@ export type MessageHistoryUnsyncedMessage = Readonly<
ExpirationTimerUpdate
>;
export type ProfileChangeNotificationMessage = Readonly<
{
type: 'profile-change';
} & SharedMessageProperties &
MessageSchemaVersion5 &
ExpirationTimerUpdate
>;
type SharedMessageProperties = Readonly<{
conversationId: string;
sent_at: number;

View File

@ -3,7 +3,14 @@ export type RenderTextCallbackType = (options: {
key: number;
}) => JSX.Element | string;
export type LocalizerType = (key: string, values?: Array<string>) => string;
export type ReplacementValuesType = {
[key: string]: string;
};
export type LocalizerType = (
key: string,
values?: Array<string> | ReplacementValuesType
) => string;
export type ColorType =
| 'red'

View File

@ -19,6 +19,9 @@ export const initializeAttachmentMetadata = async (
if (message.type === 'message-history-unsynced') {
return message;
}
if (message.type === 'profile-change') {
return message;
}
if (message.messageTimer || message.isViewOnce) {
return message;
}

View File

@ -367,7 +367,10 @@ async function showFallbackReadOnlyDialog(
type: 'warning',
buttons: [locale.messages.ok.message],
title: locale.messages.cannotUpdate.message,
message: locale.i18n('readOnlyVolume', ['Signal.app', '/Applications']),
message: locale.i18n('readOnlyVolume', {
app: 'Signal.app',
folder: '/Applications',
}),
};
showingReadOnlyDialog = true;

View File

@ -0,0 +1,29 @@
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
export type ProfileNameChangeType = {
type: 'name';
oldName: string;
newName: string;
};
export function getStringForProfileChange(
change: ProfileNameChangeType,
changedContact: ConversationType,
i18n: LocalizerType
) {
if (change.type === 'name') {
return changedContact.name
? i18n('contactChangedProfileName', {
sender: changedContact.title,
oldProfile: change.oldName,
newProfile: change.newName,
})
: i18n('changedProfileName', {
oldProfile: change.oldName,
newProfile: change.newName,
});
} else {
throw new Error('TimelineItem: Unknown type!');
}
}

View File

@ -10,6 +10,7 @@ import {
generateSecurityNumber,
getPlaceholder as getSafetyNumberPlaceholder,
} from './safetyNumber';
import { getStringForProfileChange } from './getStringForProfileChange';
import { hasExpired } from './hasExpired';
import { isFileDangerous } from './isFileDangerous';
import { makeLookup } from './makeLookup';
@ -31,6 +32,7 @@ export {
eraseAllStorageServiceState,
generateSecurityNumber,
getSafetyNumberPlaceholder,
getStringForProfileChange,
GoogleChrome,
hasExpired,
isFileDangerous,

View File

@ -207,7 +207,7 @@
"rule": "jQuery-wrap(",
"path": "js/models/conversations.js",
"line": " await wrap(",
"lineNumber": 664,
"lineNumber": 665,
"reasonCategory": "falseMatch",
"updated": "2020-06-09T20:26:46.515Z"
},
@ -243,6 +243,14 @@
"reasonCategory": "falseMatch",
"updated": "2019-10-31T17:28:08.684Z"
},
{
"rule": "jQuery-$(",
"path": "js/modules/i18n.js",
"line": " const FIND_REPLACEMENTS = /\\$([^$]+)\\$/g;",
"lineNumber": 44,
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "jQuery-load(",
"path": "js/modules/stickers.js",
@ -11416,6 +11424,14 @@
"reasonCategory": "falseMatch",
"updated": "2020-04-13T23:38:26.065Z"
},
{
"rule": "jQuery-$(",
"path": "sticker-creator/util/i18n.tsx",
"line": " const FIND_REPLACEMENTS = /\\$([^$]+)\\$/g;",
"lineNumber": 39,
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/backbone/views/Lightbox.js",
@ -11506,6 +11522,22 @@
"updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "jQuery-$(",
"path": "ts/components/Intl.js",
"line": " const FIND_REPLACEMENTS = /\\$([^$]+)\\$/g;",
"lineNumber": 35,
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "jQuery-$(",
"path": "ts/components/Intl.tsx",
"line": " const FIND_REPLACEMENTS = /\\$([^$]+)\\$/g;",
"lineNumber": 69,
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "React-createRef",
"path": "ts/components/LeftPane.js",

View File

@ -110,7 +110,7 @@
"function-name": [
true,
{
"function-regex": "^_?[a-z][\\w\\d]+$",
"function-regex": "^_?[A-Za-z][\\w\\d]+$",
"method-regex": "^_?[a-z][\\w\\d]+$",
"static-method-regex": "^_?[a-z][\\w\\d]+$"
}