Include sender in group update notifications

This commit is contained in:
Scott Nonnenberg 2020-03-26 14:47:35 -07:00
parent d88c21e5b6
commit 71436d18e2
28 changed files with 1016 additions and 472 deletions

View file

@ -268,7 +268,7 @@
"description": "Used as a label on a button allowing user to see more information" "description": "Used as a label on a button allowing user to see more information"
}, },
"youLeftTheGroup": { "youLeftTheGroup": {
"message": "You left the group", "message": "You left the group.",
"description": "Displayed when a user can't send a message because they have left the group" "description": "Displayed when a user can't send a message because they have left the group"
}, },
"scrollDown": { "scrollDown": {
@ -1591,7 +1591,7 @@
"message": "Later" "message": "Later"
}, },
"leftTheGroup": { "leftTheGroup": {
"message": "$name$ left the group", "message": "$name$ left the group.",
"description": "Shown in the conversation history when a single person leaves the group", "description": "Shown in the conversation history when a single person leaves the group",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1601,7 +1601,7 @@
} }
}, },
"multipleLeftTheGroup": { "multipleLeftTheGroup": {
"message": "$name$ left the group", "message": "$name$ left the group.",
"description": "Shown in the conversation history when multiple people leave the group", "description": "Shown in the conversation history when multiple people leave the group",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1611,11 +1611,25 @@
} }
}, },
"updatedTheGroup": { "updatedTheGroup": {
"message": "Group updated", "message": "$name$ updated the group.",
"description": "Shown in the conversation history when someone updates the group",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"youUpdatedTheGroup": {
"message": "You updated the group.",
"description": "Shown in the conversation history when you update a group"
},
"updatedGroupAvatar": {
"message": "Group avatar was updated.",
"description": "Shown in the conversation history when someone updates the group" "description": "Shown in the conversation history when someone updates the group"
}, },
"titleIsNow": { "titleIsNow": {
"message": "Title is now '$name$'", "message": "Group name is now '$name$'.",
"description": "Shown in the conversation history when someone changes the title of the group", "description": "Shown in the conversation history when someone changes the title of the group",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1624,8 +1638,12 @@
} }
} }
}, },
"youJoinedTheGroup": {
"message": "You joined the group.",
"description": "Shown in the conversation history when you are added to a group."
},
"joinedTheGroup": { "joinedTheGroup": {
"message": "$name$ joined the group", "message": "$name$ joined the group.",
"description": "Shown in the conversation history when a single person joins the group", "description": "Shown in the conversation history when a single person joins the group",
"placeholders": { "placeholders": {
"name": { "name": {
@ -1635,7 +1653,7 @@
} }
}, },
"multipleJoinedTheGroup": { "multipleJoinedTheGroup": {
"message": "$names$ joined the group", "message": "$names$ joined the group.",
"description": "Shown in the conversation history when more than one person joins the group", "description": "Shown in the conversation history when more than one person joins the group",
"placeholders": { "placeholders": {
"names": { "names": {

View file

@ -1549,6 +1549,9 @@
) { ) {
let expireTimer = providedExpireTimer; let expireTimer = providedExpireTimer;
let source = providedSource; let source = providedSource;
if (this.get('left')) {
return false;
}
_.defaults(options, { fromSync: false, fromGroupUpdate: false }); _.defaults(options, { fromSync: false, fromGroupUpdate: false });

View file

@ -432,7 +432,12 @@
const groupUpdate = this.get('group_update'); const groupUpdate = this.get('group_update');
const changes = []; const changes = [];
if (!groupUpdate.name && !groupUpdate.left && !groupUpdate.joined) { if (
!groupUpdate.avatarUpdated &&
!groupUpdate.left &&
!groupUpdate.joined &&
!groupUpdate.name
) {
changes.push({ changes.push({
type: 'general', type: 'general',
}); });
@ -474,7 +479,18 @@
}); });
} }
if (groupUpdate.avatarUpdated) {
changes.push({
type: 'avatar',
});
}
const sourceE164 = this.getSource();
const sourceUuid = this.getSourceUuid();
const from = this.findAndFormatContact(sourceE164 || sourceUuid);
return { return {
from,
changes, changes,
}; };
}, },
@ -834,34 +850,72 @@
return i18n('mediaMessage'); return i18n('mediaMessage');
} }
if (this.isGroupUpdate()) { if (this.isGroupUpdate()) {
const groupUpdate = this.get('group_update'); const groupUpdate = this.get('group_update');
const fromContact = this.getContact();
const messages = [];
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));
} }
const messages = []; if (!fromContact) {
if (!groupUpdate.name && !groupUpdate.joined) { return '';
messages.push(i18n('updatedTheGroup'));
} }
if (groupUpdate.name) {
messages.push(i18n('titleIsNow', groupUpdate.name)); if (fromContact.isMe()) {
} messages.push(i18n('youUpdatedTheGroup'));
if (groupUpdate.joined && groupUpdate.joined.length) {
const names = _.map(
groupUpdate.joined,
this.getNameForNumber.bind(this)
);
if (names.length > 1) {
messages.push(i18n('multipleJoinedTheGroup', names.join(', ')));
} else { } else {
messages.push(i18n('joinedTheGroup', names[0])); messages.push(i18n('updatedTheGroup', fromContact.getDisplayName()));
}
if (groupUpdate.joined && groupUpdate.joined.length) {
const joinedContacts = _.map(groupUpdate.joined, item =>
ConversationController.getOrCreate(item, 'private')
);
const joinedWithoutMe = joinedContacts.filter(
contact => !contact.isMe()
);
if (joinedContacts.length > 1) {
messages.push(
i18n(
'multipleJoinedTheGroup',
_.map(joinedWithoutMe, contact =>
contact.getDisplayName()
).join(', ')
)
);
if (joinedWithoutMe.length < joinedContacts.length) {
messages.push(i18n('youJoinedTheGroup'));
}
} else {
const joinedContact = ConversationController.getOrCreate(
groupUpdate.joined[0],
'private'
);
if (joinedContact.isMe()) {
messages.push(i18n('youJoinedTheGroup'));
} else {
messages.push(
i18n('joinedTheGroup', joinedContacts[0].getDisplayName())
);
}
} }
} }
return messages.join(', '); if (groupUpdate.name) {
messages.push(i18n('titleIsNow', groupUpdate.name));
}
if (groupUpdate.avatarUpdated) {
messages.push(i18n('updatedGroupAvatar'));
}
return messages.join(' ');
} }
if (this.isEndSession()) { if (this.isEndSession()) {
return i18n('sessionEnded'); return i18n('sessionEnded');
@ -2165,10 +2219,13 @@
members: _.union(members, conversation.get('members')), members: _.union(members, conversation.get('members')),
}; };
groupUpdate = groupUpdate = {};
conversation.changedAttributes( if (dataMessage.group.name !== conversation.get('name')) {
_.pick(dataMessage.group, 'name', 'avatar') groupUpdate.name = dataMessage.group.name;
) || {}; }
// Note: used and later cleared by background attachment downloader
groupUpdate.avatar = dataMessage.group.avatar;
const difference = _.difference( const difference = _.difference(
members, members,

View file

@ -422,21 +422,39 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
return; return;
} }
const existingAvatar = conversation.get('avatar');
if (existingAvatar && existingAvatar.path) {
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
}
const loadedAttachment = await Signal.Migrations.loadAttachmentData( const loadedAttachment = await Signal.Migrations.loadAttachmentData(
attachment attachment
); );
const hash = await computeHash(loadedAttachment.data);
const existingAvatar = conversation.get('avatar');
if (existingAvatar) {
if (existingAvatar.hash === hash) {
logger.info(
'_addAttachmentToMessage: Group avatar hash matched; not replacing group avatar'
);
return;
}
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
}
conversation.set({ conversation.set({
avatar: { avatar: {
...attachment, ...attachment,
hash: await computeHash(loadedAttachment.data), hash,
}, },
}); });
Signal.Data.updateConversation(conversationId, conversation.attributes); Signal.Data.updateConversation(conversationId, conversation.attributes);
message.set({
group_update: {
...message.get('group_update'),
avatar: null,
avatarUpdated: true,
},
});
return; return;
} }

