diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 86826a473..030644616 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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": { diff --git a/js/models/conversations.js b/js/models/conversations.js index 1d9dd8012..e1473ea46 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1549,6 +1549,9 @@ ) { let expireTimer = providedExpireTimer; let source = providedSource; + if (this.get('left')) { + return false; + } _.defaults(options, { fromSync: false, fromGroupUpdate: false }); diff --git a/js/models/messages.js b/js/models/messages.js index 410823de4..544e93d67 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -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, diff --git a/js/modules/attachment_downloads.js b/js/modules/attachment_downloads.js index 6ff1d4aa6..83a7d6438 100644 --- a/js/modules/attachment_downloads.js +++ b/js/modules/attachment_downloads.js @@ -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; } diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 407a87352..b3ee6aca3 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -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), diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 67d411811..86d1a08b2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/test/models/messages_test.js b/test/models/messages_test.js index 70efb2708..2931c97db 100644 --- a/test/models/messages_test.js +++ b/test/models/messages_test.js @@ -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()); }); }); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index d678b4f7b..cde997a61 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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; diff --git a/ts/components/ContactListItem.tsx b/ts/components/ContactListItem.tsx index 0290ef80e..adb66bd9b 100644 --- a/ts/components/ContactListItem.tsx +++ b/ts/components/ContactListItem.tsx @@ -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; diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 6e578aa65..d124456a6 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -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'; diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index b2b4ca425..887a6dff1 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { LocalizerType, RenderTextCallbackType } from '../types/Util'; -type FullJSX = Array | JSX.Element | string; +export type FullJSXType = Array | JSX.Element | string; interface Props { /** The translation string id */ id: string; i18n: LocalizerType; - components?: Array; + components?: Array; renderText?: RenderTextCallbackType; } @@ -19,7 +19,7 @@ export class Intl extends React.Component { ), }; - 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) { diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index fdba6b4bd..c0b9f28a4 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -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; diff --git a/ts/components/MessageSearchResult.tsx b/ts/components/MessageSearchResult.tsx index 360bf2e08..f1c994185 100644 --- a/ts/components/MessageSearchResult.tsx +++ b/ts/components/MessageSearchResult.tsx @@ -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; }; diff --git a/ts/components/NetworkStatus.tsx b/ts/components/NetworkStatus.tsx index 56a154ed7..965d67d17 100644 --- a/ts/components/NetworkStatus.tsx +++ b/ts/components/NetworkStatus.tsx @@ -50,7 +50,7 @@ export const NetworkStatus = ({ const [isConnecting, setIsConnecting] = React.useState(false); React.useEffect(() => { - let timeout: NodeJS.Timeout; + let timeout: any; if (isConnecting) { timeout = setTimeout(() => { diff --git a/ts/components/SearchResults.stories.tsx b/ts/components/SearchResults.stories.tsx index a86a9e0c9..ff8215c33 100644 --- a/ts/components/SearchResults.stories.tsx +++ b/ts/components/SearchResults.stories.tsx @@ -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 = 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, diff --git a/ts/components/conversation/ConversationHeader.md b/ts/components/conversation/ConversationHeader.md deleted file mode 100644 index fcb6fba4e..000000000 --- a/ts/components/conversation/ConversationHeader.md +++ /dev/null @@ -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 - - - 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')} - /> - -``` - -#### With name, not verified, no avatar - -```jsx - - - -``` - -#### Profile, no name - -```jsx - - - -``` - -#### No name, no profile, no color - -```jsx - - - -``` - -### With back button - -```jsx - - - -``` - -### Disappearing messages set - -```jsx - - - 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')} - /> - -``` - -### In a group - -Note that the menu should includes 'Show Members' instead of 'Show Safety Number' - -```jsx - - - 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')} - /> - -``` - -### In chat with yourself - -This is the 'Note to self' conversation. Note that the menu should not have a 'Show Safety Number' entry. - -```jsx - - - -``` diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx new file mode 100644 index 000000000..6ec722358 --- /dev/null +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -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 = [ + { + 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 ( +
+ {subtitle ?

{subtitle}

: null} + +
+ ); + }), + { + docs: description, + } + ) +); diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index e443808d5..3611a7a7f 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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; + showBackButton?: boolean; + timerOptions?: Array; +} +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 { public showMenuBound: (event: React.MouseEvent) => void; public menuTriggerRef: React.RefObject; @@ -218,6 +225,7 @@ export class ConversationHeader extends React.Component { isMe, isGroup, isArchived, + leftGroup, onDeleteMessages, onResetSession, onSetDisappearingMessages, @@ -233,18 +241,20 @@ export class ConversationHeader extends React.Component { return ( - - {(timerOptions || []).map(item => ( - { - onSetDisappearingMessages(item.value); - }} - > - {item.name} - - ))} - + {leftGroup ? null : ( + + {(timerOptions || []).map(item => ( + { + onSetDisappearingMessages(item.value); + }} + > + {item.name} + + ))} + + )} {i18n('viewAllMedia')} {isGroup ? ( diff --git a/ts/components/conversation/GroupNotification.md b/ts/components/conversation/GroupNotification.md deleted file mode 100644 index 2303766ae..000000000 --- a/ts/components/conversation/GroupNotification.md +++ /dev/null @@ -1,165 +0,0 @@ -### Three changes, all types - -```js - - - -``` - -### Joined group - -```js - - - - -``` - -### Left group - -```js - - - - - -``` - -### Title changed - -```js - - - -``` - -### Generic group update - -```js - - - -``` diff --git a/ts/components/conversation/GroupNotification.stories.tsx b/ts/components/conversation/GroupNotification.stories.tsx new file mode 100644 index 000000000..edac3ba0d --- /dev/null +++ b/ts/components/conversation/GroupNotification.stories.tsx @@ -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]; + +const stories: Array = [ + [ + '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]) => ( + <> +

{title}

+ {propsArray.map((props, i) => { + return ( + <> +
+
+ +
+
+ + ); + })} + + )) +); diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx index fac6e7f59..31e588076 100644 --- a/ts/components/conversation/GroupNotification.tsx +++ b/ts/components/conversation/GroupNotification.tsx @@ -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; } export type PropsData = { + from: Contact; changes: Array; }; @@ -30,48 +29,82 @@ type PropsHousekeeping = { i18n: LocalizerType; }; -type Props = PropsData & PropsHousekeeping; +export type Props = PropsData & PropsHousekeeping; export class GroupNotification extends React.Component { - 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 = ( - - - - ); + const otherPeople = compact( + (contacts || []).map(contact => { + if (contact.isMe) { + return null; + } - return [index > 0 ? ', ' : null, element]; - }) + return ( + + + + ); + }) + ); + const otherPeopleWithCommas: FullJSXType = compact( + flatten( + otherPeople.map((person, index) => [index > 0 ? ', ' : null, person]) ) ); + const contactsIncludesMe = (contacts || []).length !== otherPeople.length; switch (type) { case 'name': - return ; + return ( + + ); + case 'avatar': + return ; 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 ; + } else { + return ( + + ); + } + } - return ; + return ( + <> + + {contactsIncludesMe ? ( +
+ +
+ ) : null} + + ); case 'remove': - if (isMe) { + if (from && from.isMe) { return i18n('youLeftTheGroup'); } @@ -82,22 +115,49 @@ export class GroupNotification extends React.Component { const leftKey = contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; - return ; + return ( + + ); 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 = ( + + ); + + const fromLabel = from.isMe ? ( + + ) : ( + + ); return (
+ {isLeftOnly ? null : ( + <> + {fromLabel} +
+ + )} {(changes || []).map((change, index) => (
- {this.renderChange(change)} + {this.renderChange(change, from)}
))}
diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 676aeb14d..b8e4ae84b 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -106,7 +106,7 @@ const stories: Array = [ 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.', }), diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index a6b0a5204..d474ee35c 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -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; diff --git a/ts/components/conversation/TypingBubble.tsx b/ts/components/conversation/TypingBubble.tsx index 1f8d87dc1..01f89e3bb 100644 --- a/ts/components/conversation/TypingBubble.tsx +++ b/ts/components/conversation/TypingBubble.tsx @@ -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; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 5d53307c0..831d3052e 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -90,6 +90,7 @@ export type MessageType = { }>; errors?: Array; + 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 && diff --git a/ts/storybook/Fixtures.ts b/ts/storybook/Fixtures.ts new file mode 100644 index 000000000..4257bf139 --- /dev/null +++ b/ts/storybook/Fixtures.ts @@ -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' +); diff --git a/ts/types/Util.ts b/ts/types/Util.ts index db1f9cfe5..d7bf47a78 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -6,14 +6,17 @@ export type RenderTextCallbackType = (options: { export type LocalizerType = (key: string, values?: Array) => 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'; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2e7b7c4f0..e1d93a5a6 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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"