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." "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": { "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", "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": { "placeholders": {
"name": { "name1": {
"content": "$1", "content": "$1",
"example": "Bob" "example": "Bob"
},
"name2": {
"content": "$2",
"example": "Bob"
} }
} }
}, },
@ -360,12 +364,16 @@
"description": "Shown on confirmation dialog when user attempts to send a message" "description": "Shown on confirmation dialog when user attempts to send a message"
}, },
"identityKeyErrorOnSend": { "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", "description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
"placeholders": { "placeholders": {
"name": { "name1": {
"content": "$1", "content": "$1",
"example": "Bob" "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.", "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" "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.", "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" "description": "An error popup when the user has attempted to add an attachment"
}, },
@ -687,7 +695,7 @@
"content": "$1", "content": "$1",
"example": "dog" "example": "dog"
}, },
"searchTerm": { "conversationName": {
"content": "$2", "content": "$2",
"example": "Friends" "example": "Friends"
} }
@ -1378,7 +1386,7 @@
"description": "Brief message shown when trying to message a blocked group" "description": "Brief message shown when trying to message a blocked group"
}, },
"youChangedTheTimer": { "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.", "description": "Message displayed when you change the message expiration timer in a conversation.",
"placeholders": { "placeholders": {
"time": { "time": {
@ -1388,7 +1396,7 @@
} }
}, },
"timerSetOnSync": { "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.", "description": "Message displayed when timer is set on initial link of desktop device.",
"placeholders": { "placeholders": {
"time": { "time": {
@ -1398,7 +1406,7 @@
} }
}, },
"theyChangedTheTimer": { "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.", "description": "Message displayed when someone else changes the message expiration timer in a conversation.",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1516,7 +1524,7 @@
"description": "Displayed in the left pane when the timer is turned off" "description": "Displayed in the left pane when the timer is turned off"
}, },
"disabledDisappearingMessages": { "disabledDisappearingMessages": {
"message": "$name$ disabled disappearing messages", "message": "$name$ disabled disappearing messages.",
"description": "Displayed in the conversation list when the timer is turned off", "description": "Displayed in the conversation list when the timer is turned off",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1526,7 +1534,7 @@
} }
}, },
"youDisabledDisappearingMessages": { "youDisabledDisappearingMessages": {
"message": "You disabled disappearing messages", "message": "You disabled disappearing messages.",
"description": "Displayed in the conversation list when the timer is turned off" "description": "Displayed in the conversation list when the timer is turned off"
}, },
"timerSetTo": { "timerSetTo": {
@ -1555,6 +1563,38 @@
"message": "Enable incoming calls", "message": "Enable incoming calls",
"description": "Description for incoming calls setting" "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": { "safetyNumberChanged": {
"message": "Safety Number has changed", "message": "Safety Number has changed",
"description": "A notification shown in the conversation when a contact reinstalls" "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" "description": "Label on button included with safety number change notification in the conversation"
}, },
"yourSafetyNumberWith": { "yourSafetyNumberWith": {
"message": "Your safety number with $name$:", "message": "Your safety number with $name1$:",
"description": "Heading for safety number view", "description": "Heading for safety number view",
"placeholders": { "placeholders": {
"name": { "name1": {
"content": "$1", "content": "$1",
"example": "John" "example": "John"
} }
@ -2353,27 +2393,27 @@
"description": "Shown in reaction viewer as the title for the 'all' category" "description": "Shown in reaction viewer as the title for the 'all' category"
}, },
"MessageRequests--message-direct": { "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", "description": "Shown as the message for a message request in a direct message",
"placeholders": { "placeholders": {
"name": { "name": {
"content": "$1", "content": "$1",
"example": "Cayce Pollard" "example": "Cayce"
} }
} }
}, },
"MessageRequests--message-direct-blocked": { "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", "description": "Shown as the message for a message request in a direct message with a blocked account",
"placeholders": { "placeholders": {
"name": { "name": {
"content": "$1", "content": "$1",
"example": "Cayce Pollard" "example": "Cayce"
} }
} }
}, },
"MessageRequests--message-group": { "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", "description": "Shown as the message for a message request in a group",
"placeholders": { "placeholders": {
"name": { "name": {
@ -2383,7 +2423,7 @@
} }
}, },
"MessageRequests--message-group-blocked": { "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" "description": "Shown as the message for a message request in a blocked group"
}, },
"MessageRequests--block": { "MessageRequests--block": {

View file

@ -479,6 +479,7 @@
typingContact: typingContact ? typingContact.format() : null, typingContact: typingContact ? typingContact.format() : null,
lastUpdated: this.get('timestamp'), lastUpdated: this.get('timestamp'),
name: this.get('name'), name: this.get('name'),
firstName: this.get('profileName'),
profileName: this.getProfileName(), profileName: this.getProfileName(),
timestamp, timestamp,
inboxPosition, inboxPosition,
@ -1081,9 +1082,43 @@
id, id,
}) })
); );
this.trigger('newmessage', model); 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) { async onReadMessage(message, readAt) {
// We mark as read everything older than this message - to clean up old stuff // 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 // still marked unread in the database. If the user generally doesn't read in
@ -2489,8 +2524,28 @@
); );
// encode // encode
const profileFamilyName = family ? stringFromBytes(family) : null;
const profileName = given ? stringFromBytes(given) : 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 // set
this.set({ profileName, profileFamilyName }); this.set({ profileName, profileFamilyName });