View file

@ -371,6 +371,7 @@
isMe: this.model.isMe(), isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(), isGroup: !this.model.isPrivate(),
isArchived: this.model.get('isArchived'), isArchived: this.model.get('isArchived'),
leftGroup: this.model.get('left'),
expirationSettingName, expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length), showBackButton: Boolean(this.panels && this.panels.length),

View file

@ -2255,6 +2255,8 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
.module-group-notification { .module-group-notification {
margin-left: 1em; margin-left: 1em;
margin-right: 1em; margin-right: 1em;
margin-top: 5px;
margin-bottom: 5px;
text-align: center; text-align: center;
@ -2267,8 +2269,8 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
} }
.module-group-notification__change { .module-group-notification__change {
margin-top: 5px; margin-top: 2px;
margin-bottom: 5px; margin-bottom: 2px;
} }
.module-group-notification__contact { .module-group-notification__contact {

View file

@ -1,4 +1,4 @@
/* global ConversationController, i18n, Whisper */ /* global ConversationController, i18n, Whisper, textsecure */
'use strict'; 'use strict';
@ -10,15 +10,21 @@ const attributes = {
received_at: new Date().getTime(), received_at: new Date().getTime(),
}; };
const source = '+14155555555'; const source = '+1 415-555-5555';
const me = '+14155555556';
const ourUuid = window.getGuid();
describe('MessageCollection', () => { describe('MessageCollection', () => {
before(async () => { before(async () => {
await clearDatabase(); await clearDatabase();
ConversationController.reset(); ConversationController.reset();
await ConversationController.load(); await ConversationController.load();
textsecure.storage.put('number_id', `${me}.2`);
textsecure.storage.put('uuid_id', `${ourUuid}.2`);
}); });
after(() => { after(() => {
textsecure.storage.put('number_id', null);
textsecure.storage.put('uuid_id', null);
return clearDatabase(); return clearDatabase();
}); });
@ -91,46 +97,128 @@ describe('MessageCollection', () => {
'If no group updates or end session flags, return message body.' 'If no group updates or end session flags, return message body.'
); );
message = messages.add({ group_update: { left: 'Alice' } }); message = messages.add({
group_update: {},
source: 'Alice',
type: 'incoming',
});
assert.equal( assert.equal(
message.getDescription(), message.getDescription(),
'Alice left the group', 'Alice updated the group.',
'Empty group updates - generic message.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { left: 'Alice' },
});
assert.equal(
message.getDescription(),
'Alice left the group.',
'Notes one person leaving the group.' 'Notes one person leaving the group.'
); );
message = messages.add({ group_update: { name: 'blerg' } }); message = messages.add({
type: 'incoming',
source: me,
group_update: { left: 'You' },
});
assert.equal( assert.equal(
message.getDescription(), message.getDescription(),
"Title is now 'blerg'", 'You left the group.',
'Returns a single notice if only group_updates.name changes.' 'Notes that you left the group.'
); );
message = messages.add({ group_update: { joined: ['Bob'] } }); message = messages.add({
type: 'incoming',
source,
group_update: { name: 'blerg' },
});
assert.equal( assert.equal(
message.getDescription(), message.getDescription(),
'Bob joined the group', "+1 415-555-5555 updated the group. Group name is now 'blerg'.",
'Returns sender and name change.'
);
message = messages.add({
type: 'incoming',
source: me,
group_update: { name: 'blerg' },
});
assert.equal(
message.getDescription(),
"You updated the group. Group name is now 'blerg'.",
'Includes "you" as sender along with group name change.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { avatarUpdated: true },
});
assert.equal(
message.getDescription(),
'+1 415-555-5555 updated the group. Group avatar was updated.',
'Includes sender and avatar update.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { joined: [me] },
});
assert.equal(
message.getDescription(),
'+1 415-555-5555 updated the group. You joined the group.',
'Includes both sender and person added with join.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { joined: ['Bob'] },
});
assert.equal(
message.getDescription(),
'+1 415-555-5555 updated the group. Bob joined the group.',
'Returns a single notice if only group_updates.joined changes.' 'Returns a single notice if only group_updates.joined changes.'
); );
message = messages.add({ message = messages.add({
type: 'incoming',
source,
group_update: { joined: ['Bob', 'Alice', 'Eve'] }, group_update: { joined: ['Bob', 'Alice', 'Eve'] },
}); });
assert.equal( assert.equal(
message.getDescription(), message.getDescription(),
'Bob, Alice, Eve joined the group', '+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group.',
'Notes when >1 person joins the group.' 'Notes when >1 person joins the group.'
); );
message = messages.add({ message = messages.add({
type: 'incoming',
source,
group_update: { joined: ['Bob', me, 'Alice', 'Eve'] },
});
assert.equal(
message.getDescription(),
'+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group. You joined the group.',
'Splits "You" out when multiple people are added along with you.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { joined: ['Bob'], name: 'blerg' }, group_update: { joined: ['Bob'], name: 'blerg' },
}); });
assert.equal( assert.equal(
message.getDescription(), message.getDescription(),
"Title is now 'blerg', Bob joined the group", "+1 415-555-5555 updated the group. Bob joined the group. Group name is now 'blerg'.",
'Notes when there are multiple changes to group_updates properties.' 'Notes when there are multiple changes to group_updates properties.'
); );
message = messages.add({ flags: true }); message = messages.add({ type: 'incoming', source, flags: true });
assert.equal(message.getDescription(), i18n('sessionEnded')); assert.equal(message.getDescription(), i18n('sessionEnded'));
}); });
@ -139,7 +227,7 @@ describe('MessageCollection', () => {
let message = messages.add(attributes); let message = messages.add(attributes);
assert.notOk(message.isEndSession()); assert.notOk(message.isEndSession());
message = messages.add({ flags: true }); message = messages.add({ type: 'incoming', source, flags: true });
assert.ok(message.isEndSession()); assert.ok(message.isEndSession());
}); });
}); });

