Avatar: Get rid of data-uri avatars

This commit is contained in:
Alexander Zinchuk 2021-10-11 19:47:52 +03:00
parent 77194245de
commit 09caf525a3
12 changed files with 66 additions and 67 deletions

View File

@ -14,7 +14,6 @@ import {
} from '../../../config'; } from '../../../config';
import localDb from '../localDb'; import localDb from '../localDb';
import { getEntityTypeById } from '../gramjsBuilders'; import { getEntityTypeById } from '../gramjsBuilders';
import { blobToDataUri } from '../../../util/files';
import * as cacheApi from '../../../util/cacheApi'; import * as cacheApi from '../../../util/cacheApi';
type EntityType = ( type EntityType = (
@ -221,8 +220,6 @@ async function parseMedia(
data: Buffer, mediaFormat: ApiMediaFormat, mimeType?: string, data: Buffer, mediaFormat: ApiMediaFormat, mimeType?: string,
): Promise<ApiParsedMedia | undefined> { ): Promise<ApiParsedMedia | undefined> {
switch (mediaFormat) { switch (mediaFormat) {
case ApiMediaFormat.DataUri:
return blobToDataUri(new Blob([data], { type: mimeType }));
case ApiMediaFormat.BlobUrl: case ApiMediaFormat.BlobUrl:
return new Blob([data], { type: mimeType }); return new Blob([data], { type: mimeType });
case ApiMediaFormat.Lottie: { case ApiMediaFormat.Lottie: {

View File

@ -2,7 +2,6 @@
// and messages media as Blob for smaller size. // and messages media as Blob for smaller size.
export enum ApiMediaFormat { export enum ApiMediaFormat {
DataUri,
BlobUrl, BlobUrl,
Lottie, Lottie,
Progressive, Progressive,

View File

@ -1,12 +1,18 @@
import { MouseEvent as ReactMouseEvent } from 'react'; import { MouseEvent as ReactMouseEvent } from 'react';
import React, { FC, useCallback, memo } from '../../lib/teact/teact'; import React, { FC, memo, useCallback } from '../../lib/teact/teact';
import { ApiUser, ApiChat, ApiMediaFormat } from '../../api/types'; import { ApiChat, ApiMediaFormat, ApiUser } from '../../api/types';
import { IS_TEST } from '../../config'; import { IS_TEST } from '../../config';
import { import {
getChatAvatarHash, getChatTitle, isChatPrivate, getChatAvatarHash,
getUserFullName, isUserOnline, isDeletedUser, getUserColorKey, isChatWithRepliesBot, getChatTitle,
getUserColorKey,
getUserFullName,
isChatPrivate,
isChatWithRepliesBot,
isDeletedUser,
isUserOnline,
} from '../../modules/helpers'; } from '../../modules/helpers';
import { getFirstLetters } from '../../util/textFormat'; import { getFirstLetters } from '../../util/textFormat';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -52,8 +58,8 @@ const Avatar: FC<OwnProps> = ({
} }
} }
const dataUri = useMedia(imageHash, false, ApiMediaFormat.DataUri, lastSyncTime); const blobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
const { shouldRenderFullMedia, transitionClassNames } = useTransitionForMedia(dataUri, 'slow'); const { shouldRenderFullMedia, transitionClassNames } = useTransitionForMedia(blobUrl, 'slow');
const lang = useLang(); const lang = useLang();
@ -66,7 +72,7 @@ const Avatar: FC<OwnProps> = ({
} else if (isReplies) { } else if (isReplies) {
content = <i className="icon-reply-filled" />; content = <i className="icon-reply-filled" />;
} else if (shouldRenderFullMedia) { } else if (shouldRenderFullMedia) {
content = <img src={dataUri} className={`${transitionClassNames} avatar-media`} alt="" decoding="async" />; content = <img src={blobUrl} className={`${transitionClassNames} avatar-media`} alt="" decoding="async" />;
} else if (user) { } else if (user) {
const userFullName = getUserFullName(user); const userFullName = getUserFullName(user);
content = userFullName ? getFirstLetters(userFullName, 2) : undefined; content = userFullName ? getFirstLetters(userFullName, 2) : undefined;

View File

@ -1,11 +1,17 @@
import React, { FC, memo } from '../../lib/teact/teact'; import React, { FC, memo } from '../../lib/teact/teact';
import { import {
ApiUser, ApiChat, ApiMediaFormat, ApiPhoto, ApiChat, ApiMediaFormat, ApiPhoto, ApiUser,
} from '../../api/types'; } from '../../api/types';
import { import {
getChatAvatarHash, isDeletedUser, getUserColorKey, getChatTitle, isChatPrivate, getUserFullName, isChatWithRepliesBot, getChatAvatarHash,
getChatTitle,
getUserColorKey,
getUserFullName,
isChatPrivate,
isChatWithRepliesBot,
isDeletedUser,
} from '../../modules/helpers'; } from '../../modules/helpers';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -42,7 +48,7 @@ const ProfilePhoto: FC<OwnProps> = ({
const isDeleted = user && isDeletedUser(user); const isDeleted = user && isDeletedUser(user);
const isRepliesChat = chat && isChatWithRepliesBot(chat.id); const isRepliesChat = chat && isChatWithRepliesBot(chat.id);
function getMediaHash(size: 'normal' | 'big' = 'big', forceAvatar?: boolean) { function getMediaHash(size: 'normal' | 'big', forceAvatar?: boolean) {
if (photo && !forceAvatar) { if (photo && !forceAvatar) {
return `photo${photo.id}?size=c`; return `photo${photo.id}?size=c`;
} }
@ -59,21 +65,11 @@ const ProfilePhoto: FC<OwnProps> = ({
return hash; return hash;
} }
const imageHash = getMediaHash(); const photoBlobUrl = useMedia(getMediaHash('big'), false, ApiMediaFormat.BlobUrl, lastSyncTime);
const fullMediaData = useMedia( const avatarMediaHash = isFirstPhoto && !photoBlobUrl ? getMediaHash('normal', true) : undefined;
imageHash, const avatarBlobUrl = useMedia(avatarMediaHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
false, const thumbDataUri = useBlurSync(!photoBlobUrl && photo && photo.thumbnail && photo.thumbnail.dataUri);
imageHash?.startsWith('avatar') ? ApiMediaFormat.DataUri : ApiMediaFormat.BlobUrl, const imageSrc = photoBlobUrl || avatarBlobUrl || thumbDataUri;
lastSyncTime,
);
const avatarThumbnailData = useMedia(
!fullMediaData && isFirstPhoto ? getMediaHash('normal', true) : undefined,
false,
ApiMediaFormat.DataUri,
lastSyncTime,
);
const thumbDataUri = useBlurSync(!fullMediaData && photo && photo.thumbnail && photo.thumbnail.dataUri);
const imageSrc = fullMediaData || avatarThumbnailData || thumbDataUri;
const prevImageSrc = usePrevious(imageSrc); const prevImageSrc = usePrevious(imageSrc);
let content: string | undefined = ''; let content: string | undefined = '';

View File

@ -57,7 +57,7 @@ function preloadAvatars() {
return undefined; return undefined;
} }
return mediaLoader.fetch(avatarHash, ApiMediaFormat.DataUri); return mediaLoader.fetch(avatarHash, ApiMediaFormat.BlobUrl);
})); }));
} }

View File

@ -36,11 +36,7 @@ const WallpaperTile: FC<OwnProps> = ({
const localMediaHash = `wallpaper${document.id!}`; const localMediaHash = `wallpaper${document.id!}`;
const localBlobUrl = document.previewBlobUrl; const localBlobUrl = document.previewBlobUrl;
const previewBlobUrl = useMedia(`${localMediaHash}?size=m`); const previewBlobUrl = useMedia(`${localMediaHash}?size=m`);
const thumbRef = useCanvasBlur( const thumbRef = useCanvasBlur(document.thumbnail?.dataUri, Boolean(previewBlobUrl), true);
document.thumbnail?.dataUri,
Boolean(previewBlobUrl),
true,
);
const { const {
shouldRenderThumb, shouldRenderFullMedia, transitionClassNames, shouldRenderThumb, shouldRenderFullMedia, transitionClassNames,
} = useTransitionForMedia(previewBlobUrl || localBlobUrl, 'slow'); } = useTransitionForMedia(previewBlobUrl || localBlobUrl, 'slow');

View File

@ -166,7 +166,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview'); return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview');
} }
const blobUrlPictogram = useMedia( const pictogramBlobUrl = useMedia(
message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'), message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'),
undefined, undefined,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
@ -174,16 +174,14 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
isGhostAnimation && ANIMATION_DURATION, isGhostAnimation && ANIMATION_DURATION,
); );
const previewMediaHash = getMediaHash(); const previewMediaHash = getMediaHash();
const blobUrlPreview = useMedia( const previewBlobUrl = useMedia(
previewMediaHash, previewMediaHash,
undefined, undefined,
isAvatar && previewMediaHash && previewMediaHash.startsWith('profilePhoto') ApiMediaFormat.BlobUrl,
? ApiMediaFormat.DataUri
: ApiMediaFormat.BlobUrl,
undefined, undefined,
isGhostAnimation && ANIMATION_DURATION, isGhostAnimation && ANIMATION_DURATION,
); );
const { mediaData: fullMediaData, downloadProgress } = useMediaWithDownloadProgress( const { mediaData: fullMediaBlobUrl, downloadProgress } = useMediaWithDownloadProgress(
getMediaHash(true), getMediaHash(true),
undefined, undefined,
message && getMessageMediaFormat(message, 'viewerFull'), message && getMessageMediaFormat(message, 'viewerFull'),
@ -192,7 +190,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
); );
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined; const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaData)) || blobUrlPreview || blobUrlPictogram; let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message)); const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) { if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
bestImageData = thumbDataUri; bestImageData = thumbDataUri;
@ -459,7 +457,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
return ( return (
<div key={chatId} className="media-viewer-content"> <div key={chatId} className="media-viewer-content">
{renderPhoto( {renderPhoto(
fullMediaData || blobUrlPreview, fullMediaBlobUrl || previewBlobUrl,
calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false), calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false),
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed, !IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
)} )}
@ -476,14 +474,14 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
onClick={handleToggleFooterVisibility} onClick={handleToggleFooterVisibility}
> >
{isPhoto && renderPhoto( {isPhoto && renderPhoto(
localBlobUrl || fullMediaData || blobUrlPreview || blobUrlPictogram, localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
message && calculateMediaViewerDimensions(dimensions!, hasFooter), message && calculateMediaViewerDimensions(dimensions!, hasFooter),
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed, !IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
)} )}
{isVideo && ( {isVideo && (
<VideoPlayer <VideoPlayer
key={messageId} key={messageId}
url={localBlobUrl || fullMediaData} url={localBlobUrl || fullMediaBlobUrl}
isGif={isGif} isGif={isGif}
posterData={bestImageData} posterData={bestImageData}
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)} posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
@ -550,7 +548,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
{renderSenderInfo} {renderSenderInfo}
</Transition> </Transition>
<MediaViewerActions <MediaViewerActions
mediaData={fullMediaData || blobUrlPreview} mediaData={fullMediaBlobUrl || previewBlobUrl}
isVideo={isVideo} isVideo={isVideo}
isZoomed={isZoomed} isZoomed={isZoomed}
message={message} message={message}

View File

@ -64,7 +64,7 @@ const MediaResult: FC<OwnProps> = ({
return ( return (
<BaseResult <BaseResult
focus={focus} focus={focus}
thumbUrl={shouldRenderFullMedia ? mediaBlobUrl : (thumbnail?.dataUri) || thumbnailDataUrl} thumbUrl={shouldRenderFullMedia ? mediaBlobUrl : (thumbnail?.dataUri || thumbnailDataUrl)}
transitionClassNames={shouldRenderFullMedia ? transitionClassNames : undefined} transitionClassNames={shouldRenderFullMedia ? transitionClassNames : undefined}
title={title} title={title}
description={description} description={description}

View File

@ -8,9 +8,9 @@ import { EMPTY_IMAGE_DATA_URI, webpToPngBase64 } from '../util/webpToPng';
import { getMessageMediaThumbDataUri } from '../modules/helpers'; import { getMessageMediaThumbDataUri } from '../modules/helpers';
export default function useWebpThumbnail(message?: ApiMessage) { export default function useWebpThumbnail(message?: ApiMessage) {
const thumbnail = message && getMessageMediaThumbDataUri(message); const thumbDataUri = message && getMessageMediaThumbDataUri(message);
const sticker = message?.content?.sticker; const sticker = message?.content?.sticker;
const shouldDecodeThumbnail = thumbnail && sticker && !isWebpSupported() && thumbnail.includes('image/webp'); const shouldDecodeThumbnail = thumbDataUri && sticker && !isWebpSupported() && thumbDataUri.includes('image/webp');
const [thumbnailDecoded, setThumbnailDecoded] = useState(EMPTY_IMAGE_DATA_URI); const [thumbnailDecoded, setThumbnailDecoded] = useState(EMPTY_IMAGE_DATA_URI);
const messageId = message?.id; const messageId = message?.id;
@ -19,7 +19,7 @@ export default function useWebpThumbnail(message?: ApiMessage) {
return; return;
} }
webpToPngBase64(`b64-${messageId}`, thumbnail!) webpToPngBase64(`b64-${messageId}`, thumbDataUri!)
.then(setThumbnailDecoded) .then(setThumbnailDecoded)
.catch((err) => { .catch((err) => {
if (DEBUG) { if (DEBUG) {
@ -27,7 +27,7 @@ export default function useWebpThumbnail(message?: ApiMessage) {
console.error(err); console.error(err);
} }
}); });
}, [messageId, shouldDecodeThumbnail, thumbnail]); }, [messageId, shouldDecodeThumbnail, thumbDataUri]);
return shouldDecodeThumbnail ? thumbnailDecoded : thumbnail; return shouldDecodeThumbnail ? thumbnailDecoded : thumbDataUri;
} }

View File

@ -23,31 +23,38 @@ export async function fetch(
return undefined; return undefined;
} }
const contentType = response.headers.get('Content-Type');
switch (type) { switch (type) {
case Type.Text: case Type.Text:
return await response.text(); return await response.text();
case Type.Blob: { case Type.Blob: {
// Ignore deprecated data-uri avatars
if (key.startsWith('avatar') && contentType && contentType.startsWith('text')) {
return undefined;
}
const blob = await response.blob(); const blob = await response.blob();
// Safari does not return correct Content-Type header for webp images. // Safari does not return correct Content-Type header for webp images.
if (key.substr(0, 7) === 'sticker') { if (key.startsWith('sticker')) {
return new Blob([blob], { type: 'image/webp' }); return new Blob([blob], { type: 'image/webp' });
} }
const shouldRecreate = !blob.type || (!isHtmlAllowed && blob.type.includes('html'));
// iOS Safari fails to preserve `type` in cache // iOS Safari fails to preserve `type` in cache
if (!blob.type) { let resolvedType = blob.type || contentType;
const contentType = response.headers.get('Content-Type');
if (contentType) { if (!(shouldRecreate && resolvedType)) {
return new Blob([blob], { type: isHtmlAllowed ? contentType : contentType.replace(/html/gi, '') }); return blob;
}
} }
// Prevent HTML-in-video attacks (for files that were cached before fix) // Prevent HTML-in-video attacks (for files that were cached before fix)
if (!isHtmlAllowed && blob.type.includes('html')) { if (!isHtmlAllowed) {
return new Blob([blob], { type: blob.type.replace(/html/gi, '') }); resolvedType = resolvedType.replace(/html/gi, '');
} }
return blob; return new Blob([blob], { type: resolvedType });
} }
case Type.Json: case Type.Json:
return await response.json(); return await response.json();

View File

@ -17,7 +17,6 @@ import { oggToWav } from './oggToWav';
import { webpToPng } from './webpToPng'; import { webpToPng } from './webpToPng';
const asCacheApiType = { const asCacheApiType = {
[ApiMediaFormat.DataUri]: cacheApi.Type.Text,
[ApiMediaFormat.BlobUrl]: cacheApi.Type.Blob, [ApiMediaFormat.BlobUrl]: cacheApi.Type.Blob,
[ApiMediaFormat.Lottie]: cacheApi.Type.Json, [ApiMediaFormat.Lottie]: cacheApi.Type.Json,
[ApiMediaFormat.Progressive]: undefined, [ApiMediaFormat.Progressive]: undefined,
@ -82,6 +81,7 @@ async function fetchFromCacheOrRemote(
if (!MEDIA_CACHE_DISABLED) { if (!MEDIA_CACHE_DISABLED) {
const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME;
const cached = await cacheApi.fetch(cacheName, url, asCacheApiType[mediaFormat]!, isHtmlAllowed); const cached = await cacheApi.fetch(cacheName, url, asCacheApiType[mediaFormat]!, isHtmlAllowed);
if (cached) { if (cached) {
let media = cached; let media = cached;

View File

@ -25,17 +25,17 @@ export async function webpToPng(url: string, blob: Blob): Promise<Blob | undefin
return createPng({ result, width, height }); return createPng({ result, width, height });
} }
export async function webpToPngBase64(key: string, url: string): Promise<string> { export async function webpToPngBase64(key: string, dataUri: string): Promise<string> {
if (isWebpSupported() || url.substr(0, 15) !== 'data:image/webp') { if (isWebpSupported() || dataUri.substr(0, 15) !== 'data:image/webp') {
return url; return dataUri;
} }
initWebpWorker(); initWebpWorker();
const pngBlob = await webpToPng(key, dataUriToBlob(url)); const pngBlob = await webpToPng(key, dataUriToBlob(dataUri));
if (!pngBlob) { if (!pngBlob) {
throw new Error(`Can't convert webp to png. Url: ${url}`); throw new Error(`Can't convert webp to png. Url: ${dataUri}`);
} }
return blobToDataUri(pngBlob); return blobToDataUri(pngBlob);