View file

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

View file

@ -1,5 +1,7 @@
/* eslint-env node */ /* eslint-env node, browser */
/* global log */
// eslint-disable-next-line no-console
const log = typeof window !== 'undefined' ? window.log : console;
exports.setup = (locale, messages) => { exports.setup = (locale, messages) => {
if (!locale) { if (!locale) {
@ -17,18 +19,57 @@ exports.setup = (locale, messages) => {
); );
return ''; 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; const { message } = entry;
if (Array.isArray(substitutions)) { if (!substitutions) {
return message;
} else if (Array.isArray(substitutions)) {
return substitutions.reduce( return substitutions.reduce(
(result, substitution) => result.replace(/\$.+?\$/, substitution), (result, substitution) => result.replace(/\$.+?\$/, substitution),
message 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; getMessage.getLocale = () => locale;

View file

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

View file

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

View file

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

View file

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

View file

@ -2,9 +2,13 @@ import * as React from 'react';
export type I18nFn = ( export type I18nFn = (
key: string, key: string,
substitutions?: Array<string | number> substitutions?: Array<string | number> | ReplacementValuesType
) => string; ) => string;
export type ReplacementValuesType = {
[key: string]: string | number;
};
const I18nContext = React.createContext<I18nFn>(() => 'NO LOCALE LOADED'); const I18nContext = React.createContext<I18nFn>(() => 'NO LOCALE LOADED');
export type I18nProps = { export type I18nProps = {
@ -14,11 +18,55 @@ export type I18nProps = {
export const I18n = ({ messages, children }: I18nProps) => { export const I18n = ({ messages, children }: I18nProps) => {
const getMessage = React.useCallback<I18nFn>( const getMessage = React.useCallback<I18nFn>(
(key, substitutions = []) => (key, substitutions) => {
substitutions.reduce<string>( if (Array.isArray(substitutions) && substitutions.length > 1) {
(res, sub) => res.replace(/\$.+?\$/, sub), throw new Error(
messages[key].message '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] [messages]
); );

View file

@ -8795,6 +8795,45 @@ button.module-image__border-overlay:focus {
padding-right: 0px; 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 */ /* Third-party module: react-tooltip-lite */
.react-tooltip-lite { .react-tooltip-lite {

View file

@ -6,20 +6,25 @@ describe('i18n', () => {
assert.strictEqual(i18n('random'), ''); assert.strictEqual(i18n('random'), '');
}); });
it('returns message for given string', () => { 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', () => { it('returns message with single substitution', () => {
const actual = i18n('cannotUpdateDetail', 'https://signal.org/download'); const actual = i18n('cannotUpdateDetail', [
'https://signal.org/download',
]);
assert.equal( assert.equal(
actual, 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.' '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', () => { it('returns message with multiple substitutions', () => {
const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']); const actual = i18n('theyChangedTheTimer', {
name: 'Someone',
time: '5 minutes',
});
assert.equal( assert.equal(
actual, 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 React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isNumber } from 'lodash';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { MessageBody } from './conversation/MessageBody'; import { MessageBody } from './conversation/MessageBody';
@ -19,10 +20,10 @@ export type PropsData = {
name?: string; name?: string;
type: 'group' | 'direct'; type: 'group' | 'direct';
avatarPath?: string; avatarPath?: string;
isMe: boolean; isMe?: boolean;
lastUpdated: number; lastUpdated: number;
unreadCount: number; unreadCount?: number;
isSelected: boolean; isSelected: boolean;
draftPreview?: string; draftPreview?: string;
@ -80,7 +81,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
public renderUnread() { public renderUnread() {
const { unreadCount } = this.props; const { unreadCount } = this.props;
if (unreadCount > 0) { if (isNumber(unreadCount) && unreadCount > 0) {
return ( return (
<div className="module-conversation-list-item__unread-count"> <div className="module-conversation-list-item__unread-count">
{unreadCount} {unreadCount}
@ -103,12 +104,14 @@ export class ConversationListItem extends React.PureComponent<Props> {
title, title,
} = this.props; } = this.props;
const withUnread = isNumber(unreadCount) && unreadCount > 0;
return ( return (
<div className="module-conversation-list-item__header"> <div className="module-conversation-list-item__header">
<div <div
className={classNames( className={classNames(
'module-conversation-list-item__header__name', 'module-conversation-list-item__header__name',
unreadCount > 0 withUnread
? 'module-conversation-list-item__header__name--with-unread' ? 'module-conversation-list-item__header__name--with-unread'
: null : null
)} )}
@ -128,7 +131,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
<div <div
className={classNames( className={classNames(
'module-conversation-list-item__header__date', 'module-conversation-list-item__header__date',
unreadCount > 0 withUnread
? 'module-conversation-list-item__header__date--has-unread' ? 'module-conversation-list-item__header__date--has-unread'
: null : null
)} )}
@ -137,7 +140,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
timestamp={lastUpdated} timestamp={lastUpdated}
extended={false} extended={false}
module="module-conversation-list-item__header__timestamp" module="module-conversation-list-item__header__timestamp"
withUnread={unreadCount > 0} withUnread={withUnread}
i18n={i18n} i18n={i18n}
/> />
</div> </div>
@ -158,6 +161,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
return null; return null;
} }
const withUnread = isNumber(unreadCount) && unreadCount > 0;
const showingDraft = shouldShowDraft && draftPreview; const showingDraft = shouldShowDraft && draftPreview;
const deletedForEveryone = Boolean( const deletedForEveryone = Boolean(
lastMessage && lastMessage.deletedForEveryone lastMessage && lastMessage.deletedForEveryone
@ -178,7 +182,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
dir="auto" dir="auto"
className={classNames( className={classNames(
'module-conversation-list-item__message__text', 'module-conversation-list-item__message__text',
unreadCount > 0 withUnread
? 'module-conversation-list-item__message__text--has-unread' ? 'module-conversation-list-item__message__text--has-unread'
: null : null
)} )}
@ -219,6 +223,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
public render() { public render() {
const { unreadCount, onClick, id, isSelected, style } = this.props; const { unreadCount, onClick, id, isSelected, style } = this.props;
const withUnread = isNumber(unreadCount) && unreadCount > 0;
return ( return (
<button <button
@ -230,7 +235,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
style={style} style={style}
className={classNames( className={classNames(
'module-conversation-list-item', '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 isSelected ? 'module-conversation-list-item--is-selected' : null
)} )}
data-id={cleanId(id)} data-id={cleanId(id)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { ContactName, Props as ContactNameProps } from './ContactName'; import { ContactName, PropsType as ContactNameProps } from './ContactName';
import { ConfirmationModal } from '../ConfirmationModal'; import { ConfirmationModal } from '../ConfirmationModal';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
@ -25,18 +25,18 @@ export type Props = {
// tslint:disable-next-line: max-func-body-length // tslint:disable-next-line: max-func-body-length
export const MessageRequestActionsConfirmation = ({ export const MessageRequestActionsConfirmation = ({
conversationType,
i18n, i18n,
name, name,
profileName,
phoneNumber,
title,
conversationType,
onBlock, onBlock,
onBlockAndDelete, onBlockAndDelete,
onUnblock,
onDelete,
state,
onChangeState, onChangeState,
onDelete,
onUnblock,
phoneNumber,
profileName,
state,
title,
}: Props) => { }: Props) => {
if (state === MessageRequestState.blocking) { if (state === MessageRequestState.blocking) {
return ( 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, PropsData as GroupNotificationProps,
} from './GroupNotification'; } from './GroupNotification';
import { ResetSessionNotification } from './ResetSessionNotification'; import { ResetSessionNotification } from './ResetSessionNotification';
import {
ProfileChangeNotification,
PropsType as ProfileChangeNotificationPropsType,
} from './ProfileChangeNotification';
type CallHistoryType = { type CallHistoryType = {
type: 'callHistory'; type: 'callHistory';
@ -73,16 +77,22 @@ type ResetSessionNotificationType = {
type: 'resetSessionNotification'; type: 'resetSessionNotification';
data: null; data: null;
}; };
type ProfileChangeNotificationType = {
type: 'profileChange';
data: ProfileChangeNotificationPropsType;
};
export type TimelineItemType = export type TimelineItemType =
| CallHistoryType | CallHistoryType
| GroupNotificationType
| LinkNotificationType | LinkNotificationType
| MessageType | MessageType
| ProfileChangeNotificationType
| ResetSessionNotificationType | ResetSessionNotificationType
| SafetyNumberNotificationType | SafetyNumberNotificationType
| TimerNotificationType | TimerNotificationType
| UnsupportedMessageType | UnsupportedMessageType
| VerificationNotificationType | VerificationNotificationType;
| GroupNotificationType;
type PropsLocalType = { type PropsLocalType = {
conversationId: string; conversationId: string;
@ -159,6 +169,10 @@ export class TimelineItem extends React.PureComponent<PropsType> {
notification = ( notification = (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} /> <ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
); );
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification {...this.props} {...item.data} i18n={i18n} />
);
} else { } else {
throw new Error('TimelineItem: Unknown type!'); throw new Error('TimelineItem: Unknown type!');
} }

View file

@ -43,7 +43,8 @@ export class TimerNotification extends React.Component<Props> {
<Intl <Intl
i18n={i18n} i18n={i18n}
id={changeKey} id={changeKey}
components={[ components={{
name: (
<ContactName <ContactName
key="external-1" key="external-1"
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
@ -51,9 +52,10 @@ export class TimerNotification extends React.Component<Props> {
title={title} title={title}
name={name} name={name}
i18n={i18n} i18n={i18n}
/>, />
timespan, ),
]} time: timespan,
}}
/> />
); );
case 'fromMe': 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> { private async handleIncomingCall(call: Call): Promise<CallSettings | null> {
if (!this.uxActions || !this.localDeviceId) { if (!this.uxActions || !this.localDeviceId) {
window.log.error('Missing required objects, ignoring incoming call.'); window.log.error('Missing required objects, ignoring incoming call.');

View file

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

View file

@ -21,14 +21,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
i18n: getIntl(state), i18n: getIntl(state),
avatarPath: conversation.avatarPath, ...conversation,
color: conversation.color,
conversationType: conversation.type, 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, IncomingMessage,
MessageHistoryUnsyncedMessage, MessageHistoryUnsyncedMessage,
OutgoingMessage, OutgoingMessage,
ProfileChangeNotificationMessage,
VerifiedChangeMessage, VerifiedChangeMessage,
} from '../../types/Message'; } from '../../types/Message';
@ -123,5 +124,29 @@ describe('Conversation', () => {
assert.deepEqual(actual, expected); 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 { type, expirationTimerUpdate, deletedForEveryone } = lastMessage;
const isMessageHistoryUnsynced = type === 'message-history-unsynced'; const isMessageHistoryUnsynced = type === 'message-history-unsynced';
const isProfileChangedMessage = type === 'profile-change';
const isVerifiedChangeMessage = type === 'verified-change'; const isVerifiedChangeMessage = type === 'verified-change';
const isExpireTimerUpdateFromSync = Boolean( const isExpireTimerUpdateFromSync = Boolean(
expirationTimerUpdate && expirationTimerUpdate.fromSync expirationTimerUpdate && expirationTimerUpdate.fromSync
@ -35,6 +36,7 @@ export const createLastMessageUpdate = ({
const shouldUpdateTimestamp = Boolean( const shouldUpdateTimestamp = Boolean(
!isMessageHistoryUnsynced && !isMessageHistoryUnsynced &&
!isProfileChangedMessage &&
!isVerifiedChangeMessage && !isVerifiedChangeMessage &&
!isExpireTimerUpdateFromSync !isExpireTimerUpdateFromSync
); );

View file

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

View file

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

View file

@ -3,7 +3,14 @@ export type RenderTextCallbackType = (options: {
key: number; key: number;
}) => JSX.Element | string; }) => 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 = export type ColorType =
| 'red' | 'red'

View file

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

View file

@ -367,7 +367,10 @@ async function showFallbackReadOnlyDialog(
type: 'warning', type: 'warning',
buttons: [locale.messages.ok.message], buttons: [locale.messages.ok.message],
title: locale.messages.cannotUpdate.message, title: locale.messages.cannotUpdate.message,
message: locale.i18n('readOnlyVolume', ['Signal.app', '/Applications']), message: locale.i18n('readOnlyVolume', {
app: 'Signal.app',
folder: '/Applications',
}),
}; };
showingReadOnlyDialog = true; 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, generateSecurityNumber,
getPlaceholder as getSafetyNumberPlaceholder, getPlaceholder as getSafetyNumberPlaceholder,
} from './safetyNumber'; } from './safetyNumber';
import { getStringForProfileChange } from './getStringForProfileChange';
import { hasExpired } from './hasExpired'; import { hasExpired } from './hasExpired';
import { isFileDangerous } from './isFileDangerous'; import { isFileDangerous } from './isFileDangerous';
import { makeLookup } from './makeLookup'; import { makeLookup } from './makeLookup';
@ -31,6 +32,7 @@ export {
eraseAllStorageServiceState, eraseAllStorageServiceState,
generateSecurityNumber, generateSecurityNumber,
getSafetyNumberPlaceholder, getSafetyNumberPlaceholder,
getStringForProfileChange,
GoogleChrome, GoogleChrome,
hasExpired, hasExpired,
isFileDangerous, isFileDangerous,

View file

@ -207,7 +207,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/conversations.js", "path": "js/models/conversations.js",
"line": " await wrap(", "line": " await wrap(",
"lineNumber": 664, "lineNumber": 665,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-06-09T20:26:46.515Z" "updated": "2020-06-09T20:26:46.515Z"
}, },
@ -243,6 +243,14 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2019-10-31T17:28:08.684Z" "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(", "rule": "jQuery-load(",
"path": "js/modules/stickers.js", "path": "js/modules/stickers.js",
@ -11416,6 +11424,14 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-04-13T23:38:26.065Z" "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", "rule": "DOM-innerHTML",
"path": "ts/backbone/views/Lightbox.js", "path": "ts/backbone/views/Lightbox.js",
@ -11506,6 +11522,22 @@
"updated": "2020-06-03T19:23:21.195Z", "updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "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", "rule": "React-createRef",
"path": "ts/components/LeftPane.js", "path": "ts/components/LeftPane.js",

View file

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