diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index 1ed57a3b8..8f03ad436 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; +import { isBoolean } from 'lodash'; import { storiesOf } from '@storybook/react'; import { boolean, select, text } from '@storybook/addon-knobs'; @@ -30,6 +31,9 @@ const conversationTypeMap: Record = { }; const createProps = (overrideProps: Partial = {}): Props => ({ + acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest) + ? overrideProps.acceptedMessageRequest + : true, avatarPath: text('avatarPath', overrideProps.avatarPath || ''), blur: overrideProps.blur, color: select('color', colorMap, overrideProps.color || 'blue'), @@ -151,7 +155,16 @@ story.add('Loading', () => { return sizes.map(size => ); }); -story.add('Blurred', () => { +story.add('Blurred based on props', () => { + const props = createProps({ + acceptedMessageRequest: false, + avatarPath: '/fixtures/kitten-3-64-64.jpg', + }); + + return sizes.map(size => ); +}); + +story.add('Force-blurred', () => { const props = createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg', blur: AvatarBlur.BlurPicture, diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index ad82ef0be..e26ec79f6 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -17,6 +17,7 @@ import { LocalizerType } from '../types/Util'; import { ColorType } from '../types/Colors'; import * as log from '../logging/log'; import { assert } from '../util/assert'; +import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; export enum AvatarBlur { NoBlur, @@ -39,13 +40,17 @@ export type Props = { color?: ColorType; loading?: boolean; + acceptedMessageRequest?: boolean; conversationType: 'group' | 'direct'; - noteToSelf?: boolean; - title: string; + isMe?: boolean; name?: string; + noteToSelf?: boolean; phoneNumber?: string; profileName?: string; + sharedGroupNames?: Array; size: AvatarSize; + title: string; + unblurredAvatarPath?: string; onClick?: () => unknown; @@ -55,19 +60,34 @@ export type Props = { i18n: LocalizerType; } & Pick, 'className'>; +const getDefaultBlur = ( + ...args: Parameters +): AvatarBlur => + shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur; + export const Avatar: FunctionComponent = ({ + acceptedMessageRequest, avatarPath, className, color, conversationType, i18n, + isMe, innerRef, loading, noteToSelf, onClick, + sharedGroupNames, size, title, - blur = AvatarBlur.NoBlur, + unblurredAvatarPath, + blur = getDefaultBlur({ + acceptedMessageRequest, + avatarPath, + isMe, + sharedGroupNames, + unblurredAvatarPath, + }), }) => { const [imageBroken, setImageBroken] = useState(false); @@ -111,6 +131,7 @@ export const Avatar: FunctionComponent = ({ ); } else if (hasImage) { assert(avatarPath, 'avatarPath should be defined here'); + assert( blur !== AvatarBlur.BlurPictureWithClickToView || size >= 100, 'Rendering "click to view" for a small avatar. This may not render correctly' diff --git a/ts/components/CallNeedPermissionScreen.tsx b/ts/components/CallNeedPermissionScreen.tsx index 2329b3183..d17935c73 100644 --- a/ts/components/CallNeedPermissionScreen.tsx +++ b/ts/components/CallNeedPermissionScreen.tsx @@ -6,17 +6,21 @@ import { LocalizerType } from '../types/Util'; import { Avatar } from './Avatar'; import { Intl } from './Intl'; import { ContactName } from './conversation/ContactName'; -import { ColorType } from '../types/Colors'; +import { ConversationType } from '../state/ducks/conversations'; type Props = { - conversation: { - avatarPath?: string; - color?: ColorType; - name?: string; - phoneNumber?: string; - profileName?: string; - title: string; - }; + conversation: Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' + >; i18n: LocalizerType; close: () => void; }; @@ -39,6 +43,7 @@ export const CallNeedPermissionScreen: React.FC = ({ return (
void; - phoneNumber?: string; - profileName?: string; - title: string; -}; +} & Pick< + ConversationType, + | 'about' + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' +>; export class ContactListItem extends React.Component { public renderAvatar(): JSX.Element { const { + acceptedMessageRequest, avatarPath, - i18n, color, + i18n, name, phoneNumber, profileName, + sharedGroupNames, title, + unblurredAvatarPath, } = this.props; return ( { phoneNumber={phoneNumber} profileName={profileName} title={title} + sharedGroupNames={sharedGroupNames} size={52} + unblurredAvatarPath={unblurredAvatarPath} /> ); } diff --git a/ts/components/ContactPill.tsx b/ts/components/ContactPill.tsx index beb53d959..a03a21f22 100644 --- a/ts/components/ContactPill.tsx +++ b/ts/components/ContactPill.tsx @@ -3,26 +3,33 @@ import React, { FunctionComponent } from 'react'; -import { ColorType } from '../types/Colors'; +import { ConversationType } from '../state/ducks/conversations'; import { LocalizerType } from '../types/Util'; import { ContactName } from './conversation/ContactName'; import { Avatar, AvatarSize } from './Avatar'; export type PropsType = { - avatarPath?: string; - color?: ColorType; - firstName?: string; i18n: LocalizerType; - id: string; - isMe?: boolean; - name?: string; onClickRemove: (id: string) => void; - phoneNumber?: string; - profileName?: string; - title: string; -}; +} & Pick< + ConversationType, + | 'about' + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'firstName' + | 'id' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' +>; export const ContactPill: FunctionComponent = ({ + acceptedMessageRequest, avatarPath, color, firstName, @@ -31,7 +38,9 @@ export const ContactPill: FunctionComponent = ({ name, phoneNumber, profileName, + sharedGroupNames, title, + unblurredAvatarPath, onClickRemove, }) => { const removeLabel = i18n('ContactPill--remove'); @@ -39,6 +48,7 @@ export const ContactPill: FunctionComponent = ({ return (
= ({ phoneNumber={phoneNumber} profileName={profileName} title={title} + sharedGroupNames={sharedGroupNames} size={AvatarSize.TWENTY_EIGHT} + unblurredAvatarPath={unblurredAvatarPath} />
diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 8de5b59c8..98fb4b397 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { ReactPortal } from 'react'; @@ -106,14 +106,17 @@ export const ContactModal = ({ aria-label={i18n('close')} />
{contact.title}
diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index f59a9f879..e1605b36b 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -17,7 +17,7 @@ import { Avatar, AvatarSize } from '../Avatar'; import { InContactsIcon } from '../InContactsIcon'; import { LocalizerType } from '../../types/Util'; -import { ColorType } from '../../types/Colors'; +import { ConversationType } from '../../state/ducks/conversations'; import { MuteOption, getMuteOptions } from '../../util/getMuteOptions'; import { ExpirationTimerOptions, @@ -35,33 +35,33 @@ export enum OutgoingCallButtonStyle { export type PropsDataType = { conversationTitle?: string; - id: string; - name?: string; - - phoneNumber?: string; - profileName?: string; - color?: ColorType; - avatarPath?: string; - type: 'direct' | 'group'; - title: string; - - acceptedMessageRequest?: boolean; - isVerified?: boolean; - isMe?: boolean; - isArchived?: boolean; - isPinned?: boolean; isMissingMandatoryProfileSharing?: boolean; - left?: boolean; - markedUnread?: boolean; - groupVersion?: number; - - canChangeTimer?: boolean; - expireTimer?: number; - muteExpiresAt?: number; - - showBackButton?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; -}; + showBackButton?: boolean; +} & Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'canChangeTimer' + | 'color' + | 'expireTimer' + | 'groupVersion' + | 'id' + | 'isArchived' + | 'isMe' + | 'isPinned' + | 'isVerified' + | 'left' + | 'markedUnread' + | 'muteExpiresAt' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'type' + | 'unblurredAvatarPath' +>; export type PropsActionsType = { onSetMuteNotifications: (seconds: number) => void; @@ -180,6 +180,7 @@ export class ConversationHeader extends React.Component { private renderAvatar(): ReactNode { const { + acceptedMessageRequest, avatarPath, color, i18n, @@ -188,22 +189,28 @@ export class ConversationHeader extends React.Component { name, phoneNumber, profileName, + sharedGroupNames, title, + unblurredAvatarPath, } = this.props; return ( ); diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index 9b8c5b28d..38dcfd755 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { number as numberKnob, text } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; import { ConversationHero } from './ConversationHero'; import { setup as setupI18n } from '../../../js/modules/i18n'; @@ -33,6 +34,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']} + unblurAvatar={action('unblurAvatar')} />
); @@ -50,6 +52,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} + unblurAvatar={action('unblurAvatar')} />
); @@ -67,6 +70,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={['NYC Rock Climbers']} + unblurAvatar={action('unblurAvatar')} />
); @@ -84,6 +88,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={[]} + unblurAvatar={action('unblurAvatar')} />
); @@ -101,6 +106,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={[]} + unblurAvatar={action('unblurAvatar')} />
); @@ -118,6 +124,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={[]} + unblurAvatar={action('unblurAvatar')} /> ); @@ -134,6 +141,7 @@ storiesOf('Components/Conversation/ConversationHero', module) phoneNumber={text('phoneNumber', '')} conversationType="direct" sharedGroupNames={[]} + unblurAvatar={action('unblurAvatar')} /> ); @@ -147,6 +155,7 @@ storiesOf('Components/Conversation/ConversationHero', module) name={text('groupName', 'NYC Rock Climbers')} conversationType="group" membersCount={numberKnob('membersCount', 22)} + unblurAvatar={action('unblurAvatar')} /> ); @@ -160,6 +169,7 @@ storiesOf('Components/Conversation/ConversationHero', module) name={text('groupName', 'NYC Rock Climbers')} conversationType="group" membersCount={1} + unblurAvatar={action('unblurAvatar')} /> ); @@ -173,6 +183,7 @@ storiesOf('Components/Conversation/ConversationHero', module) name={text('groupName', 'NYC Rock Climbers')} conversationType="group" membersCount={0} + unblurAvatar={action('unblurAvatar')} /> ); @@ -186,6 +197,7 @@ storiesOf('Components/Conversation/ConversationHero', module) name={text('groupName', '')} conversationType="group" membersCount={0} + unblurAvatar={action('unblurAvatar')} /> ); @@ -199,6 +211,7 @@ storiesOf('Components/Conversation/ConversationHero', module) title={getTitle()} conversationType="direct" phoneNumber={getPhoneNumber()} + unblurAvatar={action('unblurAvatar')} /> ); diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index 2df7ff9f9..ad7c9d790 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -1,21 +1,25 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import { Avatar, Props as AvatarProps } from '../Avatar'; +import { Avatar, AvatarBlur, Props as AvatarProps } from '../Avatar'; import { ContactName } from './ContactName'; import { About } from './About'; import { SharedGroupNames } from '../SharedGroupNames'; import { LocalizerType } from '../../types/Util'; +import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; export type Props = { about?: string; + acceptedMessageRequest?: boolean; i18n: LocalizerType; isMe?: boolean; sharedGroupNames?: Array; membersCount?: number; phoneNumber?: string; onHeightChange?: () => unknown; + unblurAvatar: () => void; + unblurredAvatarPath?: string; updateSharedGroups?: () => unknown; } & Omit; @@ -61,6 +65,7 @@ const renderMembershipRow = ({ export const ConversationHero = ({ i18n, about, + acceptedMessageRequest, avatarPath, color, conversationType, @@ -72,6 +77,8 @@ export const ConversationHero = ({ profileName, title, onHeightChange, + unblurAvatar, + unblurredAvatarPath, updateSharedGroups, }: Props): JSX.Element => { const firstRenderRef = React.useRef(true); @@ -106,6 +113,23 @@ export const ConversationHero = ({ ]); /* eslint-enable react-hooks/exhaustive-deps */ + let avatarBlur: AvatarBlur; + let avatarOnClick: undefined | (() => void); + if ( + shouldBlurAvatar({ + acceptedMessageRequest, + avatarPath, + isMe, + sharedGroupNames, + unblurredAvatarPath, + }) + ) { + avatarBlur = AvatarBlur.BlurPictureWithClickToView; + avatarOnClick = unblurAvatar; + } else { + avatarBlur = AvatarBlur.NoBlur; + } + const phoneNumberOnly = Boolean( !name && !profileName && conversationType === 'direct' ); @@ -115,11 +139,13 @@ export const ConversationHero = ({
; reducedMotion?: boolean; conversationType: ConversationTypesType; @@ -1159,15 +1163,19 @@ export class Message extends React.Component { tabIndex={0} >
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 8a7a833dd..c9e9d0e76 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -271,6 +271,8 @@ const actions = () => ({ onBlockAndDelete: action('onBlockAndDelete'), onDelete: action('onDelete'), onUnblock: action('onUnblock'), + + unblurAvatar: action('unblurAvatar'), }); const renderItem = (id: string) => ( @@ -312,6 +314,7 @@ const renderHeroRow = () => ( phoneNumber={getPhoneNumber()} conversationType="direct" sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} + unblurAvatar={action('unblurAvatar')} /> ); const renderLoadingRow = () => ; diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index efdbbad06..d52243f3c 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -81,6 +81,7 @@ type PropsHousekeepingType = { renderHeroRow: ( id: string, resizeHeroRow: () => unknown, + unblurAvatar: () => void, updateSharedGroups: () => unknown ) => JSX.Element; renderLoadingRow: (id: string) => JSX.Element; @@ -113,6 +114,7 @@ type PropsActionsType = { onUnblock: () => unknown; selectMessage: (messageId: string, conversationId: string) => unknown; clearSelectedMessage: () => unknown; + unblurAvatar: () => void; updateSharedGroups: () => unknown; } & MessageActionsType & SafetyNumberActionsType; @@ -583,6 +585,7 @@ export class Timeline extends React.PureComponent { renderLoadingRow, renderLastSeenIndicator, renderTypingBubble, + unblurAvatar, updateSharedGroups, } = this.props; const { lastMeasuredWarningHeight } = this.state; @@ -602,7 +605,12 @@ export class Timeline extends React.PureComponent { {this.getWarning() ? (
) : null} - {renderHeroRow(id, this.resizeHeroRow, updateSharedGroups)} + {renderHeroRow( + id, + this.resizeHeroRow, + unblurAvatar, + updateSharedGroups + )}
); } else if (!haveOldest && row === 0) { diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index aeaae639c..62f81e67d 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -9,8 +9,8 @@ import { Avatar, AvatarSize } from '../Avatar'; import { Timestamp } from '../conversation/Timestamp'; import { isConversationUnread } from '../../util/isConversationUnread'; import { cleanId } from '../_util'; -import { ColorType } from '../../types/Colors'; import { LocalizerType } from '../../types/Util'; +import { ConversationType } from '../../state/ducks/conversations'; const BASE_CLASS_NAME = 'module-conversation-list__item--contact-or-conversation'; @@ -23,33 +23,40 @@ export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; type PropsType = { - avatarPath?: string; checked?: boolean; - color?: ColorType; conversationType: 'group' | 'direct'; disabled?: boolean; headerDate?: number; headerName: ReactNode; - i18n: LocalizerType; id?: string; - isMe?: boolean; + i18n: LocalizerType; isNoteToSelf?: boolean; isSelected: boolean; markedUnread?: boolean; messageId?: string; messageStatusIcon?: ReactNode; messageText?: ReactNode; - name?: string; onClick?: () => void; - phoneNumber?: string; - profileName?: string; style: CSSProperties; - title: string; unreadCount?: number; -}; +} & Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'isMe' + | 'markedUnread' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' +>; export const BaseConversationListItem: FunctionComponent = React.memo( ({ + acceptedMessageRequest, avatarPath, checked, color, @@ -69,8 +76,10 @@ export const BaseConversationListItem: FunctionComponent = React.memo onClick, phoneNumber, profileName, + sharedGroupNames, style, title, + unblurredAvatarPath, unreadCount, }) => { const isUnread = isConversationUnread({ markedUnread, unreadCount }); @@ -112,6 +121,7 @@ export const BaseConversationListItem: FunctionComponent = React.memo <>
= React.memo phoneNumber={phoneNumber} profileName={profileName} title={title} + sharedGroupNames={sharedGroupNames} size={AvatarSize.FIFTY_TWO} + unblurredAvatarPath={unblurredAvatarPath} /> {isUnread && (
diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx index 22cbe5baf..9832633d2 100644 --- a/ts/components/conversationList/ContactCheckbox.tsx +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -4,7 +4,7 @@ import React, { CSSProperties, FunctionComponent, ReactNode } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; -import { ColorType } from '../../types/Colors'; +import { ConversationType } from '../../state/ducks/conversations'; import { LocalizerType } from '../../types/Util'; import { ContactName } from '../conversation/ContactName'; import { About } from '../conversation/About'; @@ -17,18 +17,23 @@ export enum ContactCheckboxDisabledReason { } export type PropsDataType = { - about?: string; - avatarPath?: string; - color?: ColorType; disabledReason?: ContactCheckboxDisabledReason; - id: string; - isMe?: boolean; isChecked: boolean; - name?: string; - phoneNumber?: string; - profileName?: string; - title: string; -}; +} & Pick< + ConversationType, + | 'about' + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'id' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' +>; type PropsHousekeepingType = { i18n: LocalizerType; @@ -44,6 +49,7 @@ type PropsType = PropsDataType & PropsHousekeepingType; export const ContactCheckbox: FunctionComponent = React.memo( ({ about, + acceptedMessageRequest, avatarPath, color, disabledReason, @@ -55,8 +61,10 @@ export const ContactCheckbox: FunctionComponent = React.memo( onClick, phoneNumber, profileName, + sharedGroupNames, style, title, + unblurredAvatarPath, }) => { const disabled = Boolean(disabledReason); @@ -87,6 +95,7 @@ export const ContactCheckbox: FunctionComponent = React.memo( return ( = React.memo( onClick={onClickItem} phoneNumber={phoneNumber} profileName={profileName} + sharedGroupNames={sharedGroupNames} style={style} title={title} + unblurredAvatarPath={unblurredAvatarPath} /> ); } diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx index 5f031a3c9..f733ce649 100644 --- a/ts/components/conversationList/ContactListItem.tsx +++ b/ts/components/conversationList/ContactListItem.tsx @@ -4,23 +4,27 @@ import React, { CSSProperties, FunctionComponent } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; -import { ColorType } from '../../types/Colors'; +import { ConversationType } from '../../state/ducks/conversations'; import { LocalizerType } from '../../types/Util'; import { ContactName } from '../conversation/ContactName'; import { About } from '../conversation/About'; -export type PropsDataType = { - about?: string; - avatarPath?: string; - color?: ColorType; - id: string; - isMe?: boolean; - name?: string; - phoneNumber?: string; - profileName?: string; - title: string; - type: 'group' | 'direct'; -}; +export type PropsDataType = Pick< + ConversationType, + | 'about' + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'id' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'type' + | 'unblurredAvatarPath' +>; type PropsHousekeepingType = { i18n: LocalizerType; @@ -33,6 +37,7 @@ type PropsType = PropsDataType & PropsHousekeepingType; export const ContactListItem: FunctionComponent = React.memo( ({ about, + acceptedMessageRequest, avatarPath, color, i18n, @@ -42,9 +47,11 @@ export const ContactListItem: FunctionComponent = React.memo( onClick, phoneNumber, profileName, + sharedGroupNames, style, title, type, + unblurredAvatarPath, }) => { const headerName = isMe ? ( i18n('noteToSelf') @@ -63,6 +70,7 @@ export const ContactListItem: FunctionComponent = React.memo( return ( = React.memo( onClick={onClick ? () => onClick(id) : undefined} phoneNumber={phoneNumber} profileName={profileName} + sharedGroupNames={sharedGroupNames} style={style} title={title} + unblurredAvatarPath={unblurredAvatarPath} /> ); } diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 18e848d4f..007761bb5 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -44,6 +44,8 @@ export type PropsData = { avatarPath?: string; isMe?: boolean; muteExpiresAt?: number; + sharedGroupNames?: Array; + unblurredAvatarPath?: string; lastUpdated?: number; unreadCount?: number; @@ -89,11 +91,13 @@ export const ConversationListItem: FunctionComponent = React.memo( onClick, phoneNumber, profileName, + sharedGroupNames, shouldShowDraft, style, title, type, typingContact, + unblurredAvatarPath, unreadCount, }) => { const headerName = isMe ? ( @@ -180,6 +184,7 @@ export const ConversationListItem: FunctionComponent = React.memo( return ( = React.memo( onClick={onClickItem} phoneNumber={phoneNumber} profileName={profileName} + sharedGroupNames={sharedGroupNames} style={style} title={title} unreadCount={unreadCount} + unblurredAvatarPath={unblurredAvatarPath} /> ); } diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx index 3478752b7..f98e5ea10 100644 --- a/ts/components/conversationList/MessageSearchResult.tsx +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { @@ -14,8 +14,8 @@ import { ContactName } from '../conversation/ContactName'; import { assert } from '../../util/assert'; import { BodyRangesType, LocalizerType } from '../../types/Util'; -import { ColorType } from '../../types/Colors'; import { BaseConversationListItem } from './BaseConversationListItem'; +import { ConversationType } from '../../state/ducks/conversations'; export type PropsDataType = { isSelected?: boolean; @@ -29,15 +29,19 @@ export type PropsDataType = { body: string; bodyRanges: BodyRangesType; - from: { - phoneNumber?: string; - title: string; - isMe?: boolean; - name?: string; - color?: ColorType; - profileName?: string; - avatarPath?: string; - }; + from: Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'color' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + | 'unblurredAvatarPath' + >; to: { groupName?: string; @@ -192,6 +196,7 @@ export const MessageSearchResult: FunctionComponent = React.memo( return ( = React.memo( onClick={onClickItem} phoneNumber={from.phoneNumber} profileName={from.profileName} + sharedGroupNames={from.sharedGroupNames} style={style} title={from.title} + unblurredAvatarPath={from.unblurredAvatarPath} /> ); } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index dcd0ed7ac..646b82149 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -283,6 +283,15 @@ export type ConversationAttributesType = { // Used only when user is waiting for approval to join via link isTemporary?: boolean; temporaryMemberCount?: number; + + // Avatars are blurred for some unapproved conversations, but users can manually unblur + // them. If the avatar was unblurred and then changed, we don't update this value so + // the new avatar gets blurred. + // + // This value is useless once the message request has been approved. We don't clean it + // up but could. We don't persist it but could (though we'd probably want to clean it + // up in that case). + unblurredAvatarPath?: string; }; export type GroupV2MemberType = { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 5bdd87adf..9d065032b 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1340,6 +1340,7 @@ export class ConversationModel extends window.Backbone canChangeTimer: this.canChangeTimer(), canEditGroupInfo: this.canEditGroupInfo(), avatarPath: this.getAbsoluteAvatarPath(), + unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(), color, discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'), draftBodyRanges, @@ -4883,16 +4884,32 @@ export class ConversationModel extends window.Backbone return migrateColor(this.get('color')); } - getAbsoluteAvatarPath(): string | undefined { + private getAvatarPath(): undefined | string { const avatar = this.isMe() ? this.get('profileAvatar') || this.get('avatar') : this.get('avatar') || this.get('profileAvatar'); + return avatar?.path || undefined; + } - if (!avatar || !avatar.path) { - return undefined; + getAbsoluteAvatarPath(): string | undefined { + const avatarPath = this.getAvatarPath(); + return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined; + } + + getAbsoluteUnblurredAvatarPath(): string | undefined { + const unblurredAvatarPath = this.get('unblurredAvatarPath'); + return unblurredAvatarPath + ? getAbsoluteAttachmentPath(unblurredAvatarPath) + : undefined; + } + + unblurAvatar(): void { + const avatarPath = this.getAvatarPath(); + if (avatarPath) { + this.set('unblurredAvatarPath', avatarPath); + } else { + this.unset('unblurredAvatarPath'); } - - return getAbsoluteAttachmentPath(avatar.path); } private canChangeTimer(): boolean { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 56aea81cc..906adfe7c 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2326,7 +2326,9 @@ async function saveConversation( ` ).run({ id, - json: objectToJSON(omit(data, ['profileLastFetchedAt'])), + json: objectToJSON( + omit(data, ['profileLastFetchedAt', 'unblurredAvatarPath']) + ), e164: e164 || null, uuid: uuid || null, @@ -2399,7 +2401,9 @@ async function updateConversation(data: ConversationType): Promise { ` ).run({ id, - json: objectToJSON(omit(data, ['profileLastFetchedAt'])), + json: objectToJSON( + omit(data, ['profileLastFetchedAt', 'unblurredAvatarPath']) + ), e164: e164 || null, uuid: uuid || null, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 7ac56a291..28116d0b6 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -60,6 +60,7 @@ export type ConversationType = { profileName?: string; about?: string; avatarPath?: string; + unblurredAvatarPath?: string; areWeAdmin?: boolean; areWePending?: boolean; areWePendingApproval?: boolean; diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 304d7c235..ab8eae002 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -105,6 +105,8 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { 'title', 'type', 'groupVersion', + 'sharedGroupNames', + 'unblurredAvatarPath', ]), conversationTitle: state.conversations.selectedConversationTitle, isMissingMandatoryProfileSharing: Boolean( diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 1357fa50c..ca07023b1 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -70,12 +70,14 @@ function renderLastSeenIndicator(id: string): JSX.Element { function renderHeroRow( id: string, onHeightChange: () => unknown, + unblurAvatar: () => void, updateSharedGroups: () => unknown ): JSX.Element { return ( ); diff --git a/ts/test-both/util/shouldBlurAvatar_test.ts b/ts/test-both/util/shouldBlurAvatar_test.ts new file mode 100644 index 000000000..69aae3656 --- /dev/null +++ b/ts/test-both/util/shouldBlurAvatar_test.ts @@ -0,0 +1,104 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; + +describe('shouldBlurAvatar', () => { + it('returns false for me', () => { + assert.isFalse( + shouldBlurAvatar({ + isMe: true, + acceptedMessageRequest: false, + avatarPath: '/path/to/avatar.jpg', + sharedGroupNames: [], + unblurredAvatarPath: undefined, + }) + ); + }); + + it('returns false if the message request has been accepted', () => { + assert.isFalse( + shouldBlurAvatar({ + acceptedMessageRequest: true, + avatarPath: '/path/to/avatar.jpg', + isMe: false, + sharedGroupNames: [], + unblurredAvatarPath: undefined, + }) + ); + }); + + it('returns false if there are any shared groups', () => { + assert.isFalse( + shouldBlurAvatar({ + sharedGroupNames: ['Tahoe Trip'], + acceptedMessageRequest: false, + avatarPath: '/path/to/avatar.jpg', + isMe: false, + unblurredAvatarPath: undefined, + }) + ); + }); + + it('returns false if there is no avatar', () => { + assert.isFalse( + shouldBlurAvatar({ + acceptedMessageRequest: false, + isMe: false, + sharedGroupNames: [], + unblurredAvatarPath: undefined, + }) + ); + assert.isFalse( + shouldBlurAvatar({ + avatarPath: undefined, + acceptedMessageRequest: false, + isMe: false, + sharedGroupNames: [], + unblurredAvatarPath: undefined, + }) + ); + assert.isFalse( + shouldBlurAvatar({ + avatarPath: undefined, + unblurredAvatarPath: '/some/other/path', + acceptedMessageRequest: false, + isMe: false, + sharedGroupNames: [], + }) + ); + }); + + it('returns false if the avatar was unblurred', () => { + assert.isFalse( + shouldBlurAvatar({ + avatarPath: '/path/to/avatar.jpg', + unblurredAvatarPath: '/path/to/avatar.jpg', + acceptedMessageRequest: false, + isMe: false, + sharedGroupNames: [], + }) + ); + }); + + it('returns true if the stars align (i.e., not everything above)', () => { + assert.isTrue( + shouldBlurAvatar({ + avatarPath: '/path/to/avatar.jpg', + acceptedMessageRequest: false, + isMe: false, + }) + ); + assert.isTrue( + shouldBlurAvatar({ + avatarPath: '/path/to/avatar.jpg', + unblurredAvatarPath: '/different/path.jpg', + acceptedMessageRequest: false, + isMe: false, + sharedGroupNames: [], + }) + ); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index b43218370..176c02628 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -16510,7 +16510,7 @@ "rule": "React-useRef", "path": "ts/components/conversation/ConversationHero.js", "line": " const firstRenderRef = React.useRef(true);", - "lineNumber": 48, + "lineNumber": 49, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16519,7 +16519,7 @@ "rule": "React-useRef", "path": "ts/components/conversation/ConversationHero.tsx", "line": " const firstRenderRef = React.useRef(true);", - "lineNumber": 77, + "lineNumber": 84, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16582,7 +16582,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public focusRef: React.RefObject = React.createRef();", - "lineNumber": 250, + "lineNumber": 254, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for managing focus only" @@ -16591,7 +16591,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioButtonRef: React.RefObject = React.createRef();", - "lineNumber": 252, + "lineNumber": 256, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for propagating click from the Message to MessageAudio's button" @@ -16600,7 +16600,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public reactionsContainerRef: React.RefObject = React.createRef();", - "lineNumber": 254, + "lineNumber": 258, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for detecting clicks outside reaction viewer" @@ -16936,4 +16936,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] +] \ No newline at end of file diff --git a/ts/util/shouldBlurAvatar.ts b/ts/util/shouldBlurAvatar.ts new file mode 100644 index 000000000..379bc0a69 --- /dev/null +++ b/ts/util/shouldBlurAvatar.ts @@ -0,0 +1,28 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ConversationType } from '../state/ducks/conversations'; + +export const shouldBlurAvatar = ({ + acceptedMessageRequest, + avatarPath, + isMe, + sharedGroupNames = [], + unblurredAvatarPath, +}: Readonly< + Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'isMe' + | 'sharedGroupNames' + | 'unblurredAvatarPath' + > +>): boolean => + Boolean( + !isMe && + !acceptedMessageRequest && + !sharedGroupNames.length && + avatarPath && + avatarPath !== unblurredAvatarPath + ); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 7a38c6094..572a5906b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -976,6 +976,9 @@ Whisper.ConversationView = Whisper.View.extend({ }, onShowContactModal: this.showContactModal.bind(this), scrollToQuotedMessage, + unblurAvatar: () => { + this.model.unblurAvatar(); + }, updateSharedGroups: this.model.throttledUpdateSharedGroups, }), });