View file

@ -2,11 +2,12 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { getInitials } from '../util/getInitials'; import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util'; import { ColorType, LocalizerType } from '../types/Util';
export interface Props { export interface Props {
avatarPath?: string; avatarPath?: string;
color?: string; color?: ColorType;
conversationType: 'group' | 'direct'; conversationType: 'group' | 'direct';
noteToSelf?: boolean; noteToSelf?: boolean;
name?: string; name?: string;

View file

@ -4,13 +4,13 @@ import classNames from 'classnames';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { Emojify } from './conversation/Emojify'; import { Emojify } from './conversation/Emojify';
import { LocalizerType } from '../types/Util'; import { ColorType, LocalizerType } from '../types/Util';
interface Props { interface Props {
phoneNumber: string; phoneNumber: string;
isMe?: boolean; isMe?: boolean;
name?: string; name?: string;
color: string; color: ColorType;
verified: boolean; verified: boolean;
profileName?: string; profileName?: string;
avatarPath?: string; avatarPath?: string;

View file

@ -8,12 +8,12 @@ import { ContactName } from './conversation/ContactName';
import { TypingAnimation } from './conversation/TypingAnimation'; import { TypingAnimation } from './conversation/TypingAnimation';
import { cleanId } from './_util'; import { cleanId } from './_util';
import { LocalizerType } from '../types/Util'; import { ColorType, LocalizerType } from '../types/Util';
export type PropsData = { export type PropsData = {
id: string; id: string;
phoneNumber: string; phoneNumber: string;
color?: string; color?: ColorType;
profileName?: string; profileName?: string;
name?: string; name?: string;
type: 'group' | 'direct'; type: 'group' | 'direct';

View file

@ -2,13 +2,13 @@ import React from 'react';
import { LocalizerType, RenderTextCallbackType } from '../types/Util'; import { LocalizerType, RenderTextCallbackType } from '../types/Util';
type FullJSX = Array<JSX.Element | string> | JSX.Element | string; export type FullJSXType = Array<JSX.Element | string> | JSX.Element | string;
interface Props { interface Props {
/** The translation string id */ /** The translation string id */
id: string; id: string;
i18n: LocalizerType; i18n: LocalizerType;
components?: Array<FullJSX>; components?: Array<FullJSXType>;
renderText?: RenderTextCallbackType; renderText?: RenderTextCallbackType;
} }
@ -19,7 +19,7 @@ export class Intl extends React.Component<Props> {
), ),
}; };
public getComponent(index: number, key: number): FullJSX | undefined { public getComponent(index: number, key: number): FullJSXType | undefined {
const { id, components } = this.props; const { id, components } = this.props;
if (!components || !components.length || components.length <= index) { if (!components || !components.length || components.length <= index) {

View file

@ -7,7 +7,7 @@ import { createPortal } from 'react-dom';
import { showSettings } from '../shims/Whisper'; import { showSettings } from '../shims/Whisper';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { AvatarPopup } from './AvatarPopup'; import { AvatarPopup } from './AvatarPopup';
import { LocalizerType } from '../types/Util'; import { ColorType, LocalizerType } from '../types/Util';
export interface PropsType { export interface PropsType {
searchTerm: string; searchTerm: string;
@ -25,7 +25,7 @@ export interface PropsType {
phoneNumber: string; phoneNumber: string;
isMe: boolean; isMe: boolean;
name?: string; name?: string;
color: string; color: ColorType;
verified: boolean; verified: boolean;
profileName?: string; profileName?: string;
avatarPath?: string; avatarPath?: string;

View file

@ -6,7 +6,7 @@ import { MessageBodyHighlight } from './MessageBodyHighlight';
import { Timestamp } from './conversation/Timestamp'; import { Timestamp } from './conversation/Timestamp';
import { ContactName } from './conversation/ContactName'; import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util'; import { ColorType, LocalizerType } from '../types/Util';
export type PropsDataType = { export type PropsDataType = {
isSelected?: boolean; isSelected?: boolean;
@ -22,7 +22,7 @@ export type PropsDataType = {
phoneNumber: string; phoneNumber: string;
isMe?: boolean; isMe?: boolean;
name?: string; name?: string;
color?: string; color?: ColorType;
profileName?: string; profileName?: string;
avatarPath?: string; avatarPath?: string;
}; };

View file

@ -50,7 +50,7 @@ export const NetworkStatus = ({
const [isConnecting, setIsConnecting] = React.useState<boolean>(false); const [isConnecting, setIsConnecting] = React.useState<boolean>(false);
React.useEffect(() => { React.useEffect(() => {
let timeout: NodeJS.Timeout; let timeout: any;
if (isConnecting) { if (isConnecting) {
timeout = setTimeout(() => { timeout = setTimeout(() => {

View file

@ -14,32 +14,15 @@ import { storiesOf } from '@storybook/react';
//import { boolean, select } from '@storybook/addon-knobs'; //import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
// @ts-ignore import {
import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif'; gifObjectUrl,
// @ts-ignore landscapeGreenObjectUrl,
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png'; landscapePurpleObjectUrl,
// @ts-ignore pngObjectUrl,
import landscapeGreen from '../../fixtures/1000x50-green.jpeg'; } from '../storybook/Fixtures';
// @ts-ignore
import landscapePurple from '../../fixtures/200x50-purple.png';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
}
// 320x240
const gifObjectUrl = makeObjectUrl(gif, 'image/gif');
// 800×1200
const pngObjectUrl = makeObjectUrl(png, 'image/png');
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
const landscapePurpleObjectUrl = makeObjectUrl(landscapePurple, 'image/png');
const messageLookup: Map<string, MessageSearchResultPropsType> = new Map(); const messageLookup: Map<string, MessageSearchResultPropsType> = new Map();
const CONTACT = 'contact' as 'contact'; const CONTACT = 'contact' as 'contact';
@ -64,6 +47,7 @@ messageLookup.set('1-guid-guid-guid-guid-guid', {
from: { from: {
phoneNumber: '(202) 555-0020', phoneNumber: '(202) 555-0020',
isMe: true, isMe: true,
color: 'blue',
avatarPath: gifObjectUrl, avatarPath: gifObjectUrl,
}, },
to: { to: {
@ -116,6 +100,7 @@ messageLookup.set('4-guid-guid-guid-guid-guid', {
from: { from: {
phoneNumber: '(202) 555-0020', phoneNumber: '(202) 555-0020',
isMe: true, isMe: true,
color: 'light_green',
avatarPath: gifObjectUrl, avatarPath: gifObjectUrl,
}, },
to: { to: {
@ -160,6 +145,7 @@ const conversations = [
phoneNumber: '(202) 555-0011', phoneNumber: '(202) 555-0011',
name: 'Everyone 🌆', name: 'Everyone 🌆',
type: GROUP, type: GROUP,
color: 'signal-blue' as 'signal-blue',
avatarPath: landscapeGreenObjectUrl, avatarPath: landscapeGreenObjectUrl,
isMe: false, isMe: false,
lastUpdated: Date.now() - 5 * 60 * 1000, lastUpdated: Date.now() - 5 * 60 * 1000,
@ -177,6 +163,7 @@ const conversations = [
id: '+12025550012', id: '+12025550012',
phoneNumber: '(202) 555-0012', phoneNumber: '(202) 555-0012',
name: 'Everyone Else 🔥', name: 'Everyone Else 🔥',
color: 'pink' as 'pink',
type: DIRECT, type: DIRECT,
avatarPath: landscapePurpleObjectUrl, avatarPath: landscapePurpleObjectUrl,
isMe: false, isMe: false,
@ -198,6 +185,7 @@ const contacts = [
id: '+12025550013', id: '+12025550013',
phoneNumber: '(202) 555-0013', phoneNumber: '(202) 555-0013',
name: 'The one Everyone', name: 'The one Everyone',
color: 'blue' as 'blue',
type: DIRECT, type: DIRECT,
avatarPath: gifObjectUrl, avatarPath: gifObjectUrl,
isMe: false, isMe: false,
@ -213,7 +201,7 @@ const contacts = [
phoneNumber: '(202) 555-0014', phoneNumber: '(202) 555-0014',
name: 'No likey everyone', name: 'No likey everyone',
type: DIRECT, type: DIRECT,
color: 'red', color: 'red' as 'red',
isMe: false, isMe: false,
lastUpdated: Date.now() - 11 * 60 * 1000, lastUpdated: Date.now() - 11 * 60 * 1000,
unreadCount: 0, unreadCount: 0,

View file

@ -1,156 +0,0 @@
### Name variations, 1:1 conversation
Note the five items in menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
#### With name and profile, verified
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
i18n={util.i18n}
color="red"
isVerified={true}
avatarPath={util.gifObjectUrl}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0001"
id="1"
profileName="🔥Flames🔥"
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
onSearchInConversation={() => console.log('onSearchInConversation')}
/>
</util.ConversationContext>
```
#### With name, not verified, no avatar
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
i18n={util.i18n}
color="blue"
isVerified={false}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0002"
id="2"
/>
</util.ConversationContext>
```
#### Profile, no name
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
i18n={util.i18n}
color="teal"
isVerified={false}
phoneNumber="(202) 555-0003"
id="3"
profileName="🔥Flames🔥"
/>
</util.ConversationContext>
```
#### No name, no profile, no color
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader i18n={util.i18n} phoneNumber="(202) 555-0011" id="11" />
</util.ConversationContext>
```
### With back button
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
showBackButton={true}
color="deep_orange"
i18n={util.i18n}
phoneNumber="(202) 555-0004"
id="4"
/>
</util.ConversationContext>
```
### Disappearing messages set
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
color="indigo"
i18n={util.i18n}
phoneNumber="(202) 555-0005"
id="5"
expirationSettingName="10 seconds"
timerOptions={[
{
name: 'off',
value: 0,
},
{
name: '10 seconds',
value: 10,
},
]}
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
/>
</util.ConversationContext>
```
### In a group
Note that the menu should includes 'Show Members' instead of 'Show Safety Number'
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
i18n={util.i18n}
color="green"
phoneNumber="(202) 555-0006"
id="6"
isGroup={true}
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
/>
</util.ConversationContext>
```
### In chat with yourself
This is the 'Note to self' conversation. Note that the menu should not have a 'Show Safety Number' entry.
```jsx
<util.ConversationContext theme={util.theme}>
<ConversationHeader
color="cyan"
i18n={util.i18n}
phoneNumber="(202) 555-0007"
id="7"
isMe={true}
/>
</util.ConversationContext>
```

View file

@ -0,0 +1,227 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../\_locales/en/messages.json';
import {
ConversationHeader,
Props,
PropsActions,
PropsHousekeeping,
} from './ConversationHeader';
import { gifObjectUrl } from '../../storybook/Fixtures';
const book = storiesOf('Components/Conversation/ConversationHeader', module);
const i18n = setupI18n('en', enMessages);
type ConversationHeaderStory = {
title: string;
description: string;
items: Array<{
title: string;
props: Props;
}>;
};
const actionProps: PropsActions = {
onSetDisappearingMessages: action('onSetDisappearingMessages'),
onDeleteMessages: action('onDeleteMessages'),
onResetSession: action('onResetSession'),
onSearchInConversation: action('onSearchInConversation'),
onShowSafetyNumber: action('onShowSafetyNumber'),
onShowAllMedia: action('onShowAllMedia'),
onShowGroupMembers: action('onShowGroupMembers'),
onGoBack: action('onGoBack'),
onArchive: action('onArchive'),
onMoveToInbox: action('onMoveToInbox'),
};
const housekeepingProps: PropsHousekeeping = {
i18n,
};
const stories: Array<ConversationHeaderStory> = [
{
title: '1:1 conversation',
description:
"Note the five items in menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.",
items: [
{
title: 'With name and profile, verified',
props: {
color: 'red',
isVerified: true,
avatarPath: gifObjectUrl,
name: 'Someone 🔥 Somewhere',
phoneNumber: '(202) 555-0001',
id: '1',
profileName: '🔥Flames🔥',
...actionProps,
...housekeepingProps,
},
},
{
title: 'With name, not verified, no avatar',
props: {
color: 'blue',
isVerified: false,
name: 'Someone 🔥 Somewhere',
phoneNumber: '(202) 555-0002',
id: '2',
...actionProps,
...housekeepingProps,
},
},
{
title: 'Profile, no name',
props: {
color: 'teal',
isVerified: false,
phoneNumber: '(202) 555-0003',
id: '3',
profileName: '🔥Flames🔥',
...actionProps,
...housekeepingProps,
},
},
{
title: 'No name, no profile, no color',
props: {
phoneNumber: '(202) 555-0011',
id: '11',
...actionProps,
...housekeepingProps,
},
},
{
title: 'With back button',
props: {
showBackButton: true,
color: 'deep_orange',
phoneNumber: '(202) 555-0004',
id: '4',
...actionProps,
...housekeepingProps,
},
},
{
title: 'Disappearing messages set',
props: {
color: 'indigo',
phoneNumber: '(202) 555-0005',
id: '5',
expirationSettingName: '10 seconds',
timerOptions: [
{
name: 'off',
value: 0,
},
{
name: '10 seconds',
value: 10,
},
],
...actionProps,
...housekeepingProps,
},
},
],
},
{
title: 'In a group',
description:
"Note that the menu should includes 'Show Members' instead of 'Show Safety Number'",
items: [
{
title: 'Basic',
props: {
color: 'signal-blue',
name: 'Typescript support group',
phoneNumber: '',
id: '1',
isGroup: true,
expirationSettingName: '10 seconds',
timerOptions: [
{
name: 'off',
value: 0,
},
{
name: '10 seconds',
value: 10,
},
],
...actionProps,
...housekeepingProps,
},
},
{
title: 'In a group you left - no disappearing messages',
props: {
color: 'signal-blue',
name: 'Typescript support group',
phoneNumber: '',
id: '2',
isGroup: true,
leftGroup: true,
expirationSettingName: '10 seconds',
timerOptions: [
{
name: 'off',
value: 0,
},
{
name: '10 seconds',
value: 10,
},
],
...actionProps,
...housekeepingProps,
},
},
],
},
{
title: 'Note to Self',
description: 'No safety number entry.',
items: [
{
title: 'In chat with yourself',
props: {
color: 'blue',
phoneNumber: '(202) 555-0007',
id: '7',
isMe: true,
...actionProps,
...housekeepingProps,
},
},
],
},
];
stories.forEach(({ title, description, items }) =>
book.add(
title,
() =>
items.map(({ title: subtitle, props }, i) => {
return (
<div key={i}>
{subtitle ? <h3>{subtitle}</h3> : null}
<ConversationHeader {...props} />
</div>
);
}),
{
docs: description,
}
)
);

View file

@ -3,7 +3,7 @@ import classNames from 'classnames';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import { LocalizerType } from '../../types/Util'; import { ColorType, LocalizerType } from '../../types/Util';
import { import {
ContextMenu, ContextMenu,
ContextMenuTrigger, ContextMenuTrigger,
@ -16,24 +16,27 @@ interface TimerOption {
value: number; value: number;
} }
interface Props { export interface PropsData {
id: string; id: string;
name?: string; name?: string;
phoneNumber: string; phoneNumber: string;
profileName?: string; profileName?: string;
color: string; color?: ColorType;
avatarPath?: string; avatarPath?: string;
isVerified: boolean; isVerified?: boolean;
isMe: boolean; isMe?: boolean;
isGroup: boolean; isGroup?: boolean;
isArchived: boolean; isArchived?: boolean;
leftGroup?: boolean;
expirationSettingName?: string; expirationSettingName?: string;
showBackButton: boolean; showBackButton?: boolean;
timerOptions: Array<TimerOption>; timerOptions?: Array<TimerOption>;
}
export interface PropsActions {
onSetDisappearingMessages: (seconds: number) => void; onSetDisappearingMessages: (seconds: number) => void;
onDeleteMessages: () => void; onDeleteMessages: () => void;
onResetSession: () => void; onResetSession: () => void;
@ -46,10 +49,14 @@ interface Props {
onArchive: () => void; onArchive: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
}
export interface PropsHousekeeping {
i18n: LocalizerType; i18n: LocalizerType;
} }
export type Props = PropsData & PropsActions & PropsHousekeeping;
export class ConversationHeader extends React.Component<Props> { export class ConversationHeader extends React.Component<Props> {
public showMenuBound: (event: React.MouseEvent<HTMLButtonElement>) => void; public showMenuBound: (event: React.MouseEvent<HTMLButtonElement>) => void;
public menuTriggerRef: React.RefObject<any>; public menuTriggerRef: React.RefObject<any>;
@ -218,6 +225,7 @@ export class ConversationHeader extends React.Component<Props> {
isMe, isMe,
isGroup, isGroup,
isArchived, isArchived,
leftGroup,
onDeleteMessages, onDeleteMessages,
onResetSession, onResetSession,
onSetDisappearingMessages, onSetDisappearingMessages,
@ -233,6 +241,7 @@ export class ConversationHeader extends React.Component<Props> {
return ( return (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
{leftGroup ? null : (
<SubMenu title={disappearingTitle}> <SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => ( {(timerOptions || []).map(item => (
<MenuItem <MenuItem
@ -245,6 +254,7 @@ export class ConversationHeader extends React.Component<Props> {
</MenuItem> </MenuItem>
))} ))}
</SubMenu> </SubMenu>
)}
<MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem> <MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem>
{isGroup ? ( {isGroup ? (
<MenuItem onClick={onShowGroupMembers}> <MenuItem onClick={onShowGroupMembers}>

View file

@ -1,165 +0,0 @@
### Three changes, all types
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
{
type: 'name',
newName: 'New Group Name',
},
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Joined group
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Left group
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'remove',
isMe: true,
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Title changed
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'name',
newName: 'New Group Name',
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Generic group update
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'general',
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View file

@ -0,0 +1,353 @@
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 { GroupNotification, Props } from './GroupNotification';
const book = storiesOf('Components/Conversation', module);
const i18n = setupI18n('en', enMessages);
type GroupNotificationStory = [string, Array<Props>];
const stories: Array<GroupNotificationStory> = [
[
'Combo',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
{ type: 'name', newName: 'Fishing Stories' },
{ type: 'avatar' },
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
{ type: 'name', newName: 'Fishing Stories' },
{ type: 'avatar' },
],
i18n,
},
],
],
[
'Joined group',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
isMe: true,
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
isMe: true,
},
],
},
],
i18n,
},
],
],
[
'Left group',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
changes: [
{
type: 'remove',
contacts: [
{
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
],
},
],
i18n,
},
],
],
[
'Title changed',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'name',
newName: 'New Group Name',
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
changes: [
{
type: 'name',
newName: 'New Group Name',
},
],
i18n,
},
],
],
[
'Avatar changed',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'avatar',
newName: 'New Group Name',
},
],
i18n,
},
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
isMe: true,
},
changes: [
{
type: 'avatar',
newName: 'New Group Name',
},
],
i18n,
},
],
],
[
'Generic group update',
[
{
from: {
name: 'Alice',
phoneNumber: '(202) 555-1000',
},
changes: [
{
type: 'general',
},
],
i18n,
},
],
],
];
book.add('GroupNotification', () =>
stories.map(([title, propsArray]) => (
<>
<h3>{title}</h3>
{propsArray.map((props, i) => {
return (
<>
<div key={i} className="module-message-container">
<div className="module-inline-notification-wrapper">
<GroupNotification {...props} />
</div>
</div>
</>
);
})}
</>
))
);

View file

@ -1,10 +1,8 @@
import React from 'react'; import React from 'react';
// import classNames from 'classnames';
import { compact, flatten } from 'lodash'; import { compact, flatten } from 'lodash';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Emojify } from './Emojify'; import { FullJSXType, Intl } from '../Intl';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
@ -13,16 +11,17 @@ interface Contact {
phoneNumber: string; phoneNumber: string;
profileName?: string; profileName?: string;
name?: string; name?: string;
isMe?: boolean;
} }
interface Change { interface Change {
type: 'add' | 'remove' | 'name' | 'general'; type: 'add' | 'remove' | 'name' | 'avatar' | 'general';
isMe: boolean;
newName?: string; newName?: string;
contacts?: Array<Contact>; contacts?: Array<Contact>;
} }
export type PropsData = { export type PropsData = {
from: Contact;
changes: Array<Change>; changes: Array<Change>;
}; };
@ -30,17 +29,20 @@ type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
}; };
type Props = PropsData & PropsHousekeeping; export type Props = PropsData & PropsHousekeeping;
export class GroupNotification extends React.Component<Props> { export class GroupNotification extends React.Component<Props> {
public renderChange(change: Change) { public renderChange(change: Change, from: Contact) {
const { isMe, contacts, type, newName } = change; const { contacts, type, newName } = change;
const { i18n } = this.props; const { i18n } = this.props;
const people = compact( const otherPeople = compact(
flatten( (contacts || []).map(contact => {
(contacts || []).map((contact, index) => { if (contact.isMe) {
const element = ( return null;
}
return (
<span <span
key={`external-${contact.phoneNumber}`} key={`external-${contact.phoneNumber}`}
className="module-group-notification__contact" className="module-group-notification__contact"
@ -52,26 +54,57 @@ export class GroupNotification extends React.Component<Props> {
/> />
</span> </span>
); );
return [index > 0 ? ', ' : null, element];
}) })
);
const otherPeopleWithCommas: FullJSXType = compact(
flatten(
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
) )
); );
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
switch (type) { switch (type) {
case 'name': case 'name':
return <Emojify text={i18n('titleIsNow', [newName || ''])} />; return (
<Intl i18n={i18n} id="titleIsNow" components={[newName || '']} />
);
case 'avatar':
return <Intl i18n={i18n} id="updatedGroupAvatar" />;
case 'add': case 'add':
if (!contacts || !contacts.length) { if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts'); throw new Error('Group update is missing contacts');
} }
const joinKey = if (contacts.length === 1) {
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'; if (contactsIncludesMe) {
return <Intl i18n={i18n} id="youJoinedTheGroup" />;
} else {
return (
<Intl
i18n={i18n}
id="joinedTheGroup"
components={[otherPeopleWithCommas]}
/>
);
}
}
return <Intl i18n={i18n} id={joinKey} components={[people]} />; return (
<>
<Intl
i18n={i18n}
id="multipleJoinedTheGroup"
components={[otherPeopleWithCommas]}
/>
{contactsIncludesMe ? (
<div className="module-group-notification__change">
<Intl i18n={i18n} id="youJoinedTheGroup" />
</div>
) : null}
</>
);
case 'remove': case 'remove':
if (isMe) { if (from && from.isMe) {
return i18n('youLeftTheGroup'); return i18n('youLeftTheGroup');
} }
@ -82,22 +115,49 @@ export class GroupNotification extends React.Component<Props> {
const leftKey = const leftKey =
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
return <Intl i18n={i18n} id={leftKey} components={[people]} />; return (
<Intl i18n={i18n} id={leftKey} components={[otherPeopleWithCommas]} />
);
case 'general': case 'general':
return i18n('updatedTheGroup'); return;
default: default:
throw missingCaseError(type); throw missingCaseError(type);
} }
} }
public render() { public render() {
const { changes } = this.props; const { changes, i18n, from } = this.props;
// Leave messages are always from the person leaving, so we omit the fromLabel if
// the change is a 'leave.'
const isLeftOnly =
changes && changes.length === 1 && changes[0].type === 'remove';
const fromContact = (
<ContactName
phoneNumber={from.phoneNumber}
profileName={from.profileName}
name={from.name}
/>
);
const fromLabel = from.isMe ? (
<Intl i18n={i18n} id="youUpdatedTheGroup" />
) : (
<Intl i18n={i18n} id="updatedTheGroup" components={[fromContact]} />
);
return ( return (
<div className="module-group-notification"> <div className="module-group-notification">
{isLeftOnly ? null : (
<>
{fromLabel}
<br />
</>
)}
{(changes || []).map((change, index) => ( {(changes || []).map((change, index) => (
<div key={index} className="module-group-notification__change"> <div key={index} className="module-group-notification__change">
{this.renderChange(change)} {this.renderChange(change, from)}
</div> </div>
))} ))}
</div> </div>

View file

@ -106,7 +106,7 @@ const stories: Array<MessageStory> = [
makeDataProps: () => ({ makeDataProps: () => ({
...baseDataProps, ...baseDataProps,
direction: 'incoming', direction: 'incoming',
authorColor: 'gray', authorColor: 'grey',
text: text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
}), }),

View file

@ -5,7 +5,7 @@ import moment from 'moment';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Message, Props as MessageProps } from './Message'; import { Message, Props as MessageProps } from './Message';
import { LocalizerType } from '../../types/Util'; import { ColorType, LocalizerType } from '../../types/Util';
interface Contact { interface Contact {
status: string; status: string;
@ -13,7 +13,7 @@ interface Contact {
name?: string; name?: string;
profileName?: string; profileName?: string;
avatarPath?: string; avatarPath?: string;
color: string; color: ColorType;
isOutgoingKeyError: boolean; isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean; isUnidentifiedDelivery: boolean;

View file

@ -4,11 +4,11 @@ import classNames from 'classnames';
import { TypingAnimation } from './TypingAnimation'; import { TypingAnimation } from './TypingAnimation';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import { LocalizerType } from '../../types/Util'; import { ColorType, LocalizerType } from '../../types/Util';
interface Props { interface Props {
avatarPath?: string; avatarPath?: string;
color: string; color: ColorType;
name?: string; name?: string;
phoneNumber: string; phoneNumber: string;
profileName?: string; profileName?: string;

View file

@ -90,6 +90,7 @@ export type MessageType = {
}>; }>;
errors?: Array<Error>; errors?: Array<Error>;
group_update?: any;
// No need to go beyond this; unused at this stage, since this goes into // No need to go beyond this; unused at this stage, since this goes into
// a reducer still in plain JavaScript and comes out well-formed // a reducer still in plain JavaScript and comes out well-formed
@ -581,6 +582,11 @@ function hasMessageHeightChanged(
return true; return true;
} }
const groupUpdateChanged = message.group_update !== previous.group_update;
if (groupUpdateChanged) {
return true;
}
const stickerPendingChanged = const stickerPendingChanged =
message.sticker && message.sticker &&
message.sticker.data && message.sticker.data &&

30
ts/storybook/Fixtures.ts Normal file
View file

@ -0,0 +1,30 @@
// @ts-ignore
import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif';
// @ts-ignore
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png';
// @ts-ignore
import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
// @ts-ignore
import landscapePurple from '../../fixtures/200x50-purple.png';
function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
}
// 320x240
export const gifObjectUrl = makeObjectUrl(gif, 'image/gif');
// 800×1200
export const pngObjectUrl = makeObjectUrl(png, 'image/png');
export const landscapeGreenObjectUrl = makeObjectUrl(
landscapeGreen,
'image/jpeg'
);
export const landscapePurpleObjectUrl = makeObjectUrl(
landscapePurple,
'image/png'
);

View file

@ -6,14 +6,17 @@ export type RenderTextCallbackType = (options: {
export type LocalizerType = (key: string, values?: Array<string>) => string; export type LocalizerType = (key: string, values?: Array<string>) => string;
export type ColorType = export type ColorType =
| 'gray' | 'red'
| 'blue'
| 'cyan'
| 'deep_orange' | 'deep_orange'
| 'green' | 'brown'
| 'indigo'
| 'pink' | 'pink'
| 'purple' | 'purple'
| 'red' | 'indigo'
| 'blue'
| 'teal' | 'teal'
| 'ultramarine'; | 'green'
| 'light_green'
| 'blue_grey'
| 'grey'
| 'ultramarine'
| 'signal-blue';

View file

@ -11508,7 +11508,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx", "path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();", "line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 60, "lineNumber": 67,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used to reference popup menu" "reasonDetail": "Used to reference popup menu"