Click to download avatar for unaccepted conversations

This commit is contained in:
yash-signal
2025-03-26 13:27:04 -07:00
committed by GitHub
parent 6c5047ba3e
commit 7cf26c5e25
100 changed files with 730 additions and 544 deletions

View File

@@ -21,7 +21,7 @@ import { getAuthorId } from './messages/helpers';
import { maybeDeriveGroupV2Id } from './groups';
import { assertDev, strictAssert } from './util/assert';
import { drop } from './util/drop';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import { isGroup, isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import type { ServiceIdString, AciString, PniString } from './types/ServiceId';
import {
isServiceIdString,
@@ -39,6 +39,8 @@ import * as StorageService from './services/storage';
import type { ConversationPropsForUnreadStats } from './util/countUnreadStats';
import { countAllConversationsUnreadStats } from './util/countUnreadStats';
import { isTestOrMockEnvironment } from './environment';
import { isConversationAccepted } from './util/isConversationAccepted';
import { areWePending } from './util/groupMembershipUtils';
import { conversationJobQueue } from './jobs/conversationJobQueue';
type ConvoMatchType =
@@ -1372,6 +1374,52 @@ export class ConversationController {
this.get(conversationId)?.onOpenComplete(loadStart);
}
migrateAvatarsForNonAcceptedConversations(): void {
if (window.storage.get('avatarsHaveBeenMigrated')) {
return;
}
const conversations = this.getAll();
let numberOfConversationsMigrated = 0;
for (const conversation of conversations) {
const attrs = conversation.attributes;
if (
!isConversationAccepted(attrs) ||
(isGroup(attrs) && areWePending(attrs))
) {
const avatarPath = attrs.avatar?.path;
const profileAvatarPath = attrs.profileAvatar?.path;
if (avatarPath || profileAvatarPath) {
drop(
(async () => {
const { doesAttachmentExist, deleteAttachmentData } =
window.Signal.Migrations;
if (avatarPath && (await doesAttachmentExist(avatarPath))) {
await deleteAttachmentData(avatarPath);
}
if (
profileAvatarPath &&
(await doesAttachmentExist(profileAvatarPath))
) {
await deleteAttachmentData(profileAvatarPath);
}
})()
);
}
conversation.set('avatar', undefined);
conversation.set('profileAvatar', undefined);
drop(updateConversation(conversation.attributes));
numberOfConversationsMigrated += 1;
}
}
log.info(
`ConversationController: unset avatars for ${numberOfConversationsMigrated} unaccepted conversations`
);
drop(window.storage.put('avatarsHaveBeenMigrated', true));
}
repairPinnedConversations(): void {
const pinnedIds = window.storage.get('pinnedConversationIds', []);

View File

@@ -670,6 +670,34 @@ export function constantTimeEqual(
return crypto.constantTimeEqual(left, right);
}
export function getIdentifierHash({
aci,
e164,
pni,
groupId,
}: {
aci: AciString | undefined;
e164: string | undefined;
pni: PniString | undefined;
groupId: string | undefined;
}): number | null {
let identifier: Uint8Array;
if (aci != null) {
identifier = Aci.parseFromServiceIdString(aci).getServiceIdBinary();
} else if (e164 != null) {
identifier = Bytes.fromString(e164);
} else if (pni != null) {
identifier = Pni.parseFromServiceIdString(pni).getServiceIdBinary();
} else if (groupId != null) {
identifier = Bytes.fromBase64(groupId);
} else {
return null;
}
const digest = hash(HashType.size256, identifier);
return digest[0];
}
export function generateAvatarColor({
aci,
e164,
@@ -681,19 +709,11 @@ export function generateAvatarColor({
pni: PniString | undefined;
groupId: string | undefined;
}): string {
let identifier: Uint8Array;
if (aci != null) {
identifier = Aci.parseFromServiceIdString(aci).getServiceIdBinary();
} else if (e164 != null) {
identifier = Bytes.fromString(e164);
} else if (pni != null) {
identifier = Pni.parseFromServiceIdString(pni).getServiceIdBinary();
} else if (groupId != null) {
identifier = Bytes.fromBase64(groupId);
} else {
const hashValue = getIdentifierHash({ aci, e164, pni, groupId });
if (hashValue == null) {
return sample(AvatarColors) || AvatarColors[0];
}
const digest = hash(HashType.size256, identifier);
return AvatarColors[digest[0] % AVATAR_COLOR_COUNT];
return AvatarColors[hashValue % AVATAR_COLOR_COUNT];
}

View File

@@ -1445,6 +1445,10 @@ export async function startApp(): Promise<void> {
if (window.isBeforeVersion(lastVersion, 'v5.31.0')) {
window.ConversationController.repairPinnedConversations();
}
if (!window.storage.get('avatarsHaveBeenMigrated', false)) {
window.ConversationController.migrateAvatarsForNonAcceptedConversations();
}
}
void badgeImageFileDownloader.checkForFilesToDownload();

View File

@@ -129,7 +129,7 @@ export function AddUserToAnotherGroupModal({
}
return {
...pick(convo, 'id', 'avatarUrl', 'title', 'unblurredAvatarUrl'),
...pick(convo, 'id', 'avatarUrl', 'title', 'hasAvatar'),
memberships,
membersCount,
disabledReason,

View File

@@ -4,7 +4,6 @@
import type { Meta, StoryFn } from '@storybook/react';
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { isBoolean } from 'lodash';
import { expect, fn, within, userEvent } from '@storybook/test';
import type { AvatarColorType } from '../types/Colors';
import type { Props } from './Avatar';
@@ -60,16 +59,13 @@ export default {
} satisfies Meta<Props>;
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest)
? overrideProps.acceptedMessageRequest
: true,
avatarUrl: overrideProps.avatarUrl || '',
badge: overrideProps.badge,
blur: overrideProps.blur,
color: overrideProps.color || AvatarColors[0],
conversationType: overrideProps.conversationType || 'direct',
hasAvatar: Boolean(overrideProps.hasAvatar),
i18n,
isMe: false,
loading: Boolean(overrideProps.loading),
noteToSelf: Boolean(overrideProps.noteToSelf),
onClick: fn(action('onClick')),
@@ -200,8 +196,9 @@ Loading.args = createProps({
export const BlurredBasedOnProps = TemplateSingle.bind({});
BlurredBasedOnProps.args = createProps({
acceptedMessageRequest: false,
hasAvatar: true,
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
blur: AvatarBlur.BlurPicture,
});
export const ForceBlurred = TemplateSingle.bind({});

View File

@@ -25,8 +25,8 @@ import { assertDev } from '../util/assert';
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
import { getInitials } from '../util/getInitials';
import { isBadgeVisible } from '../badges/isBadgeVisible';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
import { SIGNAL_AVATAR_PATH } from '../types/SignalConversation';
import { getAvatarPlaceholderGradient } from '../utils/getAvatarPlaceholderGradient';
export enum AvatarBlur {
NoBlur,
@@ -54,20 +54,18 @@ type BadgePlacementType = { bottom: number; right: number };
export type Props = {
avatarUrl?: string;
avatarPlaceholderGradient?: Readonly<[string, string]>;
blur?: AvatarBlur;
color?: AvatarColorType;
hasAvatar?: boolean;
loading?: boolean;
acceptedMessageRequest: boolean;
conversationType: 'group' | 'direct' | 'callLink';
isMe: boolean;
noteToSelf?: boolean;
phoneNumber?: string;
profileName?: string;
sharedGroupNames: ReadonlyArray<string>;
size: AvatarSize;
title: string;
unblurredAvatarUrl?: string;
searchResult?: boolean;
storyRing?: HasStories;
@@ -100,39 +98,26 @@ const BADGE_PLACEMENT_BY_SIZE = new Map<number, BadgePlacementType>([
[112, { bottom: -4, right: 3 }],
]);
const getDefaultBlur = (
...args: Parameters<typeof shouldBlurAvatar>
): AvatarBlur =>
shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur;
export function Avatar({
acceptedMessageRequest,
avatarUrl,
avatarPlaceholderGradient = getAvatarPlaceholderGradient(0),
badge,
className,
color = 'A200',
conversationType,
hasAvatar,
i18n,
isMe,
innerRef,
loading,
noteToSelf,
onClick,
onClickBadge,
sharedGroupNames,
size,
theme,
title,
unblurredAvatarUrl,
searchResult,
storyRing,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarUrl,
isMe,
sharedGroupNames,
unblurredAvatarUrl,
}),
blur = AvatarBlur.NoBlur,
...ariaProps
}: Props): JSX.Element {
const [imageBroken, setImageBroken] = useState(false);
@@ -204,6 +189,20 @@ export function Avatar({
)}
</>
);
} else if (hasAvatar && !hasImage) {
contentsChildren = (
<>
<div
className="module-Avatar__image"
style={{
backgroundImage: `linear-gradient(to bottom, ${avatarPlaceholderGradient[0]}, ${avatarPlaceholderGradient[1]})`,
}}
/>
{blur === AvatarBlur.BlurPictureWithClickToView && (
<div className="module-Avatar__click-to-view">{i18n('icu:view')}</div>
)}
</>
);
} else if (searchResult) {
contentsChildren = (
<div

View File

@@ -10,9 +10,11 @@ import { Lightbox } from './Lightbox';
import type { LocalizerType } from '../types/Util';
export type PropsType = {
avatarPlaceholderGradient?: Readonly<[string, string]>;
avatarColor?: AvatarColorType;
avatarUrl?: string;
conversationTitle?: string;
hasAvatar?: boolean;
i18n: LocalizerType;
isGroup?: boolean;
noteToSelf?: boolean;
@@ -20,9 +22,11 @@ export type PropsType = {
};
export function AvatarLightbox({
avatarPlaceholderGradient,
avatarColor,
avatarUrl,
conversationTitle,
hasAvatar,
i18n,
isGroup,
noteToSelf,
@@ -44,9 +48,11 @@ export function AvatarLightbox({
selectedIndex={0}
>
<AvatarPreview
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarColor={avatarColor}
avatarUrl={avatarUrl}
conversationTitle={conversationTitle}
hasAvatar={hasAvatar}
i18n={i18n}
isGroup={isGroup}
noteToSelf={noteToSelf}

View File

@@ -12,6 +12,7 @@ import type { AvatarColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { getInitials } from '../util/getInitials';
import { imagePathToBytes } from '../util/imagePathToBytes';
import { type ConversationType } from '../state/ducks/conversations';
export type PropsType = {
avatarColor?: AvatarColorType;
@@ -26,19 +27,22 @@ export type PropsType = {
onClear?: () => unknown;
onClick?: () => unknown;
style?: CSSProperties;
};
} & Pick<ConversationType, 'avatarPlaceholderGradient' | 'hasAvatar'>;
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
HasPlaceholder = 'has-placeholder',
}
export function AvatarPreview({
avatarPlaceholderGradient,
avatarColor = AvatarColors[0],
avatarUrl,
avatarValue,
conversationTitle,
hasAvatar,
i18n,
isEditable,
isGroup,
@@ -127,6 +131,8 @@ export function AvatarPreview({
} else if (avatarUrl) {
encodedPath = avatarUrl;
imageStatus = ImageStatus.HasImage;
} else if (hasAvatar && avatarPlaceholderGradient) {
imageStatus = ImageStatus.HasPlaceholder;
} else {
imageStatus = ImageStatus.Nothing;
}
@@ -184,6 +190,22 @@ export function AvatarPreview({
);
}
if (imageStatus === ImageStatus.HasPlaceholder) {
return (
<div className="AvatarPreview">
<div
className="AvatarPreview__avatar"
style={{
...componentStyle,
backgroundImage: avatarPlaceholderGradient
? `linear-gradient(to bottom, ${avatarPlaceholderGradient[0]}, ${avatarPlaceholderGradient[1]})`
: undefined,
}}
/>
</div>
);
}
return (
<div className="AvatarPreview">
<div

View File

@@ -91,8 +91,6 @@ export function CallLinkAddNameModal({
color={getColorForCallLink(callLink.rootKey)}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={
callLink.name === ''

View File

@@ -107,8 +107,6 @@ export function CallLinkDetails({
color={getColorForCallLink(callLink.rootKey)}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={callLink.name ?? i18n('icu:calling__call-link-default-title')}
/>
@@ -277,8 +275,6 @@ function renderMissingCallLink({
badge={undefined}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={i18n('icu:calling__call-link-default-title')}
/>

View File

@@ -128,8 +128,6 @@ export function CallLinkEditModal({
color={getColorForCallLink(callLink.rootKey)}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={
callLink.name === ''

View File

@@ -62,19 +62,18 @@ export function CallLinkPendingParticipantModal({
theme={Theme.Dark}
>
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarUrl={conversation.avatarUrl}
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
badge={undefined}
color={conversation.color}
conversationType="direct"
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={conversation.isMe}
profileName={conversation.profileName}
sharedGroupNames={conversation.sharedGroupNames}
size={AvatarSize.EIGHTY}
title={conversation.title}
theme={ThemeType.dark}
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
/>
<button

View File

@@ -12,16 +12,17 @@ import type { ConversationType } from '../state/ducks/conversations';
export type Props = {
conversation: Pick<
ConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'color'
| 'hasAvatar'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarUrl'
>;
i18n: LocalizerType;
close: () => void;
@@ -45,14 +46,14 @@ export function CallNeedPermissionScreen({
return (
<div className="module-call-need-permission-screen">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarUrl={conversation.avatarUrl}
badge={undefined}
color={conversation.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={conversation.isMe}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
title={conversation.title}

View File

@@ -500,14 +500,14 @@ export function CallScreen({
) : (
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<Avatar
acceptedMessageRequest
avatarPlaceholderGradient={me.avatarPlaceholderGradient}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color || AvatarColors[0]}
hasAvatar={me.hasAvatar}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}

View File

@@ -79,7 +79,7 @@ function UnknownContacts({
: 0;
return (
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPlaceholderGradient={participant.avatarPlaceholderGradient}
avatarUrl={participant.avatarUrl}
badge={undefined}
className="CallingAdhocCallInfo__UnknownContactAvatar"
@@ -87,7 +87,6 @@ function UnknownContacts({
conversationType="direct"
key={key}
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
@@ -210,13 +209,12 @@ export function CallingAdhocCallInfo({
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPlaceholderGradient={participant.avatarPlaceholderGradient}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}

View File

@@ -35,9 +35,11 @@ export type PropsType = {
callMode: CallMode;
conversation: Pick<
CallingConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'color'
| 'hasAvatar'
| 'isMe'
| 'memberships'
| 'name'
@@ -48,7 +50,6 @@ export type PropsType = {
| 'systemNickname'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
>;
getIsSharingPhoneNumberWithEverybody: () => boolean;
groupMembers?: Array<

View File

@@ -126,15 +126,14 @@ export const CallingParticipantsList = React.memo(
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={
participant.acceptedMessageRequest
avatarPlaceholderGradient={
participant.avatarPlaceholderGradient
}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}

View File

@@ -308,13 +308,14 @@ export function CallingPendingParticipants({
className="module-calling-participants-list__avatar-and-name CallingPendingParticipants__ParticipantButton"
>
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPlaceholderGradient={
participant.avatarPlaceholderGradient
}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
@@ -396,13 +397,12 @@ export function CallingPendingParticipants({
className="module-calling-participants-list__avatar-and-name CallingPendingParticipants__ParticipantButton"
>
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPlaceholderGradient={participant.avatarPlaceholderGradient}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}

View File

@@ -40,11 +40,10 @@ function NoVideo({
i18n: LocalizerType;
}): JSX.Element {
const {
acceptedMessageRequest,
avatarPlaceholderGradient,
avatarUrl,
color,
type: conversationType,
isMe,
phoneNumber,
profileName,
sharedGroupNames,
@@ -56,14 +55,13 @@ function NoVideo({
<CallBackgroundBlur avatarUrl={avatarUrl}>
<div className="module-calling-pip__video--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}

View File

@@ -30,9 +30,11 @@ type PeekedParticipantType = Pick<
export type PropsType = {
conversation: Pick<
CallingConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'color'
| 'hasAvatar'
| 'isMe'
| 'phoneNumber'
| 'profileName'
@@ -41,7 +43,6 @@ export type PropsType = {
| 'systemNickname'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
>;
i18n: LocalizerType;
me: Pick<ConversationType, 'id' | 'serviceId'>;
@@ -216,19 +217,18 @@ export function CallingPreCallInfo({
return (
<div className="module-CallingPreCallInfo">
<Avatar
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarUrl={conversation.avatarUrl}
badge={undefined}
color={conversation.color}
acceptedMessageRequest={conversation.acceptedMessageRequest}
conversationType={conversation.type}
isMe={conversation.isMe}
hasAvatar={conversation.hasAvatar}
noteToSelf={false}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
sharedGroupNames={conversation.sharedGroupNames}
size={AvatarSize.SIXTY_FOUR}
title={conversation.title}
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
i18n={i18n}
/>
<div className="module-CallingPreCallInfo__title">

View File

@@ -101,13 +101,15 @@ export function CallingRaisedHandsList({
>
<div className="CallingRaisedHandsList__AvatarAndName module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPlaceholderGradient={
participant.avatarPlaceholderGradient
}
avatarUrl={participant.avatarUrl}
badge={undefined}
color={participant.color}
conversationType="direct"
hasAvatar={participant.hasAvatar}
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}

View File

@@ -900,12 +900,14 @@ export function CallsList({
aria-selected={isSelected}
leading={
<Avatar
acceptedMessageRequest
avatarPlaceholderGradient={
conversation.avatarPlaceholderGradient
}
avatarUrl={conversation.avatarUrl}
color={conversation.color}
conversationType={conversation.type}
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={false}
title={conversation.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_SIX}

View File

@@ -217,11 +217,12 @@ export function CallsNewCall({
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPlaceholderGradient={
item.conversation.avatarPlaceholderGradient
}
avatarUrl={item.conversation.avatarUrl}
conversationType="group"
i18n={i18n}
isMe={false}
title={item.conversation.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}

View File

@@ -14,32 +14,31 @@ export type PropsType = {
} & Pick<
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPlaceholderGradient'
| 'avatarUrl'
| 'color'
| 'firstName'
| 'hasAvatar'
| 'id'
| 'isMe'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarUrl'
>;
export function ContactPill({
acceptedMessageRequest,
avatarPlaceholderGradient,
avatarUrl,
color,
firstName,
hasAvatar,
i18n,
isMe,
id,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarUrl,
onClickRemove,
}: PropsType): JSX.Element {
const removeLabel = i18n('icu:ContactPill--remove');
@@ -47,20 +46,19 @@ export function ContactPill({
return (
<div className="module-ContactPill">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={undefined}
color={color}
noteToSelf={false}
conversationType="direct"
hasAvatar={hasAvatar}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY}
unblurredAvatarUrl={unblurredAvatarUrl}
/>
<ContactName
firstName={firstName}

View File

@@ -38,6 +38,7 @@ const contactPillProps = (
getDefaultConversation({
avatarUrl: gifUrl,
firstName: 'John',
hasAvatar: true,
id: 'abc123',
isMe: false,
name: 'John Bon Bon Jovi',

View File

@@ -402,12 +402,14 @@ export function ConversationList({
break;
case RowType.Conversation: {
const itemProps = pick(row.conversation, [
'avatarPlaceholderGradient',
'acceptedMessageRequest',
'avatarUrl',
'badges',
'color',
'draftPreview',
'groupId',
'hasAvatar',
'id',
'isBlocked',
'isMe',
@@ -425,7 +427,6 @@ export function ConversationList({
'title',
'type',
'typingContactIdTimestamps',
'unblurredAvatarUrl',
'unreadCount',
'unreadMentionsCount',
'serviceId',

View File

@@ -51,19 +51,21 @@ export function DirectCallRemoteParticipant({
function renderAvatar(
i18n: LocalizerType,
{
acceptedMessageRequest,
avatarPlaceholderGradient,
avatarUrl,
color,
isMe,
hasAvatar,
phoneNumber,
profileName,
sharedGroupNames,
title,
}: Pick<
ConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'color'
| 'hasAvatar'
| 'isMe'
| 'phoneNumber'
| 'profileName'
@@ -75,14 +77,14 @@ function renderAvatar(
<div className="module-ongoing-call__remote-video-disabled">
<CallBackgroundBlur avatarUrl={avatarUrl}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
hasAvatar={hasAvatar}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}

View File

@@ -90,16 +90,16 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
} = props;
const {
acceptedMessageRequest,
avatarPlaceholderGradient,
addedTime,
avatarUrl,
color,
demuxId,
hasAvatar,
hasRemoteAudio,
hasRemoteVideo,
isHandRaised,
isBlocked,
isMe,
mediaKeysReceived,
profileName,
sharedGroupNames,
@@ -455,14 +455,14 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
} else {
noVideoNode = (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
hasAvatar={hasAvatar}
i18n={i18n}
isMe={isMe}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}

View File

@@ -109,16 +109,15 @@ function Contacts({
{contacts.map(contact => (
<li key={contact.id} className="module-GroupDialog__contacts__contact">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType={contact.type}
isMe={contact.isMe}
hasAvatar={contact.hasAvatar}
noteToSelf={contact.isMe}
theme={theme}
title={contact.title}
unblurredAvatarUrl={contact.unblurredAvatarUrl}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
i18n={i18n}

View File

@@ -69,14 +69,12 @@ export const GroupV2JoinDialog = React.memo(function GroupV2JoinDialogInner({
/>
<div className="module-group-v2-join-dialog__avatar">
<Avatar
acceptedMessageRequest={false}
avatarUrl={avatar ? avatar.url : undefined}
badge={undefined}
blur={AvatarBlur.NoBlur}
loading={avatar && !avatar.url}
conversationType="group"
title={title}
isMe={false}
sharedGroupNames={[]}
size={80}
i18n={i18n}

View File

@@ -193,10 +193,8 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
} = props;
const {
id: conversationId,
acceptedMessageRequest,
avatarUrl,
color,
isMe,
phoneNumber,
profileName,
sharedGroupNames,
@@ -274,14 +272,12 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
<div className="IncomingCallBar__conversation">
<div className="IncomingCallBar__conversation--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={undefined}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}

View File

@@ -174,18 +174,19 @@ export function LeftPaneSearchInput({
}}
>
<Avatar
acceptedMessageRequest={searchConversation.acceptedMessageRequest}
avatarPlaceholderGradient={
searchConversation.avatarPlaceholderGradient
}
avatarUrl={searchConversation.avatarUrl}
badge={undefined}
color={searchConversation.color}
conversationType={searchConversation.type}
hasAvatar={searchConversation.hasAvatar}
i18n={i18n}
isMe={searchConversation.isMe}
noteToSelf={searchConversation.isMe}
sharedGroupNames={searchConversation.sharedGroupNames}
size={AvatarSize.TWENTY}
title={searchConversation.title}
unblurredAvatarUrl={searchConversation.unblurredAvatarUrl}
/>
<button
aria-label={i18n('icu:clearSearch')}

View File

@@ -882,19 +882,18 @@ function LightboxHeader({
<div className="Lightbox__header--container">
<div className="Lightbox__header--avatar">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarUrl={conversation.avatarUrl}
badge={undefined}
color={conversation.color}
conversationType={conversation.type}
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={conversation.isMe}
phoneNumber={conversation.e164}
profileName={conversation.profileName}
sharedGroupNames={conversation.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
title={conversation.title}
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
/>
</div>
<div className="Lightbox__header--content">

View File

@@ -48,15 +48,7 @@ export function MyStoryButton({
? getNewestMyStory(myStories[0])
: undefined;
const {
acceptedMessageRequest,
avatarUrl,
color,
isMe,
profileName,
sharedGroupNames,
title,
} = me;
const { avatarUrl, color, profileName, sharedGroupNames, title } = me;
if (!newestStory) {
return (
@@ -69,13 +61,11 @@ export function MyStoryButton({
>
<div className="MyStories__avatar-container">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}
@@ -122,13 +112,11 @@ export function MyStoryButton({
onContextMenuShowingChanged={setActive}
>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}

View File

@@ -372,14 +372,12 @@ export function NavTabs({
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<Avatar
acceptedMessageRequest
avatarUrl={me.avatarUrl}
badge={badge}
className="module-main-header__avatar"
color={me.color}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}

View File

@@ -451,20 +451,19 @@ function ContactRow({
return (
<li className="module-SafetyNumberChangeDialog__row" key={contact.id}>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType="direct"
hasAvatar={contact.hasAvatar}
i18n={i18n}
isMe={contact.isMe}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
theme={theme}
title={contact.title}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarUrl={contact.unblurredAvatarUrl}
/>
<div className="module-SafetyNumberChangeDialog__row--wrapper">
<div className="module-SafetyNumberChangeDialog__row--name">

View File

@@ -557,13 +557,14 @@ export function SendStoryModal({
htmlFor={id}
>
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPlaceholderGradient={
group.avatarPlaceholderGradient
}
avatarUrl={group.avatarUrl}
badge={undefined}
color={group.color}
conversationType={group.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
title={group.title}
@@ -707,13 +708,11 @@ export function SendStoryModal({
>
{list.id === MY_STORY_ID ? (
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
storyRing={undefined}
@@ -822,13 +821,11 @@ export function SendStoryModal({
htmlFor={id}
>
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarUrl={group.avatarUrl}
badge={undefined}
color={group.color}
conversationType={group.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
storyRing={group.hasStories}

View File

@@ -160,13 +160,12 @@ function DistributionListItem({
<span className="StoriesSettingsModal__list__left">
{isMyStory ? (
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarPlaceholderGradient={me.avatarPlaceholderGradient}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
title={me.title}
@@ -214,13 +213,12 @@ function GroupStoryItem({
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={groupStory.acceptedMessageRequest}
avatarPlaceholderGradient={groupStory.avatarPlaceholderGradient}
avatarUrl={groupStory.avatarUrl}
badge={undefined}
color={groupStory.color}
conversationType={groupStory.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
title={groupStory.title}
@@ -675,13 +673,12 @@ export function DistributionListSettingsModal({
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={member.acceptedMessageRequest}
avatarPlaceholderGradient={member.avatarPlaceholderGradient}
avatarUrl={member.avatarUrl}
badge={getPreferredBadge(member.badges)}
color={member.color}
conversationType={member.type}
i18n={i18n}
isMe
sharedGroupNames={member.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
theme={theme}
@@ -1094,13 +1091,12 @@ export function EditDistributionListModal({
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType={contact.type}
i18n={i18n}
isMe
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
theme={theme}
@@ -1190,10 +1186,10 @@ export function EditDistributionListModal({
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarUrl={contact.avatarUrl}
color={contact.color}
firstName={contact.firstName}
hasAvatar={contact.hasAvatar}
i18n={i18n}
id={contact.id}
isMe={contact.isMe}
@@ -1287,13 +1283,12 @@ export function GroupStorySettingsModal({
>
<div className="GroupStorySettingsModal__header">
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPlaceholderGradient={group.avatarPlaceholderGradient}
avatarUrl={group.avatarUrl}
badge={undefined}
color={group.color}
conversationType={group.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
title={group.title}
@@ -1316,13 +1311,12 @@ export function GroupStorySettingsModal({
className="GroupStorySettingsModal__members_item"
>
<Avatar
acceptedMessageRequest={member.acceptedMessageRequest}
avatarPlaceholderGradient={member.avatarPlaceholderGradient}
avatarUrl={member.avatarUrl}
badge={undefined}
color={member.color}
conversationType={member.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
title={member.title}

View File

@@ -128,20 +128,17 @@ export function StoryDetailsModal({
return (
<div key={contact.id} className="StoryDetailsModal__contact">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType="direct"
i18n={i18n}
isMe={contact.isMe}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
theme={ThemeType.dark}
title={contact.title}
unblurredAvatarUrl={contact.unblurredAvatarUrl}
/>
<div className="StoryDetailsModal__contact__text">
<ContactName title={contact.title} />
@@ -171,13 +168,12 @@ export function StoryDetailsModal({
</div>
<div className="StoryDetailsModal__contact">
<Avatar
acceptedMessageRequest={sender.acceptedMessageRequest}
avatarPlaceholderGradient={sender.avatarPlaceholderGradient}
avatarUrl={sender.avatarUrl}
badge={getPreferredBadge(sender.badges)}
color={sender.color}
conversationType="direct"
i18n={i18n}
isMe={sender.isMe}
profileName={sender.profileName}
sharedGroupNames={sender.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}

View File

@@ -34,21 +34,20 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
};
function StoryListItemAvatar({
acceptedMessageRequest,
avatarPlaceholderGradient,
avatarUrl,
avatarStoryRing,
badges,
color,
getPreferredBadge,
i18n,
isMe,
profileName,
sharedGroupNames,
title,
theme,
}: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPlaceholderGradient'
| 'avatarUrl'
| 'color'
| 'profileName'
@@ -59,18 +58,16 @@ function StoryListItemAvatar({
badges?: ConversationType['badges'];
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isMe?: boolean;
theme: ThemeType;
}): JSX.Element {
return (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={badges ? getPreferredBadge(badges) : undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}

View File

@@ -201,7 +201,6 @@ export function StoryViewer({
timestamp,
} = story;
const {
acceptedMessageRequest,
avatarUrl,
color,
isMe,
@@ -737,13 +736,11 @@ export function StoryViewer({
<div className="StoryViewer__meta__playback-bar">
<div className="StoryViewer__meta__playback-bar__container">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
@@ -751,14 +748,12 @@ export function StoryViewer({
/>
{group && (
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarUrl={group.avatarUrl}
badge={undefined}
className="StoryViewer__meta--group-avatar"
color={getAvatarColor(group.color)}
conversationType="group"
i18n={i18n}
isMe={false}
profileName={group.profileName}
sharedGroupNames={group.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}

View File

@@ -382,13 +382,11 @@ export function StoryViewsNRepliesModal({
>
<div>
<Avatar
acceptedMessageRequest={view.recipient.acceptedMessageRequest}
avatarUrl={view.recipient.avatarUrl}
badge={undefined}
color={getAvatarColor(view.recipient.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(view.recipient.isMe)}
profileName={view.recipient.profileName}
sharedGroupNames={view.recipient.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
@@ -564,13 +562,11 @@ function ReplyOrReactionMessage({
>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.author.acceptedMessageRequest}
avatarUrl={reply.author.avatarUrl}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.author.isMe)}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}

View File

@@ -68,7 +68,8 @@ export default {
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'),
updateSharedGroups: action('updateSharedGroups'),
unblurAvatar: action('unblurAvatar'),
startAvatarDownload: action('startAvatarDownload'),
pendingAvatarDownload: false,
conversation,
fromOrAddedByTrustedContact: false,
isSignalConnection: false,

View File

@@ -1,11 +1,10 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { type ReactNode, useCallback, useEffect } from 'react';
import React, { type ReactNode, useCallback, useEffect, useMemo } from 'react';
import type { ConversationType } from '../../state/ducks/conversations';
import type { LocalizerType } from '../../types/Util';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
import { Avatar, AvatarBlur, AvatarSize } from '../Avatar';
import { Modal } from '../Modal';
import { UserText } from '../UserText';
@@ -28,11 +27,12 @@ export type PropsType = Readonly<{
conversation: ConversationType;
fromOrAddedByTrustedContact?: boolean;
isSignalConnection: boolean;
pendingAvatarDownload?: boolean;
startAvatarDownload?: (id: string) => unknown;
toggleSignalConnectionsModal: () => void;
toggleSafetyNumberModal: (id: string) => void;
toggleProfileNameWarningModal: () => void;
updateSharedGroups: (id: string) => void;
unblurAvatar: (conversationId: string) => void;
}>;
export function AboutContactModal({
@@ -40,30 +40,44 @@ export function AboutContactModal({
conversation,
fromOrAddedByTrustedContact,
isSignalConnection,
pendingAvatarDownload,
startAvatarDownload,
toggleSignalConnectionsModal,
toggleSafetyNumberModal,
toggleProfileNameWarningModal,
updateSharedGroups,
unblurAvatar,
onClose,
onOpenNotePreviewModal,
}: PropsType): JSX.Element {
const { isMe } = conversation;
const { avatarUrl, hasAvatar, isMe } = conversation;
useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups(conversation.id);
}, [conversation.id, updateSharedGroups]);
const avatarBlur = shouldBlurAvatar(conversation)
// If hasAvatar is true, we show the download button instead of blur
const enableClickToLoad = !avatarUrl && !isMe && hasAvatar;
const avatarBlur = enableClickToLoad
? AvatarBlur.BlurPictureWithClickToView
: AvatarBlur.NoBlur;
const onAvatarClick = useCallback(() => {
if (avatarBlur === AvatarBlur.BlurPictureWithClickToView) {
unblurAvatar(conversation.id);
const avatarOnClick = useMemo(() => {
if (!enableClickToLoad) {
return undefined;
}
}, [avatarBlur, unblurAvatar, conversation.id]);
return () => {
if (!pendingAvatarDownload && startAvatarDownload) {
startAvatarDownload(conversation.id);
}
};
}, [
conversation.id,
startAvatarDownload,
enableClickToLoad,
pendingAvatarDownload,
]);
const onSignalConnectionClick = useCallback(
(ev: React.MouseEvent) => {
@@ -131,20 +145,20 @@ export function AboutContactModal({
>
<div className="AboutContactModal__row AboutContactModal__row--centered">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarUrl={conversation.avatarUrl}
blur={avatarBlur}
onClick={avatarBlur === AvatarBlur.NoBlur ? undefined : onAvatarClick}
onClick={avatarOnClick}
badge={undefined}
color={conversation.color}
conversationType="direct"
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={conversation.isMe}
loading={pendingAvatarDownload && !conversation.avatarUrl}
profileName={conversation.profileName}
sharedGroupNames={[]}
size={AvatarSize.TWO_HUNDRED_SIXTEEN}
title={conversation.title}
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
/>
</div>

View File

@@ -53,6 +53,7 @@ export default {
),
removeMemberFromGroup: action('removeMemberFromGroup'),
showConversation: action('showConversation'),
startAvatarDownload: action('startAvatarDownload'),
theme: ThemeType.light,
toggleAboutContactModal: action('AboutContactModal'),
toggleAdmin: action('toggleAdmin'),

View File

@@ -14,7 +14,7 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories';
import { StoryViewModeType } from '../../types/Stories';
import * as log from '../../logging/log';
import { Avatar, AvatarSize } from '../Avatar';
import { Avatar, AvatarBlur, AvatarSize } from '../Avatar';
import { AvatarLightbox } from '../AvatarLightbox';
import { BadgeDialog } from '../BadgeDialog';
import { ConfirmationDialog } from '../ConfirmationDialog';
@@ -55,6 +55,7 @@ type PropsActionType = {
onOutgoingVideoCallInConversation: (conversationId: string) => unknown;
removeMemberFromGroup: (conversationId: string, contactId: string) => void;
showConversation: ShowConversationType;
startAvatarDownload: () => void;
toggleAdmin: (conversationId: string, contactId: string) => void;
toggleAboutContactModal: (conversationId: string) => unknown;
togglePip: () => void;
@@ -98,6 +99,7 @@ export function ContactModal({
onOutgoingVideoCallInConversation,
removeMemberFromGroup,
showConversation,
startAvatarDownload,
theme,
toggleAboutContactModal,
toggleAddUserToAnotherGroupModal,
@@ -310,13 +312,18 @@ export function ContactModal({
>
<div className="ContactModal">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
badge={preferredBadge}
blur={
!contact.avatarUrl && !contact.isMe && contact.hasAvatar
? AvatarBlur.BlurPictureWithClickToView
: AvatarBlur.NoBlur
}
color={contact.color}
conversationType="direct"
hasAvatar={contact.hasAvatar}
i18n={i18n}
isMe={contact.isMe}
onClick={() => {
if (conversation && hasStories) {
viewUserStories({
@@ -324,6 +331,12 @@ export function ContactModal({
storyViewMode: StoryViewModeType.User,
});
hideContactModal();
} else if (
!contact.avatarUrl &&
!contact.isMe &&
contact.hasAvatar
) {
startAvatarDownload();
} else {
setView(ContactModalView.ShowingAvatar);
}
@@ -335,7 +348,6 @@ export function ContactModal({
storyRing={hasStories}
theme={theme}
title={contact.title}
unblurredAvatarUrl={contact.unblurredAvatarUrl}
/>
<button
type="button"
@@ -468,9 +480,11 @@ export function ContactModal({
case ContactModalView.ShowingAvatar:
return (
<AvatarLightbox
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarColor={contact.color}
avatarUrl={contact.avatarUrl}
conversationTitle={contact.title}
hasAvatar={contact.hasAvatar}
i18n={i18n}
onClose={() => setView(ContactModalView.Default)}
/>

View File

@@ -460,13 +460,17 @@ function HeaderContent({
const avatar = (
<span className="module-ConversationHeader__header__avatar">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPlaceholderGradient={
conversation.gradientStart && conversation.gradientEnd
? [conversation.gradientStart, conversation.gradientEnd]
: undefined
}
avatarUrl={conversation.avatarUrl ?? undefined}
badge={badge ?? undefined}
color={conversation.color ?? undefined}
conversationType={conversation.type}
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isMe={conversation.isMe}
noteToSelf={conversation.isMe}
onClick={hasStories ? onViewUserStories : onClick}
phoneNumber={conversation.phoneNumber ?? undefined}
@@ -477,7 +481,6 @@ function HeaderContent({
storyRing={conversation.isMe ? undefined : (hasStories ?? undefined)}
theme={theme}
title={conversation.title}
unblurredAvatarUrl={conversation.unblurredAvatarUrl ?? undefined}
/>
</span>
);

View File

@@ -23,12 +23,13 @@ export default {
i18n,
isDirectConvoAndHasNickname: false,
theme: ThemeType.light,
unblurAvatar: action('unblurAvatar'),
updateSharedGroups: action('updateSharedGroups'),
viewUserStories: action('viewUserStories'),
toggleAboutContactModal: action('toggleAboutContactModal'),
toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'),
openConversationDetails: action('openConversationDetails'),
startAvatarDownload: action('startAvatarDownload'),
pendingAvatarDownload: false,
},
} satisfies Meta<Props>;

View File

@@ -13,7 +13,6 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
import type { HasStories } from '../../types/Stories';
import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories';
import { StoryViewModeType } from '../../types/Stories';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
import { Button, ButtonVariant } from '../Button';
import { SafetyTipsModal } from '../SafetyTipsModal';
import { I18n } from '../I18n';
@@ -23,6 +22,7 @@ export type Props = {
acceptedMessageRequest?: boolean;
fromOrAddedByTrustedContact?: boolean;
groupDescription?: string;
hasAvatar?: boolean;
hasStories?: HasStories;
id: string;
i18n: LocalizerType;
@@ -31,10 +31,10 @@ export type Props = {
isSignalConversation?: boolean;
membersCount?: number;
openConversationDetails?: () => unknown;
pendingAvatarDownload?: boolean;
phoneNumber?: string;
sharedGroupNames?: ReadonlyArray<string>;
unblurAvatar: (conversationId: string) => void;
unblurredAvatarUrl?: string;
startAvatarDownload: () => void;
updateSharedGroups: (conversationId: string) => unknown;
theme: ThemeType;
viewUserStories: ViewUserStoriesActionCreatorType;
@@ -57,6 +57,7 @@ const renderExtraInformation = ({
sharedGroupNames,
}: Pick<
Props,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'conversationType'
| 'fromOrAddedByTrustedContact'
@@ -227,6 +228,7 @@ function ReleaseNotesExtraInformation({
}
export function ConversationHero({
avatarPlaceholderGradient,
i18n,
about,
acceptedMessageRequest,
@@ -236,6 +238,7 @@ export function ConversationHero({
conversationType,
fromOrAddedByTrustedContact,
groupDescription,
hasAvatar,
hasStories,
id,
isDirectConvoAndHasNickname,
@@ -243,13 +246,13 @@ export function ConversationHero({
openConversationDetails,
isSignalConversation,
membersCount,
pendingAvatarDownload,
sharedGroupNames = [],
phoneNumber,
profileName,
startAvatarDownload,
theme,
title,
unblurAvatar,
unblurredAvatarUrl,
updateSharedGroups,
viewUserStories,
toggleAboutContactModal,
@@ -264,17 +267,14 @@ export function ConversationHero({
let avatarBlur: AvatarBlur = AvatarBlur.NoBlur;
let avatarOnClick: undefined | (() => void);
if (
shouldBlurAvatar({
acceptedMessageRequest,
avatarUrl,
isMe,
sharedGroupNames,
unblurredAvatarUrl,
})
) {
if (!avatarUrl && !isMe && hasAvatar) {
avatarBlur = AvatarBlur.BlurPictureWithClickToView;
avatarOnClick = () => unblurAvatar(id);
avatarOnClick = () => {
if (!pendingAvatarDownload) {
startAvatarDownload();
}
};
} else if (hasStories) {
avatarOnClick = () => {
viewUserStories({
@@ -312,7 +312,7 @@ export function ConversationHero({
<>
<div className="module-conversation-hero">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={badge}
blur={avatarBlur}
@@ -320,7 +320,8 @@ export function ConversationHero({
color={color}
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
hasAvatar={hasAvatar}
loading={pendingAvatarDownload && !avatarUrl}
noteToSelf={isMe}
onClick={avatarOnClick}
profileName={profileName}

View File

@@ -279,18 +279,19 @@ export type PropsData = {
contact?: ReadonlyDeep<EmbeddedContactForUIType>;
author: Pick<
ConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'badges'
| 'color'
| 'firstName'
| 'hasAvatar'
| 'id'
| 'isMe'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarUrl'
>;
conversationType: ConversationTypeType;
attachments?: ReadonlyArray<AttachmentForUIType>;
@@ -1578,12 +1579,10 @@ export class Message extends React.PureComponent<Props, State> {
{first.isCallLink && (
<div className="module-message__link-preview__call-link-icon">
<Avatar
acceptedMessageRequest
badge={undefined}
color={getColorForCallLink(getKeyFromCallLink(first.url))}
conversationType="callLink"
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={64}
title={title ?? i18n('icu:calling__call-link-default-title')}
@@ -2173,13 +2172,11 @@ export class Message extends React.PureComponent<Props, State> {
<AvatarSpacer size={GROUP_AVATAR_SIZE} />
) : (
<Avatar
acceptedMessageRequest={author.acceptedMessageRequest}
avatarUrl={author.avatarUrl}
badge={getPreferredBadge(author.badges)}
color={author.color}
conversationType="direct"
i18n={i18n}
isMe={author.isMe}
onClick={event => {
event.stopPropagation();
event.preventDefault();
@@ -2192,7 +2189,6 @@ export class Message extends React.PureComponent<Props, State> {
size={GROUP_AVATAR_SIZE}
theme={theme}
title={author.title}
unblurredAvatarUrl={author.unblurredAvatarUrl}
/>
)}
</div>

View File

@@ -49,7 +49,6 @@ export type Contact = Pick<
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarUrl'
> & {
status?: SendStatus;
statusTimestamp?: number;
@@ -168,34 +167,28 @@ export function MessageDetail({
function renderAvatar(contact: Contact): JSX.Element {
const {
acceptedMessageRequest,
avatarUrl,
badges,
color,
isMe,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarUrl,
} = contact;
return (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={getPreferredBadge(badges)}
color={color}
conversationType="direct"
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarUrl={unblurredAvatarUrl}
/>
);
}

View File

@@ -257,13 +257,11 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
>
<div className="module-reaction-viewer__body__row__avatar">
<Avatar
acceptedMessageRequest={from.acceptedMessageRequest}
avatarUrl={from.avatarUrl}
badge={getPreferredBadge(from.badges)}
conversationType="direct"
sharedGroupNames={from.sharedGroupNames}
size={32}
isMe={from.isMe}
color={from.color}
profileName={from.profileName}
phoneNumber={from.phoneNumber}

View File

@@ -124,12 +124,10 @@ export function Thumbnail({
return (
<div className={getClassName('__icon-container-call-link')}>
<Avatar
acceptedMessageRequest
badge={undefined}
color={getColorForCallLink(getKeyFromCallLink(url))}
conversationType="callLink"
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={64}
title={title ?? i18n('icu:calling__call-link-default-title')}

View File

@@ -334,8 +334,6 @@ const actions = () => ({
closeContactSpoofingReview: action('closeContactSpoofingReview'),
reviewConversationNameCollision: action('reviewConversationNameCollision'),
unblurAvatar: action('unblurAvatar'),
peekGroupCallForTheFirstTime: action('peekGroupCallForTheFirstTime'),
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
@@ -346,6 +344,8 @@ const actions = () => ({
onOpenMessageRequestActionsConfirmation: action(
'onOpenMessageRequestActionsConfirmation'
),
startAvatarDownload: action('startAvatarDownload'),
});
const renderItem = ({
@@ -416,7 +416,8 @@ const renderHeroRow = () => {
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
theme={theme}
title={getTitle()}
unblurAvatar={action('unblurAvatar')}
startAvatarDownload={action('startAvatarDownload')}
pendingAvatarDownload={false}
updateSharedGroups={noop}
viewUserStories={action('viewUserStories')}
toggleAboutContactModal={action('toggleAboutContactModal')}

View File

@@ -123,13 +123,11 @@ function TypingBubbleAvatar({
return (
<animated.div className="module-message__typing-avatar" style={springProps}>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarUrl={contact.avatarUrl}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType="direct"
i18n={i18n}
isMe={contact.isMe}
onClick={event => {
event.stopPropagation();
event.preventDefault();

View File

@@ -34,14 +34,12 @@ export function renderAvatar({
const renderAttachmentDownloaded = () => (
<Avatar
acceptedMessageRequest={false}
avatarUrl={avatarUrl}
badge={undefined}
blur={AvatarBlur.NoBlur}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
isMe
title={title}
sharedGroupNames={[]}
size={size}

View File

@@ -411,10 +411,11 @@ export function ChooseGroupMembersModal({
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
color={contact.color}
firstName={contact.systemGivenName ?? contact.firstName}
hasAvatar={contact.hasAvatar}
i18n={i18n}
isMe={contact.isMe}
id={contact.id}

View File

@@ -87,6 +87,7 @@ const createProps = (
pushPanelForConversation: action('pushPanelForConversation'),
showConversation: action('showConversation'),
showLightbox: action('showLightbox'),
startAvatarDownload: action('startAvatarDownload'),
updateGroupAttributes: async () => {
action('updateGroupAttributes')();
},

View File

@@ -94,8 +94,10 @@ export type StateProps = {
maxRecommendedGroupSize: number;
memberships: ReadonlyArray<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingAvatarDownload?: boolean;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
selectedNavTab: NavTab;
startAvatarDownload: () => void;
theme: ThemeType;
userAvatarData: ReadonlyArray<AvatarDataType>;
renderChooseGroupMembersModal: (
@@ -197,6 +199,7 @@ export function ConversationDetails({
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
pendingApprovalMemberships,
pendingAvatarDownload,
pendingMemberships,
pushPanelForConversation,
renderChooseGroupMembersModal,
@@ -210,6 +213,7 @@ export function ConversationDetails({
showContactModal,
showConversation,
showLightbox,
startAvatarDownload,
theme,
toggleAboutContactModal,
toggleSafetyNumberModal,
@@ -410,6 +414,8 @@ export function ConversationDetails({
isGroup={isGroup}
isSignalConversation={isSignalConversation}
membersCount={conversation.membersCount ?? null}
pendingAvatarDownload={pendingAvatarDownload ?? false}
startAvatarDownload={startAvatarDownload}
startEditing={(isGroupTitle: boolean) => {
setModalState(
isGroupTitle

View File

@@ -43,6 +43,8 @@ function Wrapper(overrideProps: Partial<Props>) {
isGroup
isMe={false}
isSignalConversation={false}
pendingAvatarDownload={false}
startAvatarDownload={action('startAvatarDownload')}
theme={theme}
toggleAboutContactModal={action('toggleAboutContactModal')}
{...overrideProps}

View File

@@ -4,7 +4,7 @@
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import { Avatar, AvatarSize } from '../../Avatar';
import { Avatar, AvatarBlur, AvatarSize } from '../../Avatar';
import { AvatarLightbox } from '../../AvatarLightbox';
import type { ConversationType } from '../../../state/ducks/conversations';
import { GroupDescription } from '../GroupDescription';
@@ -27,6 +27,8 @@ export type Props = {
isMe: boolean;
isSignalConversation: boolean;
membersCount: number | null;
pendingAvatarDownload: boolean;
startAvatarDownload: () => void;
startEditing: (isGroupTitle: boolean) => void;
toggleAboutContactModal: (contactId: string) => void;
theme: ThemeType;
@@ -47,6 +49,8 @@ export function ConversationDetailsHeader({
isMe,
isSignalConversation,
membersCount,
pendingAvatarDownload,
startAvatarDownload,
startEditing,
toggleAboutContactModal,
theme,
@@ -84,8 +88,15 @@ export function ConversationDetailsHeader({
preferredBadge = badges?.[0];
}
const shouldShowClickToView =
!conversation.avatarUrl && !isMe && conversation.hasAvatar;
const avatarBlur = shouldShowClickToView
? AvatarBlur.BlurPictureWithClickToView
: AvatarBlur.NoBlur;
const avatar = (
<Avatar
blur={avatarBlur}
badge={preferredBadge}
conversationType={conversation.type}
i18n={i18n}
@@ -93,9 +104,18 @@ export function ConversationDetailsHeader({
{...conversation}
noteToSelf={isMe}
onClick={() => {
if (shouldShowClickToView) {
startAvatarDownload();
return;
}
setActiveModal(ConversationDetailsHeaderActiveModal.ShowingAvatar);
}}
loading={pendingAvatarDownload}
onClickBadge={() => {
if (shouldShowClickToView) {
startAvatarDownload();
return;
}
setActiveModal(ConversationDetailsHeaderActiveModal.ShowingBadges);
}}
sharedGroupNames={[]}
@@ -108,9 +128,11 @@ export function ConversationDetailsHeader({
case ConversationDetailsHeaderActiveModal.ShowingAvatar:
modal = (
<AvatarLightbox
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarColor={conversation.color}
avatarUrl={conversation.avatarUrl}
conversationTitle={conversation.title}
hasAvatar={conversation.hasAvatar}
i18n={i18n}
isGroup={isGroup}
noteToSelf={isMe}

View File

@@ -58,17 +58,17 @@ type PropsType = {
testId?: string;
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPlaceholderGradient'
| 'avatarUrl'
| 'color'
| 'groupId'
| 'hasAvatar'
| 'isMe'
| 'markedUnread'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarUrl'
| 'serviceId'
> &
(
@@ -79,7 +79,7 @@ type PropsType = {
export const BaseConversationListItem: FunctionComponent<PropsType> =
React.memo(function BaseConversationListItem(props) {
const {
acceptedMessageRequest,
avatarPlaceholderGradient,
avatarUrl,
avatarSize,
buttonAriaLabel,
@@ -88,6 +88,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
conversationType,
disabled,
groupId,
hasAvatar,
headerDate,
headerName,
i18n,
@@ -108,7 +109,6 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
shouldShowSpinner,
testId: overrideTestId,
title,
unblurredAvatarUrl,
unreadCount,
unreadMentionsCount,
serviceId,
@@ -195,20 +195,19 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
<>
<div className={AVATAR_CONTAINER_CLASS_NAME}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
color={color}
conversationType={conversationType}
hasAvatar={hasAvatar}
noteToSelf={isAvatarNoteToSelf}
searchResult={isUsernameSearchResult}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={avatarSize ?? AvatarSize.FORTY_EIGHT}
unblurredAvatarUrl={unblurredAvatarUrl}
// This is here to appease the type checker.
{...(props.badge
? { badge: props.badge, theme: props.theme }

View File

@@ -37,7 +37,6 @@ export type PropsDataType = {
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
| 'serviceId'
>;
@@ -55,7 +54,6 @@ type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
function ContactCheckbox({
about,
acceptedMessageRequest,
avatarUrl,
badge,
color,
@@ -71,7 +69,6 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
theme,
title,
type,
unblurredAvatarUrl,
}) {
const disabled = Boolean(disabledReason);
@@ -103,19 +100,16 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
isChecked={isChecked}
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarUrl={unblurredAvatarUrl}
// appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>

View File

@@ -37,7 +37,6 @@ export type ContactListItemConversationType = Pick<
| 'systemFamilyName'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
| 'username'
| 'e164'
| 'serviceId'
@@ -63,7 +62,6 @@ type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactListItem: FunctionComponent<PropsType> = React.memo(
function ContactListItem({
about,
acceptedMessageRequest,
avatarUrl,
badge,
color,
@@ -85,7 +83,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
theme,
title,
type,
unblurredAvatarUrl,
serviceId,
}) {
const [isConfirmingBlocking, setConfirmingBlocking] = useState(false);
@@ -264,19 +261,16 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
moduleClassName="ContactListItem"
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarUrl={unblurredAvatarUrl}
// This is here to appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>

View File

@@ -38,12 +38,14 @@ export type MessageStatusType = (typeof MessageStatuses)[number];
export type PropsData = Pick<
ConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'badges'
| 'color'
| 'draftPreview'
| 'groupId'
| 'hasAvatar'
| 'id'
| 'isBlocked'
| 'isMe'
@@ -62,7 +64,6 @@ export type PropsData = Pick<
| 'title'
| 'type'
| 'typingContactIdTimestamps'
| 'unblurredAvatarUrl'
| 'unreadCount'
| 'unreadMentionsCount'
| 'serviceId'
@@ -82,6 +83,7 @@ export type Props = PropsData & PropsHousekeeping;
export const ConversationListItem: FunctionComponent<Props> = React.memo(
function ConversationListItem({
avatarPlaceholderGradient,
acceptedMessageRequest,
avatarUrl,
badge,
@@ -89,6 +91,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
color,
draftPreview,
groupId,
hasAvatar,
i18n,
id,
isBlocked,
@@ -109,7 +112,6 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
title,
type,
typingContactIdTimestamps,
unblurredAvatarUrl,
unreadCount,
unreadMentionsCount,
serviceId,
@@ -211,13 +213,14 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={badge}
buttonAriaLabel={buttonAriaLabel}
color={color}
conversationType={type}
groupId={groupId}
hasAvatar={hasAvatar}
headerDate={lastUpdated}
headerName={headerName}
i18n={i18n}
@@ -237,7 +240,6 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
title={title}
unreadCount={unreadCount}
unreadMentionsCount={unreadMentionsCount}
unblurredAvatarUrl={unblurredAvatarUrl}
serviceId={serviceId}
/>
);

View File

@@ -16,7 +16,7 @@ export enum DisabledReason {
export type GroupListItemConversationType = Pick<
ConversationType,
'id' | 'title' | 'avatarUrl'
'avatarPlaceholderGradient' | 'id' | 'title' | 'avatarUrl' | 'hasAvatar'
> & {
disabledReason: DisabledReason | undefined;
membersCount: number;
@@ -55,11 +55,11 @@ export function GroupListItem({
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPlaceholderGradient={group.avatarPlaceholderGradient}
avatarUrl={group.avatarUrl}
conversationType="group"
hasAvatar={group.hasAvatar}
i18n={i18n}
isMe={false}
title={group.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}

View File

@@ -48,7 +48,6 @@ export type PropsDataType = {
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
>;
to: Pick<
@@ -183,7 +182,6 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={from.acceptedMessageRequest}
avatarUrl={from.avatarUrl}
badge={getPreferredBadge(from.badges)}
color={from.color}
@@ -192,8 +190,8 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
headerName={headerName}
i18n={i18n}
id={id}
isNoteToSelf={isNoteToSelf}
isMe={from.isMe}
isNoteToSelf={isNoteToSelf}
isSelected={false}
messageText={messageText}
onClick={onClickItem}
@@ -202,7 +200,6 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
sharedGroupNames={from.sharedGroupNames}
theme={theme}
title={from.title}
unblurredAvatarUrl={from.unblurredAvatarUrl}
/>
);
}

View File

@@ -93,11 +93,9 @@ export const PhoneNumberCheckbox: FunctionComponent<PropsType> = React.memo(
const avatar = (
<Avatar
acceptedMessageRequest={false}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
isMe={false}
phoneNumber={phoneNumber.userInput}
title={phoneNumber.userInput}
sharedGroupNames={[]}

View File

@@ -92,11 +92,9 @@ export const StartNewConversation: FunctionComponent<Props> = React.memo(
<ListTile
leading={
<Avatar
acceptedMessageRequest={false}
conversationType="direct"
searchResult
i18n={i18n}
isMe={false}
title={phoneNumber.userInput}
size={AvatarSize.THIRTY_TWO}
badge={undefined}

View File

@@ -66,12 +66,10 @@ export const UsernameCheckbox: FunctionComponent<PropsType> = React.memo(
const avatar = (
<Avatar
acceptedMessageRequest={false}
color={AvatarColors[0]}
conversationType="direct"
searchResult
i18n={i18n}
isMe={false}
title={title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}

View File

@@ -61,11 +61,9 @@ export function UsernameSearchResultListItem({
<ListTile
leading={
<Avatar
acceptedMessageRequest={false}
conversationType="direct"
searchResult
i18n={i18n}
isMe={false}
title={username}
size={AvatarSize.THIRTY_TWO}
badge={undefined}

View File

@@ -214,11 +214,12 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
<ContactPills>
{this.#selectedContacts.map(contact => (
<ContactPill
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarUrl={contact.avatarUrl}
color={contact.color}
firstName={contact.systemGivenName ?? contact.firstName}
hasAvatar={contact.hasAvatar}
i18n={i18n}
id={contact.id}
isMe={contact.isMe}

View File

@@ -104,6 +104,8 @@ import { getProfile } from './util/getProfile';
import { generateMessageId } from './util/generateMessageId';
import { postSaveUpdates } from './util/cleanup';
import { MessageModel } from './models/messages';
import { areWePending } from './util/groupMembershipUtils';
import { isConversationAccepted } from './util/isConversationAccepted';
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
@@ -3792,11 +3794,11 @@ async function updateGroupViaPreJoinInfo({
newAttributes = {
...newAttributes,
...(await applyNewAvatar(
dropNull(preJoinInfo.avatar),
newAttributes,
logId
)),
...(await applyNewAvatar({
newAvatarUrl: dropNull(preJoinInfo.avatar),
attributes: newAttributes,
logId,
})),
};
return {
@@ -5441,7 +5443,11 @@ async function applyGroupChange({
const { avatar } = actions.modifyAvatar;
result = {
...result,
...(await applyNewAvatar(dropNull(avatar), result, logId)),
...(await applyNewAvatar({
newAvatarUrl: dropNull(avatar),
attributes: result,
logId,
})),
};
}
@@ -5721,13 +5727,23 @@ export async function decryptGroupAvatar(
// Overwriting result.avatar as part of functionality
export async function applyNewAvatar(
newAvatarUrl: string | undefined,
attributes: Readonly<
Pick<ConversationAttributesType, 'avatar' | 'secretParams'>
>,
logId: string
): Promise<Pick<ConversationAttributesType, 'avatar'>> {
const result: Pick<ConversationAttributesType, 'avatar'> = {};
options:
| {
newAvatarUrl?: string | undefined;
attributes: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>;
logId: string;
forceDownload: true;
}
| {
newAvatarUrl?: string | undefined;
attributes: ConversationAttributesType;
logId: string;
forceDownload?: false | undefined;
}
): Promise<Pick<ConversationAttributesType, 'avatar' | 'remoteAvatarUrl'>> {
const { newAvatarUrl, attributes, logId, forceDownload } = options;
const result: Pick<ConversationAttributesType, 'avatar' | 'remoteAvatarUrl'> =
{};
try {
// Avatar has been dropped
if (!newAvatarUrl && attributes.avatar) {
@@ -5739,19 +5755,34 @@ export async function applyNewAvatar(
result.avatar = undefined;
}
const avatarUrlToUse =
newAvatarUrl ||
('remoteAvatarUrl' in attributes
? attributes.remoteAvatarUrl
: undefined);
// Group has avatar; has it changed?
if (
newAvatarUrl &&
(!attributes.avatar?.path || attributes.avatar.url !== newAvatarUrl)
avatarUrlToUse &&
(!attributes.avatar?.path || attributes.avatar.url !== avatarUrlToUse)
) {
if (!attributes.secretParams) {
throw new Error('applyNewAvatar: group was missing secretParams!');
}
const data = await decryptGroupAvatar(
newAvatarUrl,
if (
!forceDownload &&
(areWePending(attributes) || !isConversationAccepted(attributes))
) {
result.remoteAvatarUrl = avatarUrlToUse;
return result;
}
const data: Uint8Array = await decryptGroupAvatar(
avatarUrlToUse,
attributes.secretParams
);
const hash = computeHash(data);
if (attributes.avatar?.hash === hash) {
@@ -5760,7 +5791,7 @@ export async function applyNewAvatar(
);
result.avatar = {
...attributes.avatar,
url: newAvatarUrl,
url: avatarUrlToUse,
};
return result;
}
@@ -5770,10 +5801,9 @@ export async function applyNewAvatar(
attributes.avatar.path
);
}
const local = await window.Signal.Migrations.writeNewAttachmentData(data);
result.avatar = {
url: newAvatarUrl,
url: avatarUrlToUse,
...local,
hash,
};
@@ -5871,7 +5901,11 @@ async function applyGroupState({
// avatar
result = {
...result,
...(await applyNewAvatar(dropNull(groupState.avatar), result, logId)),
...(await applyNewAvatar({
newAvatarUrl: dropNull(groupState.avatar),
attributes: result,
logId,
})),
};
// disappearingMessagesTimer

View File

@@ -415,7 +415,12 @@ export async function joinViaLink(value: string): Promise<void> {
secretParams,
};
try {
const patch = await applyNewAvatar(result.avatar, attributes, logId);
const patch = await applyNewAvatar({
newAvatarUrl: result.avatar,
attributes,
logId,
forceDownload: true,
});
attributes = { ...attributes, ...patch };
if (attributes.avatar && attributes.avatar.path) {

View File

@@ -28,6 +28,7 @@ export type MinimalConversation = Satisfies<
| 'color'
| 'expireTimer'
| 'groupVersion'
| 'hasAvatar'
| 'id'
| 'isArchived'
| 'isBlocked'
@@ -43,8 +44,10 @@ export type MinimalConversation = Satisfies<
| 'profileName'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
>
> & {
gradientStart?: string;
gradientEnd?: string;
}
>;
export function useMinimalConversation(
@@ -59,6 +62,7 @@ export function useMinimalConversation(
color,
expireTimer,
groupVersion,
hasAvatar,
id,
isArchived,
isBlocked,
@@ -74,10 +78,13 @@ export function useMinimalConversation(
profileName,
title,
type,
unblurredAvatarUrl,
} = conversation;
return useMemo(() => {
const gradientStart = conversation.avatarPlaceholderGradient?.[0];
const gradientEnd = conversation.avatarPlaceholderGradient?.[1];
return useMemo((): MinimalConversation => {
return {
gradientStart,
gradientEnd,
acceptedMessageRequest,
announcementsOnly,
areWeAdmin,
@@ -86,6 +93,7 @@ export function useMinimalConversation(
color,
expireTimer,
groupVersion,
hasAvatar,
id,
isArchived,
isBlocked,
@@ -101,9 +109,10 @@ export function useMinimalConversation(
profileName,
title,
type,
unblurredAvatarUrl,
};
}, [
gradientStart,
gradientEnd,
acceptedMessageRequest,
announcementsOnly,
areWeAdmin,
@@ -112,6 +121,7 @@ export function useMinimalConversation(
color,
expireTimer,
groupVersion,
hasAvatar,
id,
isArchived,
isBlocked,
@@ -127,6 +137,5 @@ export function useMinimalConversation(
profileName,
title,
type,
unblurredAvatarUrl,
]);
}

View File

@@ -44,7 +44,11 @@ export class GroupAvatarJobQueue extends JobQueue<GroupAvatarJobData> {
}
// Generate correct attributes patch
const patch = await applyNewAvatar(newAvatarUrl, attributes, logId);
const patch = await applyNewAvatar({
newAvatarUrl,
attributes,
logId,
});
convo.set(patch);
await DataWriter.updateConversation(convo.attributes);

12
ts/model-types.d.ts vendored
View File

@@ -499,18 +499,12 @@ export type ConversationAttributesType = {
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).
unblurredAvatarUrl?: string;
// Legacy field, mapped to above in getConversation()
unblurredAvatarPath?: string;
// remoteAvatarUrl
remoteAvatarUrl?: string;
// Only used during backup integration tests. After import, our data model merges
// Contact and Chat frames from a backup, and we will then by default export both, even
// if the Chat frame was not imported. That's fine in normal usage, but breaks

View File

@@ -34,7 +34,6 @@ import {
getAvatar,
getRawAvatarPath,
getLocalAvatarUrl,
getLocalProfileAvatarUrl,
} from '../util/avatarUtils';
import { getDraftPreview } from '../util/getDraftPreview';
import { hasDraft } from '../util/hasDraft';
@@ -192,6 +191,7 @@ import { getIsInitialContactSync } from '../services/contactSync';
import { queueAttachmentDownloadsForMessage } from '../util/queueAttachmentDownloads';
import { cleanupMessages } from '../util/cleanup';
import { MessageModel } from './messages';
import { applyNewAvatar } from '../groups';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@@ -2485,6 +2485,25 @@ export class ConversationModel extends window.Backbone
// time to go through old messages to download attachments.
if (didResponseChange && !wasPreviouslyAccepted) {
await this.handleReadAndDownloadAttachments({ isLocalAction });
if (this.attributes.remoteAvatarUrl) {
if (isDirectConversation(this.attributes)) {
drop(
this.setAndMaybeFetchProfileAvatar({
avatarUrl: this.attributes.remoteAvatarUrl,
})
);
}
if (isGroup(this.attributes)) {
const updateAttrs = await applyNewAvatar({
newAvatarUrl: this.attributes.remoteAvatarUrl,
attributes: this.attributes,
logId: 'applyMessageRequestResponse',
forceDownload: true,
});
this.set(updateAttrs);
}
}
}
if (isLocalAction) {
@@ -4922,10 +4941,12 @@ export class ConversationModel extends window.Backbone
}
}
async setAndMaybeFetchProfileAvatar(
avatarUrl: undefined | null | string,
decryptionKey: Uint8Array
): Promise<void> {
async setAndMaybeFetchProfileAvatar(options: {
avatarUrl: undefined | null | string;
decryptionKey?: Uint8Array | null | undefined;
forceFetch?: boolean;
}): Promise<void> {
const { avatarUrl, decryptionKey, forceFetch } = options;
if (isMe(this.attributes)) {
if (avatarUrl) {
await window.storage.put('avatarUrl', avatarUrl);
@@ -4944,10 +4965,28 @@ export class ConversationModel extends window.Backbone
throw new Error('setProfileAvatar: Cannot fetch avatar when offline!');
}
if (!this.getAccepted({ ignoreEmptyConvo: true }) && !forceFetch) {
this.set({ remoteAvatarUrl: avatarUrl });
return;
}
const avatar = await messaging.getAvatar(avatarUrl);
// If decryptionKey isn't provided, use the one from the model
const modelProfileKey = this.get('profileKey');
const updatedDecryptionKey =
decryptionKey ||
(modelProfileKey ? Bytes.fromBase64(modelProfileKey) : null);
if (!updatedDecryptionKey) {
log.warn(
'setAndMaybeFetchProfileAvatar: No decryption key provided and none found on model'
);
return;
}
// decrypt
const decrypted = decryptProfile(avatar, decryptionKey);
const decrypted = decryptProfile(avatar, updatedDecryptionKey);
// update the conversation avatar only if hash differs
if (decrypted) {
@@ -5303,15 +5342,6 @@ export class ConversationModel extends window.Backbone
};
}
unblurAvatar(): void {
const avatarUrl = getLocalProfileAvatarUrl(this.attributes);
if (avatarUrl) {
this.set('unblurredAvatarUrl', avatarUrl);
} else {
this.unset('unblurredAvatarUrl');
}
}
areWeAdmin(): boolean {
return areWeAdmin(this.attributes);
}

View File

@@ -294,17 +294,16 @@ export class MentionCompletion {
)}
>
<Avatar
acceptedMessageRequest={member.acceptedMessageRequest}
avatarPlaceholderGradient={member.avatarPlaceholderGradient}
avatarUrl={member.avatarUrl}
badge={getPreferredBadge(member.badges)}
conversationType="direct"
hasAvatar={member.hasAvatar}
i18n={this.options.i18n}
isMe={member.isMe}
sharedGroupNames={member.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
theme={theme}
title={member.title}
unblurredAvatarUrl={member.unblurredAvatarUrl}
/>
<div className="module-composition-input__suggestions__title">
<UserText text={member.title} />

View File

@@ -784,10 +784,10 @@ async function doGetProfile(
try {
if (requestDecryptionKey != null) {
// Note: Fetches avatar
await c.setAndMaybeFetchProfileAvatar(
profile.avatar,
requestDecryptionKey
);
await c.setAndMaybeFetchProfileAvatar({
avatarUrl: profile.avatar,
decryptionKey: requestDecryptionKey,
});
}
} catch (error) {
if (error instanceof HTTPError) {

View File

@@ -1743,7 +1743,10 @@ export async function mergeAccountRecord(
);
const avatarUrl = dropNull(accountRecord.avatarUrl);
await conversation.setAndMaybeFetchProfileAvatar(avatarUrl, profileKey);
await conversation.setAndMaybeFetchProfileAvatar({
avatarUrl,
decryptionKey: profileKey,
});
await window.storage.put('avatarUrl', avatarUrl);
}

View File

@@ -1655,9 +1655,7 @@ function saveConversation(db: WritableDB, data: ConversationType): void {
`
).run({
id,
json: objectToJSON(
omit(data, ['profileLastFetchedAt', 'unblurredAvatarUrl'])
),
json: objectToJSON(omit(data, ['profileLastFetchedAt'])),
e164: e164 || null,
serviceId: serviceId || null,
@@ -1723,9 +1721,7 @@ function updateConversation(db: WritableDB, data: ConversationType): void {
`
).run({
id,
json: objectToJSON(
omit(data, ['profileLastFetchedAt', 'unblurredAvatarUrl'])
),
json: objectToJSON(omit(data, ['profileLastFetchedAt'])),
e164: e164 || null,
serviceId: serviceId || null,

View File

@@ -97,6 +97,7 @@ import {
getConversationSelector,
getMe,
getMessagesByConversation,
getPendingAvatarDownloadSelector,
} from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import type {
@@ -302,8 +303,9 @@ export type ConversationType = ReadonlyDeep<
avatarUrl?: string;
rawAvatarPath?: string;
avatarHash?: string;
avatarPlaceholderGradient?: Readonly<[string, string]>;
profileAvatarUrl?: string;
unblurredAvatarUrl?: string;
hasAvatar?: boolean;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
@@ -578,6 +580,10 @@ export type ConversationsStateType = ReadonlyDeep<{
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
// Map of conversation IDs to a boolean indicating whether an avatar download
// was requested
pendingRequestedAvatarDownload: Record<string, boolean>;
preloadData?: ConversationPreloadDataType;
}>;
@@ -640,6 +646,8 @@ export const SET_VOICE_NOTE_PLAYBACK_RATE =
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
export const SHOW_SPOILER = 'conversations/SHOW_SPOILER';
export const SET_PENDING_REQUESTED_AVATAR_DOWNLOAD =
'conversations/SET_PENDING_REQUESTED_AVATAR_DOWNLOAD';
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
@@ -830,6 +838,14 @@ export type ShowSpoilerActionType = ReadonlyDeep<{
};
}>;
export type SetPendingRequestedAvatarDownloadActionType = ReadonlyDeep<{
type: typeof SET_PENDING_REQUESTED_AVATAR_DOWNLOAD;
payload: {
conversationId: string;
value: boolean;
};
}>;
export type MessagesAddedActionType = ReadonlyDeep<{
type: 'MESSAGES_ADDED';
payload: {
@@ -1064,6 +1080,7 @@ export type ConversationActionType =
| ReplaceAvatarsActionType
| ReviewConversationNameCollisionActionType
| ScrollToMessageActionType
| SetPendingRequestedAvatarDownloadActionType
| TargetedConversationChangedActionType
| SetComposeGroupAvatarActionType
| SetComposeGroupExpireTimerActionType
@@ -1179,6 +1196,8 @@ export const actions = {
saveAvatarToDisk,
scrollToMessage,
scrollToOldestUnreadMention,
setPendingRequestedAvatarDownload,
startAvatarDownload,
showSpoiler,
targetMessage,
setAccessControlAddFromInviteLinkSetting,
@@ -1220,7 +1239,6 @@ export const actions = {
toggleHideStories,
toggleSelectMessage,
toggleSelectMode,
unblurAvatar,
updateConversationModelSharedGroups,
updateGroupAttributes,
updateLastMessage,
@@ -1465,19 +1483,7 @@ function removeMember(
payload: null,
};
}
function unblurAvatar(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('unblurAvatar: Conversation not found!');
}
conversation.unblurAvatar();
return {
type: 'NOOP',
payload: null,
};
}
function updateSharedGroups(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
@@ -4873,6 +4879,73 @@ function doubleCheckMissingQuoteReference(messageId: string): NoopActionType {
};
}
function setPendingRequestedAvatarDownload(
conversationId: string,
value: boolean
): SetPendingRequestedAvatarDownloadActionType {
return {
type: SET_PENDING_REQUESTED_AVATAR_DOWNLOAD,
payload: {
conversationId,
value,
},
};
}
function startAvatarDownload(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
SetPendingRequestedAvatarDownloadActionType
> {
return async (dispatch, getState) => {
const isAlreadyLoading =
getPendingAvatarDownloadSelector(getState())(conversationId);
if (isAlreadyLoading) {
return;
}
dispatch(setPendingRequestedAvatarDownload(conversationId, true));
try {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('startAvatarDownload: Conversation not found');
}
if (!conversation.attributes.remoteAvatarUrl) {
throw new Error('startAvatarDownload: no avatar URL');
}
if (isGroup(conversation.attributes)) {
const updateAttrs = await groups.applyNewAvatar({
newAvatarUrl: conversation.attributes.remoteAvatarUrl,
attributes: conversation.attributes,
logId: 'startAvatarDownload',
forceDownload: true,
});
conversation.set(updateAttrs);
} else {
await conversation.setAndMaybeFetchProfileAvatar({
avatarUrl: conversation.attributes.remoteAvatarUrl,
decryptionKey: null,
forceFetch: true,
});
}
await DataWriter.updateConversation(conversation.attributes);
} catch (error) {
log.error(
'startAvatarDownload: Failed to download avatar',
Errors.toLogFormat(error)
);
} finally {
dispatch(setPendingRequestedAvatarDownload(conversationId, false));
}
};
}
// Reducer
export function getEmptyState(): ConversationsStateType {
@@ -4892,6 +4965,7 @@ export function getEmptyState(): ConversationsStateType {
selectedMessageIds: undefined,
showArchived: false,
hasContactSpoofingReview: false,
pendingRequestedAvatarDownload: {},
targetedConversationPanels: {
isAnimating: false,
wasAnimated: false,
@@ -7232,5 +7306,17 @@ export function reducer(
};
}
if (action.type === SET_PENDING_REQUESTED_AVATAR_DOWNLOAD) {
const { conversationId, value } = action.payload;
return {
...state,
pendingRequestedAvatarDownload: {
...state.pendingRequestedAvatarDownload,
[conversationId]: value,
},
};
}
return state;
}

View File

@@ -1339,3 +1339,14 @@ export const getPreloadedConversationId = createSelector(
getConversations,
({ preloadData }): string | undefined => preloadData?.conversationId
);
export const getPendingAvatarDownloadSelector = createSelector(
getConversations,
(conversations: ConversationsStateType) => {
return (conversationId: string): boolean => {
return Boolean(
conversations.pendingRequestedAvatarDownload[conversationId]
);
};
}
);

View File

@@ -164,7 +164,6 @@ type FormattedContact = Partial<ConversationType> &
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarUrl'
>;
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
export type MessagePropsType = Omit<
@@ -351,11 +350,13 @@ const getAuthorForMessage = (
options: GetContactOptions
): PropsData['author'] => {
const {
avatarPlaceholderGradient,
acceptedMessageRequest,
avatarUrl,
badges,
color,
firstName,
hasAvatar,
id,
isMe,
name,
@@ -363,15 +364,16 @@ const getAuthorForMessage = (
profileName,
sharedGroupNames,
title,
unblurredAvatarUrl,
} = getContact(message, options);
const unsafe = {
avatarPlaceholderGradient,
acceptedMessageRequest,
avatarUrl,
badges,
color,
firstName,
hasAvatar,
id,
isMe,
name,
@@ -379,7 +381,6 @@ const getAuthorForMessage = (
profileName,
sharedGroupNames,
title,
unblurredAvatarUrl,
};
const safe: AssertProps<PropsData['author'], typeof unsafe> = unsafe;

View File

@@ -166,11 +166,13 @@ export function getStoryView(
const sender = pick(
conversationSelector(story.sourceServiceId || story.source),
[
'avatarPlaceholderGradient',
'acceptedMessageRequest',
'avatarUrl',
'badges',
'color',
'firstName',
'hasAvatar',
'hideStory',
'id',
'isMe',

View File

@@ -6,7 +6,10 @@ import { AboutContactModal } from '../../components/conversation/AboutContactMod
import { isSignalConnection } from '../../util/getSignalConnections';
import { getIntl } from '../selectors/user';
import { getGlobalModalsState } from '../selectors/globalModals';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationSelector,
getPendingAvatarDownloadSelector,
} from '../selectors/conversations';
import type { ConversationType } from '../ducks/conversations';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
@@ -35,8 +38,9 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
const globalModals = useSelector(getGlobalModalsState);
const { aboutContactModalContactId: contactId } = globalModals;
const getConversation = useSelector(getConversationSelector);
const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector);
const { updateSharedGroups, unblurAvatar } = useConversationsActions();
const { startAvatarDownload, updateSharedGroups } = useConversationsActions();
const conversation = getConversation(contactId);
const { id: conversationId } = conversation ?? {};
@@ -63,7 +67,6 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
i18n={i18n}
conversation={conversation}
updateSharedGroups={updateSharedGroups}
unblurAvatar={unblurAvatar}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
toggleSafetyNumberModal={toggleSafetyNumberModal}
isSignalConnection={isSignalConnection(conversation)}
@@ -71,6 +74,12 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
onClose={toggleAboutContactModal}
onOpenNotePreviewModal={handleOpenNotePreviewModal}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
pendingAvatarDownload={
conversationId ? isPendingAvatarDownload(conversationId) : false
}
startAvatarDownload={
conversationId ? () => startAvatarDownload(conversationId) : undefined
}
/>
);
});

View File

@@ -54,6 +54,7 @@ export const SmartContactModal = memo(function SmartContactModal() {
updateConversationModelSharedGroups,
toggleAdmin,
blockConversation,
startAvatarDownload,
} = useConversationsActions();
const { viewUserStories } = useStoriesActions();
const {
@@ -94,6 +95,7 @@ export const SmartContactModal = memo(function SmartContactModal() {
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
removeMemberFromGroup={removeMemberFromGroup}
showConversation={showConversation}
startAvatarDownload={() => startAvatarDownload(contact.id)}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}
toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}

View File

@@ -23,6 +23,7 @@ import {
getAllComposableConversations,
getConversationByIdSelector,
getConversationByServiceIdSelector,
getPendingAvatarDownloadSelector,
} from '../selectors/conversations';
import {
getAreWeASubscriber,
@@ -98,6 +99,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
const conversationSelector = useSelector(getConversationByIdSelector);
const defaultConversationColor = useSelector(getDefaultConversationColor);
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector);
const selectedNavTab = useSelector(getSelectedNavTab);
const {
@@ -114,6 +116,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
setDisappearingMessages,
setMuteExpiration,
showConversation,
startAvatarDownload,
updateGroupAttributes,
updateNicknameAndNote,
} = useConversationsActions();
@@ -205,6 +208,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
pendingApprovalMemberships={pendingApprovalMemberships}
pendingAvatarDownload={isPendingAvatarDownload(conversationId)}
pendingMemberships={pendingMemberships}
pushPanelForConversation={pushPanelForConversation}
renderChooseGroupMembersModal={renderChooseGroupMembersModal}
@@ -218,6 +222,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
showContactModal={showContactModal}
showConversation={showConversation}
showLightbox={showLightbox}
startAvatarDownload={() => startAvatarDownload(conversationId)}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}
toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}

View File

@@ -8,7 +8,10 @@ import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getTheme } from '../selectors/user';
import { getHasStoriesSelector } from '../selectors/stories2';
import { isSignalConversation } from '../../util/isSignalConversation';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationSelector,
getPendingAvatarDownloadSelector,
} from '../selectors/conversations';
import {
type ConversationType,
useConversationsActions,
@@ -46,6 +49,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const hasStoriesSelector = useSelector(getHasStoriesSelector);
const conversationSelector = useSelector(getConversationSelector);
const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector);
const conversation = conversationSelector(id);
if (conversation == null) {
throw new Error(`Did not find conversation ${id} in state!`);
@@ -55,7 +59,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
const isSignalConversationValue = isSignalConversation(conversation);
const fromOrAddedByTrustedContact =
isFromOrAddedByTrustedContact(conversation);
const { pushPanelForConversation, unblurAvatar, updateSharedGroups } =
const { pushPanelForConversation, startAvatarDownload, updateSharedGroups } =
useConversationsActions();
const { toggleAboutContactModal, toggleProfileNameWarningModal } =
useGlobalModalActions();
@@ -64,10 +68,12 @@ export const SmartHeroRow = memo(function SmartHeroRow({
}, [pushPanelForConversation]);
const { viewUserStories } = useStoriesActions();
const {
avatarPlaceholderGradient,
about,
acceptedMessageRequest,
avatarUrl,
groupDescription,
hasAvatar,
isMe,
membersCount,
nicknameGivenName,
@@ -77,7 +83,6 @@ export const SmartHeroRow = memo(function SmartHeroRow({
sharedGroupNames,
title,
type,
unblurredAvatarUrl,
} = conversation;
const isDirectConvoAndHasNickname =
@@ -85,6 +90,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
return (
<ConversationHero
avatarPlaceholderGradient={avatarPlaceholderGradient}
about={about}
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
@@ -92,6 +98,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
conversationType={type}
fromOrAddedByTrustedContact={fromOrAddedByTrustedContact}
groupDescription={groupDescription}
hasAvatar={hasAvatar}
hasStories={hasStories}
i18n={i18n}
id={id}
@@ -100,15 +107,15 @@ export const SmartHeroRow = memo(function SmartHeroRow({
isSignalConversation={isSignalConversationValue}
membersCount={membersCount}
openConversationDetails={openConversationDetails}
pendingAvatarDownload={isPendingAvatarDownload(id)}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
startAvatarDownload={() => startAvatarDownload(id)}
theme={theme}
title={title}
toggleAboutContactModal={toggleAboutContactModal}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
unblurAvatar={unblurAvatar}
unblurredAvatarUrl={unblurredAvatarUrl}
updateSharedGroups={updateSharedGroups}
viewUserStories={viewUserStories}
/>

View File

@@ -12,6 +12,7 @@ import type { GroupListItemConversationType } from '../../components/conversatio
import { getRandomColor } from './getRandomColor';
import { ConversationColors } from '../../types/Colors';
import { StorySendMode } from '../../types/Stories';
import { getAvatarPlaceholderGradient } from '../../utils/getAvatarPlaceholderGradient';
export const getAvatarPath = (): string =>
sample([
@@ -27,6 +28,7 @@ export function getDefaultConversation(
const lastName = casual.last_name;
return {
avatarPlaceholderGradient: getAvatarPlaceholderGradient(0),
acceptedMessageRequest: true,
avatarUrl: getAvatarPath(),
badges: [],

View File

@@ -1,97 +0,0 @@
// 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,
avatarUrl: '/path/to/avatar.jpg',
sharedGroupNames: [],
unblurredAvatarUrl: undefined,
})
);
});
it('returns false if the message request has been accepted', () => {
assert.isFalse(
shouldBlurAvatar({
acceptedMessageRequest: true,
avatarUrl: '/path/to/avatar.jpg',
isMe: false,
sharedGroupNames: [],
unblurredAvatarUrl: undefined,
})
);
});
it('returns false if there are any shared groups', () => {
assert.isFalse(
shouldBlurAvatar({
sharedGroupNames: ['Tahoe Trip'],
acceptedMessageRequest: false,
avatarUrl: '/path/to/avatar.jpg',
isMe: false,
unblurredAvatarUrl: undefined,
})
);
});
it('returns false if there is no avatar', () => {
assert.isFalse(
shouldBlurAvatar({
acceptedMessageRequest: false,
isMe: false,
sharedGroupNames: [],
unblurredAvatarUrl: undefined,
})
);
assert.isFalse(
shouldBlurAvatar({
avatarUrl: undefined,
acceptedMessageRequest: false,
isMe: false,
sharedGroupNames: [],
unblurredAvatarUrl: undefined,
})
);
assert.isFalse(
shouldBlurAvatar({
avatarUrl: undefined,
unblurredAvatarUrl: '/some/other/path',
acceptedMessageRequest: false,
isMe: false,
sharedGroupNames: [],
})
);
});
it('returns false if the avatar was unblurred', () => {
assert.isFalse(
shouldBlurAvatar({
avatarUrl: '/path/to/avatar.jpg',
unblurredAvatarUrl: '/path/to/avatar.jpg',
acceptedMessageRequest: false,
isMe: false,
sharedGroupNames: [],
})
);
});
it('returns true if the stars align (i.e., not everything above)', () => {
assert.isTrue(
shouldBlurAvatar({
avatarUrl: '/path/to/avatar.jpg',
unblurredAvatarUrl: '/different/path.jpg',
acceptedMessageRequest: false,
isMe: false,
sharedGroupNames: [],
})
);
});
});

View File

@@ -6,7 +6,11 @@ import { assert } from 'chai';
import * as durations from '../../util/durations';
import type { App, Bootstrap } from './fixtures';
import { initStorage, debug } from './fixtures';
import { typeIntoInput, waitForEnabledComposer } from '../helpers';
import {
acceptConversation,
typeIntoInput,
waitForEnabledComposer,
} from '../helpers';
describe('storage service', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
@@ -50,7 +54,6 @@ describe('storage service', function (this: Mocha.Suite) {
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('Opening conversation with a stranger');
debug(stranger.toContact().aci);
@@ -77,14 +80,7 @@ describe('storage service', function (this: Mocha.Suite) {
}
debug('Accept conversation from a stranger');
await conversationStack
.locator('.module-message-request-actions button >> "Accept"')
.click();
await window
.locator('.MessageRequestActionsConfirmation')
.getByRole('button', { name: 'Accept' })
.click();
await acceptConversation(window);
debug('Verify that storage state was updated');
{

View File

@@ -233,6 +233,8 @@ export type StorageAccessType = {
postRegistrationSyncsStatus: 'incomplete' | 'complete';
avatarsHaveBeenMigrated: boolean;
// Deprecated
'challenge:retry-message-ids': never;
nextSignedKeyRotationTime: number;

View File

@@ -82,11 +82,13 @@ export type StoryViewType = {
readAt?: number;
sender: Pick<
ConversationType,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'badges'
| 'color'
| 'firstName'
| 'hasAvatar'
| 'hideStory'
| 'id'
| 'isMe'

View File

@@ -7,6 +7,14 @@ import { isMe } from './whatTypeOfConversation';
import { isSignalConversation } from './isSignalConversation';
import { getLocalAttachmentUrl } from './getLocalAttachmentUrl';
export function hasAvatar(
conversationAttrs: ConversationAttributesType
): boolean {
return Boolean(
getAvatar(conversationAttrs) || conversationAttrs.remoteAvatarUrl
);
}
export function getAvatarHash(
conversationAttrs: ConversationAttributesType
): undefined | string {
@@ -65,29 +73,3 @@ export function getLocalProfileAvatarUrl(
const avatar = conversationAttrs.profileAvatar || conversationAttrs.avatar;
return avatar?.path ? getLocalAttachmentUrl(avatar) : undefined;
}
export function getLocalUnblurredAvatarUrl(
conversationAttrs: ConversationAttributesType
): string | undefined {
const { unblurredAvatarPath, unblurredAvatarUrl } = conversationAttrs;
if (unblurredAvatarUrl != null) {
return unblurredAvatarUrl;
}
if (unblurredAvatarPath == null) {
return undefined;
}
// Compatibility mode
const avatar = getAvatar(conversationAttrs);
// Since we use `unblurredAvatarUrl` only for equality checks - if the path
// is the same - return equivalent url
if (avatar?.path === unblurredAvatarPath) {
return getLocalAvatarUrl(conversationAttrs);
}
// Otherwise generate some valid url, but it will never be the same because of
// absent "size".
return getLocalAttachmentUrl({ path: unblurredAvatarPath });
}

View File

@@ -17,11 +17,11 @@ import { canEditGroupInfo } from './canEditGroupInfo';
import { dropNull } from './dropNull';
import { getAboutText } from './getAboutText';
import {
getLocalUnblurredAvatarUrl,
getAvatarHash,
getLocalAvatarUrl,
getLocalProfileAvatarUrl,
getRawAvatarPath,
hasAvatar,
} from './avatarUtils';
import { getAvatarData } from './getAvatarData';
import { getConversationMembers } from './getConversationMembers';
@@ -46,16 +46,17 @@ import {
isMe,
} from './whatTypeOfConversation';
import {
areWePending,
getBannedMemberships,
getMembersCount,
getMemberships,
getPendingApprovalMemberships,
getPendingMemberships,
isMember,
isMemberAwaitingApproval,
isMemberPending,
} from './groupMembershipUtils';
import { isNotNil } from './isNotNil';
import { getIdentifierHash } from '../Crypto';
import { getAvatarPlaceholderGradient } from '../utils/getAvatarPlaceholderGradient';
const EMPTY_ARRAY: Readonly<[]> = [];
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
@@ -88,7 +89,13 @@ export function getConversation(model: ConversationModel): ConversationType {
);
const ourAci = window.textsecure.storage.user.getAci();
const ourPni = window.textsecure.storage.user.getPni();
const identifierHash = getIdentifierHash({
aci: isAciString(attributes.serviceId) ? attributes.serviceId : undefined,
e164: attributes.e164,
pni: attributes.pni,
groupId: attributes.groupId,
});
const color = migrateColor(attributes.color, {
aci: isAciString(attributes.serviceId) ? attributes.serviceId : undefined,
@@ -97,6 +104,11 @@ export function getConversation(model: ConversationModel): ConversationType {
groupId: attributes.groupId,
});
const avatarPlaceholderGradient =
hasAvatar(attributes) && identifierHash != null
? getAvatarPlaceholderGradient(identifierHash)
: undefined;
const { draftTimestamp, draftEditMessage, timestamp } = attributes;
const draftPreview = getDraftPreview(attributes);
const draftText = dropNull(attributes.draft);
@@ -142,20 +154,14 @@ export function getConversation(model: ConversationModel): ConversationType {
acceptedMessageRequest: isConversationAccepted(attributes),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
activeAt: attributes.active_at!,
areWePending:
ourAci &&
(isMemberPending(attributes, ourAci) ||
Boolean(
ourPni &&
!isMember(attributes, ourAci) &&
isMemberPending(attributes, ourPni)
)),
areWePending: areWePending(attributes),
areWePendingApproval: Boolean(
ourConversationId &&
ourAci &&
isMemberAwaitingApproval(attributes, ourAci)
),
areWeAdmin: areWeAdmin(attributes),
avatarPlaceholderGradient,
avatars: getAvatarData(attributes),
badges: attributes.badges ?? EMPTY_ARRAY,
canChangeTimer: canChangeTimer(attributes),
@@ -164,8 +170,8 @@ export function getConversation(model: ConversationModel): ConversationType {
avatarUrl: getLocalAvatarUrl(attributes),
rawAvatarPath: getRawAvatarPath(attributes),
avatarHash: getAvatarHash(attributes),
unblurredAvatarUrl: getLocalUnblurredAvatarUrl(attributes),
profileAvatarUrl: getLocalProfileAvatarUrl(attributes),
hasAvatar: hasAvatar(attributes),
color,
conversationColor: attributes.conversationColor,
customColor,

View File

@@ -184,3 +184,22 @@ export function getMemberships(
aci: member.aci,
}));
}
export function areWePending(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion' | 'pendingMembersV2'
>
): boolean {
const ourAci = window.textsecure.storage.user.getAci();
const ourPni = window.textsecure.storage.user.getPni();
return Boolean(
ourAci &&
(isMemberPending(conversationAttrs, ourAci) ||
Boolean(
ourPni &&
!isMember(conversationAttrs, ourAci) &&
isMemberPending(conversationAttrs, ourPni)
))
);
}

View File

@@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationType } from '../state/ducks/conversations';
export const shouldBlurAvatar = ({
acceptedMessageRequest,
avatarUrl,
isMe,
sharedGroupNames,
unblurredAvatarUrl,
}: Readonly<
Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarUrl'
| 'isMe'
| 'sharedGroupNames'
| 'unblurredAvatarUrl'
>
>): boolean =>
Boolean(
!isMe &&
!acceptedMessageRequest &&
!sharedGroupNames.length &&
avatarUrl &&
avatarUrl !== unblurredAvatarUrl
);

View File

@@ -0,0 +1,33 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const GRADIENTS = [
['#252568', '#9C8F8F'],
['#2A4275', '#9D9EA1'],
['#2E4B5F', '#8AA9B1'],
['#2E426C', '#7A9377'],
['#1A341A', '#807F6E'],
['#464E42', '#D5C38F'],
['#595643', '#93A899'],
['#2C2F36', '#687466'],
['#2B1E18', '#968980'],
['#7B7067', '#A5A893'],
['#706359', '#BDA194'],
['#383331', '#A48788'],
['#924F4F', '#897A7A'],
['#663434', '#C58D77'],
['#8F4B02', '#AA9274'],
['#784747', '#8C8F6F'],
['#747474', '#ACACAC'],
['#49484C', '#A5A6B5'],
['#4A4E4D', '#ABAFAE'],
['#3A3A3A', '#929887'],
] as const;
export function getAvatarPlaceholderGradient(
identifierHash: number
): Readonly<[string, string]> {
const colorIndex = identifierHash % GRADIENTS.length;
return GRADIENTS[colorIndex];
}