Show notifications when a user's profile name changes
This commit is contained in:
parent
2f015863ca
commit
d75eee015f
44 changed files with 749 additions and 194 deletions
|
@ -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 won’t know you’ve 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 won’t know you’ve 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": {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export interface PropsType {
|
|||
|
||||
// For display
|
||||
phoneNumber?: string;
|
||||
isMe: boolean;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
color?: ColorType;
|
||||
isVerified?: boolean;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
26
ts/components/conversation/ProfileChangeNotification.tsx
Normal file
26
ts/components/conversation/ProfileChangeNotification.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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!');
|
||||
}
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
29
ts/util/getStringForProfileChange.ts
Normal file
29
ts/util/getStringForProfileChange.ts
Normal 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!');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]+$"
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue