Message: Support "Hidden Media" (#2308)

This commit is contained in:
Alexander Zinchuk 2023-01-22 19:16:30 +01:00
parent 00e7381a03
commit 24957c958e
43 changed files with 609 additions and 193 deletions

View File

@ -62,7 +62,7 @@ export function buildApiThumbnailFromPath(
};
}
export function buildApiPhoto(photo: GramJs.Photo): ApiPhoto {
export function buildApiPhoto(photo: GramJs.Photo, isSpoiler?: boolean): ApiPhoto {
const sizes = photo.sizes
.filter((s: any): s is GramJs.PhotoSize => {
return s instanceof GramJs.PhotoSize || s instanceof GramJs.PhotoSizeProgressive;
@ -73,6 +73,7 @@ export function buildApiPhoto(photo: GramJs.Photo): ApiPhoto {
id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes,
isSpoiler,
...(photo.videoSizes && { videoSizes: photo.videoSizes.map(buildApiVideoSize), isVideo: true }),
};
}

View File

@ -447,10 +447,10 @@ function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined {
return undefined;
}
return buildApiPhoto(media.photo);
return buildApiPhoto(media.photo, media.spoiler);
}
export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | undefined {
export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: boolean): ApiVideo | undefined {
if (document instanceof GramJs.DocumentEmpty) {
return undefined;
}
@ -499,6 +499,7 @@ export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | un
isGif: Boolean(gifAttr),
thumbnail: buildApiThumbnailFromStripped(thumbs),
size: size.toJSNumber(),
isSpoiler,
};
}
@ -511,7 +512,7 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
return undefined;
}
return buildVideoFromDocument(media.document);
return buildVideoFromDocument(media.document, media.spoiler);
}
function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
@ -1393,6 +1394,7 @@ function buildUploadingMedia(
size,
audio,
shouldSendAsFile,
shouldSendAsSpoiler,
} = attachment;
if (!shouldSendAsFile) {
@ -1403,8 +1405,9 @@ function buildUploadingMedia(
photo: {
id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
sizes: [],
thumbnail: { width, height, dataUri: '' }, // Used only for dimensions
thumbnail: { width, height, dataUri: blobUrl },
blobUrl,
isSpoiler: shouldSendAsSpoiler,
},
};
}
@ -1421,6 +1424,7 @@ function buildUploadingMedia(
blobUrl,
...(previewBlobUrl && { thumbnail: { width, height, dataUri: previewBlobUrl } }),
size,
isSpoiler: shouldSendAsSpoiler,
},
};
}

View File

