diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index c414ae2aa7..251e1bbe26 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -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', []); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 1fa3afff0d..b24a5c003a 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -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]; } diff --git a/ts/background.ts b/ts/background.ts index b897d4959a..5631e1ab3c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1445,6 +1445,10 @@ export async function startApp(): Promise { if (window.isBeforeVersion(lastVersion, 'v5.31.0')) { window.ConversationController.repairPinnedConversations(); } + + if (!window.storage.get('avatarsHaveBeenMigrated', false)) { + window.ConversationController.migrateAvatarsForNonAcceptedConversations(); + } } void badgeImageFileDownloader.checkForFilesToDownload(); diff --git a/ts/components/AddUserToAnotherGroupModal.tsx b/ts/components/AddUserToAnotherGroupModal.tsx index 765aa5fa1a..40a682428d 100644 --- a/ts/components/AddUserToAnotherGroupModal.tsx +++ b/ts/components/AddUserToAnotherGroupModal.tsx @@ -129,7 +129,7 @@ export function AddUserToAnotherGroupModal({ } return { - ...pick(convo, 'id', 'avatarUrl', 'title', 'unblurredAvatarUrl'), + ...pick(convo, 'id', 'avatarUrl', 'title', 'hasAvatar'), memberships, membersCount, disabledReason, diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index 9960111334..2667fdf85f 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -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; const createProps = (overrideProps: Partial = {}): 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({}); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 913d5978ee..f0deb1c75f 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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; size: AvatarSize; title: string; - unblurredAvatarUrl?: string; searchResult?: boolean; storyRing?: HasStories; @@ -100,39 +98,26 @@ const BADGE_PLACEMENT_BY_SIZE = new Map([ [112, { bottom: -4, right: 3 }], ]); -const getDefaultBlur = ( - ...args: Parameters -): 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 = ( + <> +
+ {blur === AvatarBlur.BlurPictureWithClickToView && ( +
{i18n('icu:view')}
+ )} + + ); } else if (searchResult) { contentsChildren = (
; 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} > unknown; onClick?: () => unknown; style?: CSSProperties; -}; +} & Pick; 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 ( +
+
+
+ ); + } + return (
@@ -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')} /> diff --git a/ts/components/CallLinkEditModal.tsx b/ts/components/CallLinkEditModal.tsx index 1cbd30e4b4..cab53cf7a7 100644 --- a/ts/components/CallLinkEditModal.tsx +++ b/ts/components/CallLinkEditModal.tsx @@ -128,8 +128,6 @@ export function CallLinkEditModal({ color={getColorForCallLink(callLink.rootKey)} conversationType="callLink" size={AvatarSize.SIXTY_FOUR} - acceptedMessageRequest - isMe={false} sharedGroupNames={[]} title={ callLink.name === '' diff --git a/ts/components/CallLinkPendingParticipantModal.tsx b/ts/components/CallLinkPendingParticipantModal.tsx index 2ab161a0bb..505113dfcb 100644 --- a/ts/components/CallLinkPendingParticipantModal.tsx +++ b/ts/components/CallLinkPendingParticipantModal.tsx @@ -62,19 +62,18 @@ export function CallLinkPendingParticipantModal({ theme={Theme.Dark} >