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"
},
"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"
},
"scrollDown": {
@ -1591,7 +1591,7 @@
"message": "Later"
},
"leftTheGroup": {
"message": "$name$ left the group",
"message": "$name$ left the group.",
"description": "Shown in the conversation history when a single person leaves the group",
"placeholders": {
"name": {
@ -1601,7 +1601,7 @@
}
},
"multipleLeftTheGroup": {
"message": "$name$ left the group",
"message": "$name$ left the group.",
"description": "Shown in the conversation history when multiple people leave the group",
"placeholders": {
"name": {
@ -1611,11 +1611,25 @@
}
},
"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"
},
"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",
"placeholders": {
"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": {
"message": "$name$ joined the group",
"message": "$name$ joined the group.",
"description": "Shown in the conversation history when a single person joins the group",
"placeholders": {
"name": {
@ -1635,7 +1653,7 @@
}
},
"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",
"placeholders": {
"names": {

View File

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

View File

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

View File

@ -422,21 +422,39 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
return;
}
const existingAvatar = conversation.get('avatar');
if (existingAvatar && existingAvatar.path) {
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
}
const loadedAttachment = await Signal.Migrations.loadAttachmentData(
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({
avatar: {
...attachment,
hash: await computeHash(loadedAttachment.data),
hash,
},
});
Signal.Data.updateConversation(conversationId, conversation.attributes);
message.set({
group_update: {
...message.get('group_update'),
avatar: null,
avatarUpdated: true,
},
});
return;
}

View File

@ -371,6 +371,7 @@
isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(),
isArchived: this.model.get('isArchived'),
leftGroup: this.model.get('left'),
expirationSettingName,
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 {
margin-left: 1em;
margin-right: 1em;
margin-top: 5px;
margin-bottom: 5px;
text-align: center;
@ -2267,8 +2269,8 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
}
.module-group-notification__change {
margin-top: 5px;
margin-bottom: 5px;
margin-top: 2px;
margin-bottom: 2px;
}
.module-group-notification__contact {

View File

@ -1,4 +1,4 @@
/* global ConversationController, i18n, Whisper */
/* global ConversationController, i18n, Whisper, textsecure */
'use strict';
@ -10,15 +10,21 @@ const attributes = {
received_at: new Date().getTime(),
};
const source = '+14155555555';
const source = '+1 415-555-5555';
const me = '+14155555556';
const ourUuid = window.getGuid();
describe('MessageCollection', () => {
before(async () => {
await clearDatabase();
ConversationController.reset();
await ConversationController.load();
textsecure.storage.put('number_id', `${me}.2`);
textsecure.storage.put('uuid_id', `${ourUuid}.2`);
});
after(() => {
textsecure.storage.put('number_id', null);
textsecure.storage.put('uuid_id', null);
return clearDatabase();
});
@ -91,46 +97,128 @@ describe('MessageCollection', () => {
'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(
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.'
);
message = messages.add({ group_update: { name: 'blerg' } });
message = messages.add({
type: 'incoming',
source: me,
group_update: { left: 'You' },
});
assert.equal(
message.getDescription(),
"Title is now 'blerg'",
'Returns a single notice if only group_updates.name changes.'
'You left the group.',
'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(
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.'
);
message = messages.add({
type: 'incoming',
source,
group_update: { joined: ['Bob', 'Alice', 'Eve'] },
});
assert.equal(
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.'
);
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' },
});
assert.equal(
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.'
);
message = messages.add({ flags: true });
message = messages.add({ type: 'incoming', source, flags: true });
assert.equal(message.getDescription(), i18n('sessionEnded'));
});
@ -139,7 +227,7 @@ describe('MessageCollection', () => {
let message = messages.add(attributes);
assert.notOk(message.isEndSession());
message = messages.add({ flags: true });
message = messages.add({ type: 'incoming', source, flags: true });
assert.ok(message.isEndSession());
});
});

View File

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

View File

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

View File

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

View File

@ -2,13 +2,13 @@ import React from 'react';
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 {
/** The translation string id */
id: string;
i18n: LocalizerType;
components?: Array<FullJSX>;
components?: Array<FullJSXType>;
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;
if (!components || !components.length || components.length <= index) {

View File

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

View File

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

View File

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

View File

@ -14,32 +14,15 @@ import { storiesOf } from '@storybook/react';
//import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
// @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';
import {
gifObjectUrl,
landscapeGreenObjectUrl,
landscapePurpleObjectUrl,
pngObjectUrl,
} from '../storybook/Fixtures';
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 CONTACT = 'contact' as 'contact';
@ -64,6 +47,7 @@ messageLookup.set('1-guid-guid-guid-guid-guid', {
from: {
phoneNumber: '(202) 555-0020',
isMe: true,
color: 'blue',
avatarPath: gifObjectUrl,
},
to: {
@ -116,6 +100,7 @@ messageLookup.set('4-guid-guid-guid-guid-guid', {
from: {
phoneNumber: '(202) 555-0020',
isMe: true,
color: 'light_green',
avatarPath: gifObjectUrl,
},
to: {
@ -160,6 +145,7 @@ const conversations = [
phoneNumber: '(202) 555-0011',
name: 'Everyone 🌆',
type: GROUP,
color: 'signal-blue' as 'signal-blue',
avatarPath: landscapeGreenObjectUrl,
isMe: false,
lastUpdated: Date.now() - 5 * 60 * 1000,
@ -177,6 +163,7 @@ const conversations = [
id: '+12025550012',
phoneNumber: '(202) 555-0012',
name: 'Everyone Else 🔥',
color: 'pink' as 'pink',
type: DIRECT,
avatarPath: landscapePurpleObjectUrl,
isMe: false,
@ -198,6 +185,7 @@ const contacts = [
id: '+12025550013',
phoneNumber: '(202) 555-0013',
name: 'The one Everyone',
color: 'blue' as 'blue',
type: DIRECT,
avatarPath: gifObjectUrl,
isMe: false,
@ -213,7 +201,7 @@ const contacts = [
phoneNumber: '(202) 555-0014',
name: 'No likey everyone',
type: DIRECT,
color: 'red',
color: 'red' as 'red',
isMe: false,
lastUpdated: Date.now() - 11 * 60 * 1000,
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 { Avatar } from '../Avatar';
import { LocalizerType } from '../../types/Util';
import { ColorType, LocalizerType } from '../../types/Util';
import {
ContextMenu,
ContextMenuTrigger,
@ -16,24 +16,27 @@ interface TimerOption {
value: number;
}
interface Props {
export interface PropsData {
id: string;
name?: string;
phoneNumber: string;
profileName?: string;
color: string;
color?: ColorType;
avatarPath?: string;
isVerified: boolean;
isMe: boolean;
isGroup: boolean;
isArchived: boolean;
isVerified?: boolean;
isMe?: boolean;
isGroup?: boolean;
isArchived?: boolean;
leftGroup?: boolean;
expirationSettingName?: string;
showBackButton: boolean;
timerOptions: Array<TimerOption>;
showBackButton?: boolean;
timerOptions?: Array<TimerOption>;
}
export interface PropsActions {
onSetDisappearingMessages: (seconds: number) => void;
onDeleteMessages: () => void;
onResetSession: () => void;
@ -46,10 +49,14 @@ interface Props {
onArchive: () => void;
onMoveToInbox: () => void;
}
export interface PropsHousekeeping {
i18n: LocalizerType;
}
export type Props = PropsData & PropsActions & PropsHousekeeping;
export class ConversationHeader extends React.Component<Props> {
public showMenuBound: (event: React.MouseEvent<HTMLButtonElement>) => void;
public menuTriggerRef: React.RefObject<any>;
@ -218,6 +225,7 @@ export class ConversationHeader extends React.Component<Props> {
isMe,
isGroup,
isArchived,
leftGroup,
onDeleteMessages,
onResetSession,
onSetDisappearingMessages,
@ -233,18 +241,20 @@ export class ConversationHeader extends React.Component<Props> {
return (
<ContextMenu id={triggerId}>
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
{leftGroup ? null : (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
)}
<MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem>
{isGroup ? (
<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 classNames from 'classnames';
import { compact, flatten } from 'lodash';
import { ContactName } from './ContactName';
import { Emojify } from './Emojify';
import { Intl } from '../Intl';
import { FullJSXType, Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
@ -13,16 +11,17 @@ interface Contact {
phoneNumber: string;
profileName?: string;
name?: string;
isMe?: boolean;
}
interface Change {
type: 'add' | 'remove' | 'name' | 'general';
isMe: boolean;
type: 'add' | 'remove' | 'name' | 'avatar' | 'general';
newName?: string;
contacts?: Array<Contact>;
}
export type PropsData = {
from: Contact;
changes: Array<Change>;
};
@ -30,48 +29,82 @@ type PropsHousekeeping = {
i18n: LocalizerType;
};
type Props = PropsData & PropsHousekeeping;
export type Props = PropsData & PropsHousekeeping;
export class GroupNotification extends React.Component<Props> {
public renderChange(change: Change) {
const { isMe, contacts, type, newName } = change;
public renderChange(change: Change, from: Contact) {
const { contacts, type, newName } = change;
const { i18n } = this.props;
const people = compact(
flatten(
(contacts || []).map((contact, index) => {
const element = (
<span
key={`external-${contact.phoneNumber}`}
className="module-group-notification__contact"
>
<ContactName
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
name={contact.name}
/>
</span>
);
const otherPeople = compact(
(contacts || []).map(contact => {
if (contact.isMe) {
return null;
}
return [index > 0 ? ', ' : null, element];
})
return (
<span
key={`external-${contact.phoneNumber}`}
className="module-group-notification__contact"
>
<ContactName
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
name={contact.name}
/>
</span>
);
})
);
const otherPeopleWithCommas: FullJSXType = compact(
flatten(
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
)
);
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
switch (type) {
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':
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
const joinKey =
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup';
if (contacts.length === 1) {
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':
if (isMe) {
if (from && from.isMe) {
return i18n('youLeftTheGroup');
}
@ -82,22 +115,49 @@ export class GroupNotification extends React.Component<Props> {
const leftKey =
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
return <Intl i18n={i18n} id={leftKey} components={[people]} />;
return (
<Intl i18n={i18n} id={leftKey} components={[otherPeopleWithCommas]} />
);
case 'general':
return i18n('updatedTheGroup');
return;
default:
throw missingCaseError(type);
}
}
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 (
<div className="module-group-notification">
{isLeftOnly ? null : (
<>
{fromLabel}
<br />
</>
)}
{(changes || []).map((change, index) => (
<div key={index} className="module-group-notification__change">
{this.renderChange(change)}
{this.renderChange(change, from)}
</div>
))}
</div>

View File

@ -106,7 +106,7 @@ const stories: Array<MessageStory> = [
makeDataProps: () => ({
...baseDataProps,
direction: 'incoming',
authorColor: 'gray',
authorColor: 'grey',
text:
'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 { ContactName } from './ContactName';
import { Message, Props as MessageProps } from './Message';
import { LocalizerType } from '../../types/Util';
import { ColorType, LocalizerType } from '../../types/Util';
interface Contact {
status: string;
@ -13,7 +13,7 @@ interface Contact {
name?: string;
profileName?: string;
avatarPath?: string;
color: string;
color: ColorType;
isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean;

View File

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

View File

@ -90,6 +90,7 @@ export type MessageType = {
}>;
errors?: Array<Error>;
group_update?: any;
// 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
@ -581,6 +582,11 @@ function hasMessageHeightChanged(
return true;
}
const groupUpdateChanged = message.group_update !== previous.group_update;
if (groupUpdateChanged) {
return true;
}
const stickerPendingChanged =
message.sticker &&
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 ColorType =
| 'gray'
| 'blue'
| 'cyan'
| 'red'
| 'deep_orange'
| 'green'
| 'indigo'
| 'brown'
| 'pink'
| 'purple'
| 'red'
| 'indigo'
| 'blue'
| 'teal'
| 'ultramarine';
| 'green'
| 'light_green'
| 'blue_grey'
| 'grey'
| 'ultramarine'
| 'signal-blue';

View File

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