@ -462,6 +462,7 @@ async function fetchInputMedia(
peer,
media: uploadedMedia,
}));
const isSpoiler = uploadedMedia.spoiler;
if ((
messageMedia instanceof GramJs.MessageMediaPhoto
@ -472,6 +473,7 @@ async function fetchInputMedia(
return new GramJs.InputMediaPhoto({
id: new GramJs.InputPhoto({ id, accessHash, fileReference }),
spoiler: isSpoiler,
});
}
@ -484,6 +486,7 @@ async function fetchInputMedia(
return new GramJs.InputMediaDocument({
id: new GramJs.InputDocument({ id, accessHash, fileReference }),
spoiler: isSpoiler,
});
}
@ -562,7 +565,7 @@ export async function rescheduleMessage({
async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment, onProgress: ApiOnProgress) {
const {
filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile,
filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile, shouldSendAsSpoiler,
} = attachment;
const patchedOnProgress: ApiOnProgress = (progress) => {
@ -583,7 +586,10 @@ async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment,
if (!shouldSendAsFile) {
if (quick) {
if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) {
return new GramJs.InputMediaUploadedPhoto({ file: inputFile });
return new GramJs.InputMediaUploadedPhoto({
file: inputFile,
spoiler: shouldSendAsSpoiler,
});
}
if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) {
@ -624,7 +630,8 @@ async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment,
mimeType,
attributes,
thumb,
forceFile: shouldSendAsFile || undefined,
forceFile: shouldSendAsFile,
spoiler: shouldSendAsSpoiler,
});
}

View File

@ -251,7 +251,7 @@ export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) {
return {
photos: result.photos
.filter((photo): photo is GramJs.Photo => photo instanceof GramJs.Photo)
.map(buildApiPhoto),
.map((photo) => buildApiPhoto(photo)),
users: result.users.map(buildApiUser).filter(Boolean),
};
}

View File

@ -28,6 +28,7 @@ export interface ApiPhoto {
sizes: ApiPhotoSize[];
videoSizes?: ApiVideoSize[];
blobUrl?: string;
isSpoiler?: boolean;
}
export interface ApiSticker {
@ -88,8 +89,10 @@ export interface ApiVideo {
supportsStreaming?: boolean;
isRound?: boolean;
isGif?: boolean;
isSpoiler?: boolean;
thumbnail?: ApiThumbnail;
blobUrl?: string;
previewBlobUrl?: string;
size: number;
}

View File

@ -46,7 +46,8 @@ export interface ApiAttachment {
};
previewBlobUrl?: string;
shouldSendAsFile?: boolean;
shouldSendAsFile?: true;
shouldSendAsSpoiler?: true;
uniqueId?: string;
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,6 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<filter id="blurFilter">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" />
</filter>
<circle cx="50" cy="50" r="50" filter="url(#blurFilter)" />
</svg>

After

Width:  |  Height:  |  Size: 230 B

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -36,7 +36,7 @@
bottom: 0.625rem;
}
.pictogram {
.embedded-thumb {
margin-inline-start: 0.5rem;
}
@ -120,19 +120,27 @@
opacity: 0.75;
}
.pictogram {
.embedded-thumb {
position: relative;
width: 2rem;
height: 2rem;
object-fit: cover;
border-radius: 0.25rem;
margin-left: 0.25rem;
flex-shrink: 0;
overflow: hidden;
&.round {
border-radius: 1rem;
}
}
.pictogram {
width: 100%;
height: 100%;
object-fit: cover;
}
&.inside-input {
padding-inline-start: 0.5625rem;
width: 100%;
@ -144,7 +152,7 @@
bottom: 0.3125rem;
}
.pictogram {
.embedded-thumb {
margin-left: 0.125rem;
}

View File

@ -11,6 +11,7 @@ import {
getSenderTitle,
getMessageRoundVideo,
getUserColorKey,
getMessageIsSpoiler,
} from '../../global/helpers';
import renderText from './helpers/renderText';
import { getPictogramDimensions } from './helpers/mediaDimensions';
@ -24,6 +25,7 @@ import useLang from '../../hooks/useLang';
import ActionMessage from '../middle/ActionMessage';
import MessageSummary from './MessageSummary';
import MediaSpoiler from './MediaSpoiler';
import './EmbeddedMessage.scss';
@ -63,6 +65,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
const mediaBlobUrl = useMedia(message && getMessageMediaHash(message, 'pictogram'), !isIntersecting);
const mediaThumbnail = useThumbnail(message);
const isRoundVideo = Boolean(message && getMessageRoundVideo(message));
const isSpoiler = Boolean(message && getMessageIsSpoiler(message));
const lang = useLang();
@ -78,7 +81,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
)}
onClick={message ? onClick : undefined}
>
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected)}
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}
<div className="message-text">
<p dir="auto">
{!message ? (
@ -112,21 +115,27 @@ function renderPictogram(
blobUrl?: string,
isRoundVideo?: boolean,
isProtected?: boolean,
isSpoiler?: boolean,
) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
return (
<>
<img
src={blobUrl || thumbDataUri}
width={width}
height={height}
alt=""
className={buildClassName('pictogram', isRoundVideo && 'round')}
draggable={!isProtected}
/>
<div className={buildClassName('embedded-thumb', isRoundVideo && 'round')}>
{!isSpoiler && (
<img
src={srcUrl}
width={width}
height={height}
alt=""
className="pictogram"
draggable={false}
/>
)}
<MediaSpoiler thumbDataUri={srcUrl} isVisible={Boolean(isSpoiler)} width={width} height={height} />
{isProtected && <span className="protector" />}
</>
</div>
);
}

View File

@ -17,7 +17,7 @@
line-height: 1.125rem;
}
img {
.media-miniature {
position: absolute;
left: 0;
top: 0;

View File

@ -1,22 +1,27 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useCallback, useRef } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { formatMediaDuration } from '../../util/dateFormat';
import stopEvent from '../../util/stopEvent';
import {
getMessageHtmlId,
getMessageIsSpoiler,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessageVideo,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import useFlag from '../../hooks/useFlag';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import MediaSpoiler from './MediaSpoiler';
import './Media.scss';
type OwnProps = {
@ -44,9 +49,13 @@ const Media: FC<OwnProps> = ({
const video = getMessageVideo(message);
const hasSpoiler = getMessageIsSpoiler(message);
const [isSpoilerShown, , hideSpoiler] = useFlag(hasSpoiler);
const handleClick = useCallback(() => {
hideSpoiler();
onClick!(message.id, message.chatId);
}, [message.id, message.chatId, onClick]);
}, [hideSpoiler, message, onClick]);
return (
<div
@ -57,6 +66,7 @@ const Media: FC<OwnProps> = ({
>
<img
src={thumbDataUri}
className="media-miniature"
alt=""
draggable={!isProtected}
decoding="async"
@ -64,12 +74,19 @@ const Media: FC<OwnProps> = ({
/>
<img
src={mediaBlobUrl}
className={buildClassName('full-media', transitionClassNames)}
className={buildClassName('full-media', 'media-miniature', transitionClassNames)}
alt=""
draggable={!isProtected}
decoding="async"
onContextMenu={isProtected ? stopEvent : undefined}
/>
{hasSpoiler && (
<MediaSpoiler
thumbDataUri={mediaBlobUrl || thumbDataUri}
isVisible={isSpoilerShown}
className="media-spoiler"
/>
)}
{video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>}
{isProtected && <span className="protector" />}
</div>

View File

@ -0,0 +1,105 @@
.root {
position: absolute;
width: 100%;
height: 100%;
background-color: var(--color-text-secondary); // Fallback before canvas is prepared
--click-shift-x: 0px;
--click-shift-y: 0px;
}
.mask-animation:global(.closing) {
mask-image: url("../../assets/spoilers/mask.svg"), linear-gradient(#ffffff, #ffffff);
-webkit-mask-composite: destination-out;
mask-composite: exclude;
mask-position: calc(50% + var(--click-shift-x)) calc(50% + var(--click-shift-y)), center center;
mask-repeat: no-repeat;
animation: 500ms ease-in circle-cut forwards;
.dots {
transform: scale(1.2);
}
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
.dots {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
--background-url: url("../../assets/spoilers/turbulence_1x.png");
--background-size: 256px;
background: rgba(0, 0, 0, 0.25) var(--background-url);
background-size: var(--background-size) var(--background-size);
transition: transform 500ms ease-in;
transform-origin: calc(50% + var(--click-shift-x)) calc(50% + var(--click-shift-y));
@media (-webkit-min-device-pixel-ratio: 2) {
--background-url: url("../../assets/spoilers/turbulence_2x.png");
}
@media (-webkit-min-device-pixel-ratio: 3) {
--background-url: url("../../assets/spoilers/turbulence_3x.png");
}
--x-direction: var(--background-size);
--y-direction: 0;
animation: 20s linear infinite dots;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--background-url);
background-size: var(--background-size) var(--background-size);
--x-direction: 0;
--y-direction: var(--background-size);
animation: 20s linear -7s infinite dots;
}
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--background-url);
background-size: var(--background-size) var(--background-size);
--x-direction: calc(-1 * var(--background-size));
--y-direction: calc(-1 * var(--background-size));
animation: 20s linear -14s infinite dots;
}
}
@keyframes dots {
0% {
background-position: 0 0;
}
100% {
background-position: var(--x-direction) var(--y-direction);
}
}
@keyframes circle-cut {
0% {
mask-size: 0%, 100%;
}
100% {
mask-size: 350%, 100%;
}
}

View File

@ -0,0 +1,65 @@
import React, { memo, useCallback, useRef } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useShowTransition from '../../hooks/useShowTransition';
import styles from './MediaSpoiler.module.scss';
type OwnProps = {
isVisible: boolean;
withAnimation?: boolean;
thumbDataUri?: string;
width?: number;
height?: number;
className?: string;
};
const BLUR_RADIUS = 25;
const ANIMATION_DURATION = 500;
const MediaSpoiler: FC<OwnProps> = ({
isVisible,
withAnimation,
thumbDataUri,
className,
width,
height,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(
isVisible, undefined, true, withAnimation ? false : undefined, undefined, ANIMATION_DURATION,
);
const canvasRef = useCanvasBlur(thumbDataUri, !shouldRender, undefined, BLUR_RADIUS, width, height);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const shiftX = x - (rect.width / 2);
const shiftY = y - (rect.height / 2);
ref.current.setAttribute('style', `--click-shift-x: ${shiftX}px; --click-shift-y: ${shiftY}px`);
}, []);
if (!shouldRender) {
return undefined;
}
return (
<div
className={buildClassName(styles.root, transitionClassNames, className, withAnimation && styles.maskAnimation)}
ref={ref}
onClick={withAnimation ? handleClick : undefined}
>
<canvas ref={canvasRef} className={styles.canvas} width={width} height={height} />
<div className={styles.dots} />
</div>
);
};
export default memo(MediaSpoiler);

View File

@ -24,6 +24,7 @@ import telegramLogoPath from '../../assets/telegram-logo.svg';
import reactionThumbsPath from '../../assets/reaction-thumbs.png';
import lockPreviewPath from '../../assets/lock.png';
import monkeyPath from '../../assets/monkey.svg';
import spoilerMaskPath from '../../assets/spoilers/mask.svg';
export type UiLoaderPage =
'main'
@ -76,6 +77,7 @@ const preloadTasks = {
.then(preloadFonts),
preloadAvatars(),
preloadImage(reactionThumbsPath),
preloadImage(spoilerMaskPath),
]),
authPhoneNumber: () => Promise.all([
preloadFonts(),

View File

@ -250,6 +250,10 @@
margin-inline-end: 0.25rem;
}
.media-preview-spoiler {
filter: blur(1px);
}
.media-preview--image {
width: 1.25rem;
height: 1.25rem;
@ -257,6 +261,7 @@
border-radius: 0.125rem;
vertical-align: -0.25rem;
margin-inline-end: 0.25rem;
margin-inline-start: 0.125rem;
body.is-ios & {
width: 1.125rem;

View File

@ -12,6 +12,7 @@ import type { Thread } from '../../../../global/types';
import { ANIMATION_END_DELAY, CHAT_HEIGHT_PX } from '../../../../config';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import {
getMessageIsSpoiler,
getMessageMediaHash,
getMessageMediaThumbDataUri, getMessageRoundVideo,
getMessageSenderName, getMessageSticker, getMessageVideo, isActionMessage, isChatChannel,
@ -214,9 +215,17 @@ function renderSummary(
return messageSummary;
}
const isSpoiler = getMessageIsSpoiler(message);
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
<img
src={blobUrl}
alt=""
className={
buildClassName('media-preview--image', isRoundVideo && 'round', isSpoiler && 'media-preview-spoiler')
}
/>
{getMessageVideo(message) && <i className="icon-play" />}
{messageSummary}
</span>

View File

@ -53,13 +53,18 @@
}
}
.media-preview-spoiler {
filter: blur(1px);
}
.media-preview--image {
width: 1.25rem;
height: 1.25rem;
object-fit: cover;
border-radius: 0.125rem;
vertical-align: -0.25rem;
margin-right: 0.25rem;
margin-inline-start: 0.125rem;
margin-inline-end: 0.25rem;
}
.icon-play {

View File

@ -15,6 +15,7 @@ import {
getMessageVideo,
getMessageRoundVideo,
getMessageSticker,
getMessageIsSpoiler,
} from '../../../global/helpers';
import { selectChat, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
@ -120,9 +121,17 @@ function renderSummary(
return renderMessageSummary(lang, message, undefined, searchQuery);
}
const isSpoiler = getMessageIsSpoiler(message);
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
<img
src={blobUrl}
alt=""
className={
buildClassName('media-preview--image', isRoundVideo && 'round', isSpoiler && 'media-preview-spoiler')
}
/>
{getMessageVideo(message) && <i className="icon-play" />}
{renderMessageSummary(lang, message, true, searchQuery)}
</span>

View File

@ -5,7 +5,10 @@ import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types';
import { getPictogramDimensions } from '../common/helpers/mediaDimensions';
import { getMessageMediaHash, getMessageSingleInlineButton } from '../../global/helpers';
import {
getMessageIsSpoiler,
getMessageMediaHash, getMessageSingleInlineButton,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/environment';
import renderText from '../common/helpers/renderText';
@ -20,6 +23,7 @@ import ConfirmDialog from '../ui/ConfirmDialog';
import Button from '../ui/Button';
import PinnedMessageNavigation from './PinnedMessageNavigation';
import MessageSummary from '../common/MessageSummary';
import MediaSpoiler from '../common/MediaSpoiler';
type OwnProps = {
message: ApiMessage;
@ -40,6 +44,8 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const mediaThumbnail = useThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const isSpoiler = getMessageIsSpoiler(message);
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
const handleUnpinMessage = useCallback(() => {
@ -102,7 +108,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
count={count}
index={index}
/>
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl)}
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)}
<div className="message-text">
<div className="title" dir="auto">
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`}
@ -129,11 +135,15 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
);
};
function renderPictogram(thumbDataUri: string, blobUrl?: string) {
function renderPictogram(thumbDataUri: string, blobUrl?: string, isSpoiler?: boolean) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
return (
<img src={blobUrl || thumbDataUri} width={width} height={height} alt="" />
<div className="pinned-thumb">
{!isSpoiler && <img className="pinned-thumb-image" src={srcUrl} width={width} height={height} alt="" />}
<MediaSpoiler thumbDataUri={srcUrl} isVisible={Boolean(isSpoiler)} width={width} height={height} />
</div>
);
}

View File

@ -566,19 +566,26 @@
height: 1rem;
}
& > img {
.pinned-thumb {
position: relative;
width: 2.25rem;
height: 2.25rem;
object-fit: cover;
border-radius: 0.25rem;
margin-inline-start: 0.375rem;
margin-top: 0.125rem;
flex-shrink: 0;
border-radius: 0.25rem;
overflow: hidden;
& + .message-text {
max-width: 12rem;
}
}
.pinned-thumb-image {
object-fit: cover;
}
}
.HeaderActions {

View File

@ -3,6 +3,7 @@
.modal-dialog {
max-width: 26.25rem;
@media (max-width: 600px) {
max-height: 100%;
}
@ -10,20 +11,30 @@
.modal-header-condensed {
padding: 0.3125rem 0.5rem !important;
border-bottom: 1px solid var(--color-borders);
border-bottom: 1px solid transparent;
transition: border-color 250ms ease-in-out;
}
&.modal-header-border .modal-header-condensed {
border-bottom-color: var(--color-borders);
}
.modal-content {
padding: 0;
max-height: calc(100vh - 3.25rem);
display: flex;
flex-direction: column;
padding: 0 0 0.5rem;
// Full height - header - margins
max-height: calc(100vh - 3.25rem - 5rem);
overflow-x: auto;
}
.attachments-wrapper {
max-height: 26rem;
min-height: 13rem;
overflow: auto;
flex-shrink: 0;
flex-shrink: 1;
display: flex;
flex-wrap: wrap;
@ -46,7 +57,9 @@
left: -0.5rem;
top: 0;
right: -0.5rem;
border-top: 1px solid var(--color-borders);
border-top: 1px solid transparent;
transition: border-color 250ms ease-in-out;
}
.form-control {
@ -59,8 +72,13 @@
}
}
.caption-top-border::before {
border-top-color: var(--color-borders);
}
.attachment-caption {
display: flex;
align-items: center;
gap: 0.5rem;
}
@ -69,6 +87,9 @@
}
.drop-target {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
@ -140,12 +161,12 @@
.AttachmentModal--send-wrapper {
align-self: flex-end;
margin-right: 0.25rem;
padding-bottom: 0.25rem;
margin-right: 0.375rem;
}
.AttachmentModal--send {
height: 2.5rem;
padding: 0 1rem;
margin-bottom: 0.25rem;
}
}

View File

@ -32,6 +32,8 @@ import useFlag from '../../../hooks/useFlag';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import { useStateRef } from '../../../hooks/useStateRef';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import useAppLayout from '../../../hooks/useAppLayout';
import useScrolledState from '../../../hooks/useScrolledState';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
@ -57,9 +59,9 @@ export type OwnProps = {
shouldSuggestCompression?: boolean;
onCaptionUpdate: (html: string) => void;
onSend: (sendCompressed: boolean, sendGrouped: boolean) => void;
onFileAppend: (files: File[]) => void;
onDelete: (attachmentIndex: number) => void;
onClear: () => void;
onFileAppend: (files: File[], isSpoiler?: boolean) => void;
onAttachmentsUpdate: (attachments: ApiAttachment[]) => void;
onClear: NoneToVoidFunction;
onSendSilent: (sendCompressed: boolean, sendGrouped: boolean) => void;
onSendScheduled: (sendCompressed: boolean, sendGrouped: boolean) => void;
};
@ -99,10 +101,10 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
customEmojiForEmoji,
attachmentSettings,
shouldSuggestCompression,
onAttachmentsUpdate,
onCaptionUpdate,
onSend,
onFileAppend,
onDelete,
onClear,
onSendSilent,
onSendScheduled,
@ -121,6 +123,14 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
);
const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped);
const {
handleScroll: handleAttachmentsScroll,
isAtBeginning: areAttachmentsNotScrolled,
isAtEnd: areAttachmentsScrolledToBottom,
} = useScrolledState();
const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState();
const isOpen = Boolean(attachments.length);
const [isHovered, markHovered, unmarkHovered] = useFlag();
@ -131,6 +141,13 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
return [oneMedia, false];
}, [renderingAttachments]);
const [hasSpoiler, isEverySpoiler] = useMemo(() => {
const areAllSpoilers = Boolean(renderingAttachments?.every((a) => a.shouldSendAsSpoiler));
if (areAllSpoilers) return [true, true];
const hasOneSpoiler = Boolean(renderingAttachments?.some((a) => a.shouldSendAsSpoiler));
return [hasOneSpoiler, false];
}, [renderingAttachments]);
const {
isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers,
} = useMentionTooltip(
@ -240,9 +257,9 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const files = await getFilesFromDataTransferItems(dataTransfer.items);
if (files?.length) {
onFileAppend(files);
onFileAppend(files, isEverySpoiler);
}
}, [onFileAppend, unmarkHovered]);
}, [isEverySpoiler, onFileAppend, unmarkHovered]);
function handleDragOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault();
@ -258,14 +275,39 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const validatedFiles = validateFiles(files);
if (validatedFiles?.length) {
onFileAppend(validatedFiles);
onFileAppend(validatedFiles, isEverySpoiler);
}
}, [onFileAppend]);
}, [isEverySpoiler, onFileAppend]);
const handleDocumentSelect = useCallback(() => {
openSystemFilesDialog('*', (e) => handleFileSelect(e));
}, [handleFileSelect]);
const handleDelete = useCallback((index: number) => {
onAttachmentsUpdate(attachments.filter((a, i) => i !== index));
}, [attachments, onAttachmentsUpdate]);
const handleEnableSpoilers = useCallback(() => {
onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: true })));
}, [attachments, onAttachmentsUpdate]);
const handleDisableSpoilers = useCallback(() => {
onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: undefined })));
}, [attachments, onAttachmentsUpdate]);
const handleToggleSpoiler = useCallback((index: number) => {
onAttachmentsUpdate(attachments.map((attachment, i) => {
if (i === index) {
return {
...attachment,
shouldSendAsSpoiler: !attachment.shouldSendAsSpoiler || undefined,
};
}
return attachment;
}));
}, [attachments, onAttachmentsUpdate]);
const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen: isMenuOpen }) => (
<Button
@ -332,17 +374,32 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
>
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem>
{hasMedia && (
shouldSendCompressed ? (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')}
</MenuItem>
) : (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="photo" onClick={() => setShouldSendCompressed(true)}>
{isMultiple ? 'Send All as Media' : 'Send as Media'}
</MenuItem>
)
<>
{
shouldSendCompressed ? (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')}
</MenuItem>
) : (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="photo" onClick={() => setShouldSendCompressed(true)}>
{isMultiple ? 'Send All as Media' : 'Send as Media'}
</MenuItem>
)
}
{shouldSendCompressed && (
hasSpoiler ? (
<MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}>
{lang('Attachment.DisableSpoiler')}
</MenuItem>
) : (
<MenuItem icon="spoiler" onClick={handleEnableSpoilers}>
{lang('Attachment.EnableSpoiler')}
</MenuItem>
)
)}
</>
)}
{isMultiple && (
shouldSendGrouped ? (
@ -370,7 +427,11 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
isOpen={isOpen}
onClose={onClear}
header={renderHeader()}
className={`AttachmentModal ${isHovered ? 'hovered' : ''}`}
className={buildClassName(
'AttachmentModal',
isHovered && 'hovered',
!areAttachmentsNotScrolled && 'modal-header-border',
)}
noBackdropClose
>
<div
@ -383,7 +444,10 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
data-attach-description={lang('Preview.Dragging.AddItems', 10)}
data-dropzone
>
<div className={buildClassName('attachments-wrapper', 'custom-scroll')}>
<div
className="attachments-wrapper custom-scroll"
onScroll={handleAttachmentsScroll}
>
{renderingAttachments.map((attachment, i) => (
<AttachmentModalItem
attachment={attachment}
@ -393,11 +457,17 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
isSingle={renderingAttachments.length === 1}
index={i}
key={attachment.uniqueId || i}
onDelete={onDelete}
onDelete={handleDelete}
onToggleSpoiler={handleToggleSpoiler}
/>
))}
</div>
<div className="attachment-caption-wrapper">
<div
className={buildClassName(
'attachment-caption-wrapper',
(!areAttachmentsScrolledToBottom || !isCaptionNotScrolled) && 'caption-top-border',
)}
>
<MentionTooltip
isOpen={isMentionTooltipOpen}
onClose={closeMentionTooltip}
@ -431,6 +501,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
placeholder={lang('AddCaption')}
onUpdate={onCaptionUpdate}
onSend={handleSendClick}
onScroll={handleCaptionScroll}
canAutoFocus={Boolean(isReady && attachments.length)}
captionLimit={leftChars}
/>

View File

@ -5,13 +5,15 @@
display: flex;
align-items: center;
border-radius: var(--border-radius-default);
overflow: hidden;
}
.preview {
width: 100%;
height: 12rem;
object-fit: cover;
border-radius: var(--border-radius-default);
}
.duration {
@ -51,6 +53,8 @@
background-color: rgba(0, 0, 0, 0.25);
border-radius: var(--border-radius-default);
display: flex;
overflow: hidden;
backdrop-filter: blur(10px);

View File

@ -1,4 +1,4 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment } from '../../../api/types';
@ -7,8 +7,10 @@ import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES } from '..
import { getFileExtension } from '../../common/helpers/documentInfo';
import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dateFormat';
import { REM } from '../../common/helpers/mediaDimensions';
import File from '../../common/File';
import MediaSpoiler from '../../common/MediaSpoiler';
import styles from './AttachmentModalItem.module.scss';
@ -20,8 +22,11 @@ type OwnProps = {
isSingle?: boolean;
index: number;
onDelete?: (index: number) => void;
onToggleSpoiler?: (index: number) => void;
};
const BLUR_CANVAS_SIZE = 15 * REM;
const AttachmentModalItem: FC<OwnProps> = ({
attachment,
className,
@ -30,9 +35,14 @@ const AttachmentModalItem: FC<OwnProps> = ({
shouldDisplayGrouped,
index,
onDelete,
onToggleSpoiler,
}) => {
const displayType = getDisplayType(attachment, shouldDisplayCompressed);
const handleSpoilerClick = useCallback(() => {
onToggleSpoiler?.(index);
}, [index, onToggleSpoiler]);
const content = useMemo(() => {
switch (displayType) {
case 'image':
@ -83,7 +93,8 @@ const AttachmentModalItem: FC<OwnProps> = ({
}, [attachment, displayType, index, onDelete]);
const shouldSkipGrouping = displayType === 'file' || !shouldDisplayGrouped;
const shouldRenderOverlay = displayType !== 'file' && onDelete;
const shouldDisplaySpoiler = Boolean(displayType !== 'file' && attachment.shouldSendAsSpoiler);
const shouldRenderOverlay = displayType !== 'file';
const rootClassName = buildClassName(
className, styles.root, isSingle && styles.single, shouldSkipGrouping && styles.noGrouping,
@ -91,14 +102,27 @@ const AttachmentModalItem: FC<OwnProps> = ({
return (
<div className={rootClassName}>
{content}
<MediaSpoiler
isVisible={shouldDisplaySpoiler}
thumbDataUri={attachment.previewBlobUrl || attachment.blobUrl}
width={BLUR_CANVAS_SIZE}
height={BLUR_CANVAS_SIZE}
/>
{shouldRenderOverlay && (
<div className={styles.overlay}>
<i
className={buildClassName(
attachment.shouldSendAsSpoiler ? 'icon-spoiler-disable' : 'icon-spoiler',
styles.actionItem,
)}
onClick={handleSpoilerClick}
/>
{onDelete && (
<i className={buildClassName('icon-delete', styles.actionItem)} onClick={() => onDelete(index)} />
)}
</div>
)}
{content}
</div>
);
};

View File

@ -604,7 +604,7 @@
min-height: 3rem;
max-height: 15rem;
margin-right: -5.5625rem;
margin-right: -5.625rem;
&:has(.form-control:focus) {
border-color: var(--color-primary);

View File

@ -349,7 +349,6 @@ const Composer: FC<OwnProps & StateProps> = ({
handleFileSelect,
onCaptionUpdate,
handleClearAttachments,
handleDeleteAttachment,
handleSetAttachments,
} = useAttachmentModal({
attachments,
@ -1196,7 +1195,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onSendScheduled={handleSendScheduledAttachments}
onFileAppend={handleAppendFiles}
onClear={handleClearAttachments}
onDelete={handleDeleteAttachment}
onAttachmentsUpdate={handleSetAttachments}
/>
<PollModal
isOpen={pollModal.isOpen}

View File

@ -52,6 +52,7 @@ type OwnProps = {
onUpdate: (html: string) => void;
onSuppressedFocus?: () => void;
onSend: () => void;
onScroll?: (event: React.UIEvent<HTMLElement>) => void;
captionLimit?: number;
};
@ -105,6 +106,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onUpdate,
onSuppressedFocus,
onSend,
onScroll,
}) => {
const {
editLastMessage,
@ -473,7 +475,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
return (
<div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={lang.isRtl ? 'rtl' : undefined}>
<div className={buildClassName('custom-scroll', SCROLLER_CLASS)}>
<div className={buildClassName('custom-scroll', SCROLLER_CLASS)} onScroll={onScroll}>
<div className="input-scroller-content">
<div
ref={inputRef}

View File

@ -77,6 +77,10 @@ export default async function buildAttachment(
export function prepareAttachmentsToSend(attachments: ApiAttachment[], shouldSendCompressed?: boolean) {
return !shouldSendCompressed
? attachments.map((attachment) => ({ ...attachment, shouldSendAsFile: !attachment.voice ? true : undefined }))
? attachments.map((attachment) => ({
...attachment,
shouldSendAsFile: !attachment.voice ? true : undefined,
shouldSendAsSpoiler: undefined,
}))
: attachments;
}

View File

@ -24,14 +24,14 @@ export default function useAttachmentModal({
setAttachments(MEMO_EMPTY_ARRAY);
}, [setAttachments]);
const handleDeleteAttachment = useCallback((index: number) => {
const newAttachments = attachments.filter((_, i) => i !== index);
setAttachments(newAttachments?.length ? newAttachments : MEMO_EMPTY_ARRAY);
}, [attachments, setAttachments]);
const handleSetAttachments = useCallback(
(newValue: ApiAttachment[] | ((current: ApiAttachment[]) => ApiAttachment[])) => {
const newAttachments = typeof newValue === 'function' ? newValue(attachments) : newValue;
if (!newAttachments.length) {
setAttachments(MEMO_EMPTY_ARRAY);
return;
}
if (newAttachments.some(({ size }) => size > fileSizeLimit)) {
openLimitReachedModal({
limit: 'uploadMaxFileparts',
@ -42,10 +42,12 @@ export default function useAttachmentModal({
}, [attachments, fileSizeLimit, openLimitReachedModal, setAttachments],
);
const handleAppendFiles = useCallback(async (files: File[]) => {
const handleAppendFiles = useCallback(async (files: File[], isSpoiler?: boolean) => {
handleSetAttachments([
...attachments,
...await Promise.all(files.map((file) => buildAttachment(file.name, file))),
...await Promise.all(files.map((file) => (
buildAttachment(file.name, file, { shouldSendAsSpoiler: isSpoiler || undefined })
))),
]);
}, [attachments, handleSetAttachments]);
@ -60,7 +62,6 @@ export default function useAttachmentModal({
handleFileSelect,
onCaptionUpdate: setHtml,
handleClearAttachments,
handleDeleteAttachment,
handleSetAttachments,
};
}

View File

@ -6,62 +6,6 @@
cursor: pointer;
}
.dots {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
--background-url: url('../../../assets/turbulence_1x.png');
--background-size: 256px;
background: rgba(0, 0, 0, 0.25) var(--background-url);
background-size: var(--background-size) var(--background-size);
z-index: 1;
@media (-webkit-min-device-pixel-ratio: 2) {
--background-url: url('../../../assets/turbulence_2x.png');
}
@media (-webkit-min-device-pixel-ratio: 3) {
--background-url: url('../../../assets/turbulence_3x.png');
}
--x-direction: var(--background-size);
--y-direction: 0;
animation: 20s linear infinite dots;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--background-url);
background-size: var(--background-size) var(--background-size);
--x-direction: 0;
--y-direction: var(--background-size);
animation: 20s linear -7s infinite dots;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--background-url);
background-size: var(--background-size) var(--background-size);
--x-direction: calc(-1 * var(--background-size));
--y-direction: calc(-1 * var(--background-size));
animation: 20s linear -14s infinite dots;
}
}
.duration {
z-index: 2;
position: absolute;
@ -97,16 +41,6 @@
line-height: 1rem;
}
.canvas {
display: block;
max-width: 100%;
}
@keyframes dots {
0% {
background-position: 0 0;
}
100% {
background-position: var(--x-direction) var(--y-direction);
}
.spoiler {
position: static;
}

View File

@ -10,9 +10,10 @@ import { formatMediaDuration } from '../../../util/dateFormat';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useCanvasBlur from '../../../hooks/useCanvasBlur';
import useInterval from '../../../hooks/useInterval';
import MediaSpoiler from '../../common/MediaSpoiler';
import styles from './InvoiceMediaPreview.module.scss';
type OwnProps = {
@ -21,7 +22,6 @@ type OwnProps = {
};
const POLLING_INTERVAL = 30000;
const BLUR_RADIUS = 25;
const InvoiceMediaPreview: FC<OwnProps> = ({
message,
@ -49,8 +49,6 @@ const InvoiceMediaPreview: FC<OwnProps> = ({
width, height, thumbnail, duration,
} = extendedMedia!;
const canvasRef = useCanvasBlur(thumbnail?.dataUri, false, undefined, BLUR_RADIUS, width, height);
const handleClick = useCallback(() => {
openInvoice({
chatId,
@ -64,8 +62,13 @@ const InvoiceMediaPreview: FC<OwnProps> = ({
className={buildClassName(styles.root, 'media-inner')}
onClick={handleClick}
>
<canvas ref={canvasRef} className={styles.canvas} width={width} height={height} />
<div className={styles.dots} />
<MediaSpoiler
thumbDataUri={thumbnail?.dataUri}
width={width}
height={height}
isVisible
className={styles.spoiler}
/>
{Boolean(duration) && <div className={styles.duration}>{formatMediaDuration(duration)}</div>}
<div className={styles.buy}>
<i className={buildClassName('icon-lock', styles.lock)} />

View File

@ -16,6 +16,7 @@ import {
getMediaTransferState,
isOwnMessage,
getMessageMediaFormat,
getMessageMediaThumbDataUri,
} from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import getCustomAppendixBg from './helpers/getCustomAppendixBg';
@ -28,8 +29,10 @@ import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import usePrevious from '../../../hooks/usePrevious';
import useMediaTransition from '../../../hooks/useMediaTransition';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import useFlag from '../../../hooks/useFlag';
import ProgressSpinner from '../../ui/ProgressSpinner';
import MediaSpoiler from '../../common/MediaSpoiler';
export type OwnProps = {
id?: string;
@ -91,6 +94,9 @@ const Photo: FC<OwnProps> = ({
const noThumb = Boolean(fullMediaData);
const thumbRef = useBlurredMediaThumbRef(message, noThumb);
const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message);
const [isSpoilerShown, , hideSpoiler] = useFlag(photo.isSpoiler);
const {
loadProgress: downloadProgress,
@ -118,15 +124,22 @@ const Photo: FC<OwnProps> = ({
const handleClick = useCallback(() => {
if (isUploading) {
if (onCancelUpload) {
onCancelUpload(message);
}
} else if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (onClick) {
onClick(message.id);
onCancelUpload?.(message);
return;
}
}, [fullMediaData, isUploading, message, onCancelUpload, onClick]);
if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
return;
}
if (isSpoilerShown) {
hideSpoiler();
return;
}
onClick?.(message.id);
}, [fullMediaData, hideSpoiler, isSpoilerShown, isUploading, message, onCancelUpload, onClick]);
const isOwn = isOwnMessage(message);
useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => {
@ -184,6 +197,14 @@ const Photo: FC<OwnProps> = ({
</div>
)}
{shouldRenderDownloadButton && <i className={buildClassName('icon-download', downloadButtonClassNames)} />}
<MediaSpoiler
isVisible={isSpoilerShown}
withAnimation
thumbDataUri={thumbDataUri}
width={width}
height={height}
className="media-spoiler"
/>
{isTransferring && (
<span className="message-transfer-progress">{Math.round(transferProgress * 100)}%</span>
)}

View File

@ -30,6 +30,7 @@ import useFlag from '../../../hooks/useFlag';
import ProgressSpinner from '../../ui/ProgressSpinner';
import OptimizedVideo from '../../ui/OptimizedVideo';
import MediaSpoiler from '../../common/MediaSpoiler';
export type OwnProps = {
id?: string;
@ -74,6 +75,8 @@ const Video: FC<OwnProps> = ({
const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!;
const localBlobUrl = video.blobUrl;
const [isSpoilerShown, , hideSpoiler] = useFlag(video.isSpoiler);
const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading);
const isIntersectingForPlaying = (
useIsIntersecting(ref, observeIntersectionForPlaying)
@ -86,7 +89,7 @@ const Video: FC<OwnProps> = ({
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading && lastSyncTime);
const [isPlayAllowed, setIsPlayAllowed] = useState(canAutoPlay);
const [isPlayAllowed, setIsPlayAllowed] = useState(canAutoPlay && !isSpoilerShown);
const fullMediaHash = getMessageMediaHash(message, 'inline');
const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash)));
@ -96,7 +99,8 @@ const Video: FC<OwnProps> = ({
const fullMediaData = localBlobUrl || mediaData;
const [isPlayerReady, markPlayerReady] = useFlag();
const hasThumb = Boolean(getMessageMediaThumbDataUri(message));
const thumbDataUri = getMessageMediaThumbDataUri(message);
const hasThumb = Boolean(thumbDataUri);
const previewMediaHash = getMessageMediaHash(message, 'preview');
const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash)));
@ -147,19 +151,34 @@ const Video: FC<OwnProps> = ({
const handleClick = useCallback(() => {
if (isUploading) {
if (onCancelUpload) {
onCancelUpload(message);
}
} else if (isDownloading) {
getActions().cancelMessageMediaDownload({ message });
} else if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (fullMediaData && !isPlayAllowed) {
setIsPlayAllowed(true);
} else if (onClick) {
onClick(message.id);
onCancelUpload?.(message);
return;
}
}, [isUploading, isDownloading, fullMediaData, isPlayAllowed, onClick, onCancelUpload, message]);
if (isDownloading) {
getActions().cancelMessageMediaDownload({ message });
return;
}
if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
return;
}
if (fullMediaData && !isPlayAllowed) {
setIsPlayAllowed(true);
}
if (isSpoilerShown) {
hideSpoiler();
return;
}
onClick?.(message.id);
}, [
isUploading, isDownloading, fullMediaData, isPlayAllowed, isSpoilerShown, onClick, message, onCancelUpload,
hideSpoiler,
]);
const className = buildClassName('media-inner dark', !isUploading && 'interactive');
@ -202,6 +221,14 @@ const Video: FC<OwnProps> = ({
)}
{isProtected && <span className="protector" />}
<i className={buildClassName('icon-large-play', playButtonClassNames)} />
<MediaSpoiler
isVisible={isSpoilerShown}
withAnimation
thumbDataUri={thumbDataUri}
width={width}
height={height}
className="media-spoiler"
/>
{shouldRenderSpinner && (
<div className={buildClassName('media-loading', spinnerClassNames)}>
<ProgressSpinner progress={transferProgress} onClick={handleClick} />

View File

@ -134,7 +134,8 @@ export function getMessageMediaThumbnail(message: ApiMessage) {
|| getMessageDocument(message)
|| getMessageSticker(message)
|| getMessageWebPagePhoto(message)
|| getMessageWebPageVideo(message);
|| getMessageWebPageVideo(message)
|| getMessageInvoice(message)?.extendedMedia;
if (!media) {
return undefined;
@ -147,6 +148,14 @@ export function getMessageMediaThumbDataUri(message: ApiMessage) {
return getMessageMediaThumbnail(message)?.dataUri;
}
export function getMessageIsSpoiler(message: ApiMessage) {
const media = getMessagePhoto(message)
|| getMessageVideo(message);
const invoiceMedia = getMessageInvoice(message)?.extendedMedia;
return Boolean(invoiceMedia || media?.isSpoiler);
}
export function getDocumentMediaHash(document: ApiDocument) {
return `document${document.id}`;
}

View File

@ -2,6 +2,7 @@ import { useEffect, useRef } from '../lib/teact/teact';
import { IS_CANVAS_FILTER_SUPPORTED } from '../util/environment';
import fastBlur from '../lib/fastBlur';
import useOnChange from './useOnChange';
const RADIUS = 2;
const ITERATIONS = 2;
@ -18,6 +19,12 @@ export default function useCanvasBlur(
const canvasRef = useRef<HTMLCanvasElement>(null);
const isStarted = useRef();
useOnChange(() => {
if (!isDisabled) {
isStarted.current = false;
}
}, [dataUri, isDisabled]);
useEffect(() => {
const canvas = canvasRef.current;

View File

@ -0,0 +1,17 @@
import { useCallback, useState } from '../lib/teact/teact';
const THRESHOLD = 5;
export default function useScrolledState(threshold = THRESHOLD) {
const [isAtBeginning, setIsAtBeginning] = useState(true);
const [isAtEnd, setIsAtEnd] = useState(true);
const handleScroll = useCallback((e: React.UIEvent<HTMLElement>) => {
const { scrollHeight, scrollTop, clientHeight } = e.target as HTMLElement;
setIsAtBeginning(scrollTop < threshold);
setIsAtEnd(scrollHeight - scrollTop - clientHeight < threshold);
}, [threshold]);
return { isAtBeginning, isAtEnd, handleScroll };
}

View File

@ -9,6 +9,7 @@ const useShowTransition = (
noOpenTransition = false,
className: string | false = 'fast',
noCloseTransition = false,
closeDuration = CLOSE_DURATION,
) => {
const [isClosed, setIsClosed] = useState(!isOpen);
const closeTimeoutRef = useRef<number>();
@ -40,7 +41,7 @@ const useShowTransition = (
if (noCloseTransition) {
exec();
} else {
closeTimeoutRef.current = window.setTimeout(exec, CLOSE_DURATION);
closeTimeoutRef.current = window.setTimeout(exec, closeDuration);
}
}
}

View File

@ -2068,21 +2068,19 @@
{
"id": 101,
"paths": [
"M660.48 128c0 25.6-20.48 40.96-40.96 40.96-25.6 0-40.96-20.48-40.96-40.96 0-25.6 20.48-40.96 40.96-40.96s40.96 15.36 40.96 40.96zM619.52 363.52c30.72 0 51.2-25.6 51.2-51.2 0-30.72-25.6-51.2-51.2-51.2-30.72 0-51.2 25.6-51.2 51.2-5.12 25.6 20.48 51.2 51.2 51.2zM788.48 563.2c30.72 0 51.2-25.6 51.2-51.2 0-30.72-25.6-51.2-51.2-51.2-30.72 0-51.2 25.6-51.2 51.2 0 30.72 20.48 51.2 51.2 51.2zM983.040 512c0 25.6-20.48 40.96-40.96 40.96-25.6 0-40.96-20.48-40.96-40.96 0-25.6 20.48-40.96 40.96-40.96s40.96 15.36 40.96 40.96zM619.52 936.96c25.6 0 40.96-20.48 40.96-40.96s-20.48-40.96-40.96-40.96c-25.6 0-40.96 20.48-40.96 40.96s15.36 40.96 40.96 40.96zM788.48 353.28c25.6 0 40.96-20.48 40.96-40.96 0-25.6-20.48-40.96-40.96-40.96-25.6 0-40.96 20.48-40.96 40.96s20.48 40.96 40.96 40.96zM819.2 128c0 15.36-15.36 30.72-30.72 30.72s-30.72-15.36-30.72-30.72c0-15.36 15.36-30.72 30.72-30.72 20.48 0 30.72 15.36 30.72 30.72zM936.96 343.040c20.48 0 35.84-15.36 35.84-35.84s-15.36-30.72-30.72-30.72c-15.36 0-30.72 15.36-30.72 30.72s10.24 35.84 25.6 35.84zM972.8 727.040c0 15.36-15.36 30.72-30.72 30.72s-30.72-15.36-30.72-30.72c0-15.36 15.36-30.72 30.72-30.72 15.36-5.12 30.72 10.24 30.72 30.72zM936.96 148.48c10.24 0 20.48-10.24 20.48-20.48s-10.24-20.48-20.48-20.48c-10.24 0-20.48 10.24-20.48 20.48s10.24 20.48 20.48 20.48zM399.36 87.040v0 0c-46.080 0-87.040 0-117.76 0-25.6 5.12-56.32 10.24-81.92 20.48-30.72 15.36-56.32 40.96-76.8 71.68l61.44 61.44c0 0 0 0 0-5.12 10.24-25.6 30.72-46.080 56.32-56.32 10.24-5.12 25.6-10.24 51.2-10.24s61.44 0 107.52 0h66.56c25.6-0 46.080-15.36 46.080-40.96s-20.48-40.96-40.96-40.96h-71.68zM174.080 291.84c0 25.6 0 61.44 0 107.52v220.16c0 46.080 0 81.92 0 107.52 0 5.12 0 5.12 0 5.12l66.56-107.52c15.36-20.48 25.6-40.96 35.84-56.32s25.6-30.72 46.080-35.84c25.6-10.24 56.32-10.24 87.040 0 20.48 10.24 35.84 25.6 46.080 35.84 10.24 15.36 20.48 35.84 35.84 56.32v0l5.12 5.12c10.24 20.48 5.12 46.080-15.36 56.32s-46.080 5.12-56.32-15.36l-5.12-5.12c-15.36-25.6-25.6-40.96-30.72-51.2-15.36 5.12-15.36 0-20.48 0s-10.24 0-15.36 0c0 0-5.12 0-10.24 10.24-10.24 10.24-15.36 25.6-30.72 51.2l-92.16 148.48c5.12 5.12 15.36 10.24 20.48 15.36 10.24 5.12 25.6 10.24 51.2 10.24s61.44 0 107.52 0h66.56c25.6 0 40.96 20.48 40.96 40.96 0 25.6-20.48 40.96-40.96 40.96h-66.56c-46.080 0-81.92 0-112.64 0s-56.32-10.24-81.92-20.48c-40.96-20.48-71.68-51.2-92.16-92.16-15.36-25.6-20.48-51.2-20.48-81.92s0-66.56 0-112.64v0-225.28c0-46.080 0-81.92 0-112.64 0-25.6 5.12-46.080 10.24-66.56l71.68 71.68z",
"M128 128l768 768z",
"M896 936.96c-10.24 0-20.48-5.12-30.72-10.24l-768-768c-15.36-15.36-15.36-46.080 0-61.44s46.080-15.36 61.44 0l768 768c15.36 15.36 15.36 46.080 0 61.44-10.24 10.24-20.48 10.24-30.72 10.24z"
"M465.92 849.92h-174.080c-25.6 0-40.96-5.12-51.2-10.24-5.12-5.12-15.36-10.24-20.48-15.36l92.16-148.48c15.36-25.6 20.48-40.96 30.72-51.2 5.12-10.24 10.24-10.24 10.24-10.24h15.36c5.12 0 5.12 5.12 20.48 0 5.12 10.24 15.36 25.6 30.72 51.2l5.12 5.12c10.24 20.48 35.84 25.6 56.32 15.36s25.6-35.84 15.36-56.32l-5.12-5.12c-15.36-20.48-25.6-40.96-35.84-56.32-10.24-10.24-25.6-25.6-46.080-35.84-30.72-10.24-61.44-10.24-87.040 0-20.48 5.12-35.84 20.48-46.080 35.84s-20.48 35.84-35.84 56.32l-66.56 107.52c0 0 0 0 0-5.12v-435.2l-71.68-71.68c-5.12 20.48-10.24 40.96-10.24 66.56v450.56c0 30.72 5.12 56.32 20.48 81.92 20.48 40.96 51.2 71.68 92.16 92.16 25.6 10.24 51.2 20.48 81.92 20.48h179.2c20.48 0 40.96-15.36 40.96-40.96 0-20.48-15.36-40.96-40.96-40.96zM936.96 107.52c-10.24 0-20.48 10.24-20.48 20.48s10.24 20.48 20.48 20.48 20.48-10.24 20.48-20.48-10.24-20.48-20.48-20.48zM942.080 696.32c-15.36 0-30.72 15.36-30.72 30.72s15.36 30.72 30.72 30.72c15.36 0 30.72-15.36 30.72-30.72 0-20.48-15.36-35.84-30.72-30.72zM942.080 276.48c-15.36 0-30.72 15.36-30.72 30.72s10.24 35.84 25.6 35.84c20.48 0 35.84-15.36 35.84-35.84s-15.36-30.72-30.72-30.72zM788.48 97.28c-15.36 0-30.72 15.36-30.72 30.72s15.36 30.72 30.72 30.72c15.36 0 30.72-15.36 30.72-30.72s-10.24-30.72-30.72-30.72zM788.48 271.36c-25.6 0-40.96 20.48-40.96 40.96s20.48 40.96 40.96 40.96c25.6 0 40.96-20.48 40.96-40.96 0-25.6-20.48-40.96-40.96-40.96zM619.52 855.040c-25.6 0-40.96 20.48-40.96 40.96s15.36 40.96 40.96 40.96 40.96-20.48 40.96-40.96-20.48-40.96-40.96-40.96zM942.080 471.040c-20.48 0-40.96 15.36-40.96 40.96 0 20.48 15.36 40.96 40.96 40.96 20.48 0 40.96-15.36 40.96-40.96s-20.48-40.96-40.96-40.96zM788.48 460.8c-30.72 0-51.2 25.6-51.2 51.2 0 30.72 20.48 51.2 51.2 51.2s51.2-25.6 51.2-51.2c0-30.72-25.6-51.2-51.2-51.2zM619.52 261.12c-30.72 0-51.2 25.6-51.2 51.2-5.12 25.6 20.48 51.2 51.2 51.2s51.2-25.6 51.2-51.2c0-30.72-25.6-51.2-51.2-51.2zM619.52 87.040c-20.48 0-40.96 15.36-40.96 40.96 0 20.48 15.36 40.96 40.96 40.96 20.48 0 40.96-15.36 40.96-40.96s-20.48-40.96-40.96-40.96z",
"M926.72 926.72c-10.24 10.24-20.48 10.24-30.72 10.24s-20.48-5.12-30.72-10.24l-768-768c-15.36-15.36-15.36-46.080 0-61.44s46.080-15.36 61.44 0l20.48 20.48c5.12-5.12 10.24-10.24 20.48-10.24 25.6-10.24 56.32-15.36 81.92-20.48h189.44c20.48 0 40.96 15.36 40.96 40.96s-20.48 40.96-46.080 40.96h-174.080c-25.6 0-40.96 5.12-51.2 10.24l686.080 686.080c15.36 15.36 15.36 46.080 0 61.44z"
],
"attrs": [
{},
{},
{}
],
"isMulticolor": false,
"isMulticolor2": false,
"grid": 24,
"tags": [
"spoiler-disable"
]
],
"isMulticolor": false,
"isMulticolor2": false
},
{
"id": 100,
@ -4526,4 +4524,4 @@
"showLiga": false
},
"uid": -1
}
}