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 const sizes = photo.sizes
.filter((s: any): s is GramJs.PhotoSize => { .filter((s: any): s is GramJs.PhotoSize => {
return s instanceof GramJs.PhotoSize || s instanceof GramJs.PhotoSizeProgressive; return s instanceof GramJs.PhotoSize || s instanceof GramJs.PhotoSizeProgressive;
@ -73,6 +73,7 @@ export function buildApiPhoto(photo: GramJs.Photo): ApiPhoto {
id: String(photo.id), id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes), thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes, sizes,
isSpoiler,
...(photo.videoSizes && { videoSizes: photo.videoSizes.map(buildApiVideoSize), isVideo: true }), ...(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 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) { if (document instanceof GramJs.DocumentEmpty) {
return undefined; return undefined;
} }
@ -499,6 +499,7 @@ export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | un
isGif: Boolean(gifAttr), isGif: Boolean(gifAttr),
thumbnail: buildApiThumbnailFromStripped(thumbs), thumbnail: buildApiThumbnailFromStripped(thumbs),
size: size.toJSNumber(), size: size.toJSNumber(),
isSpoiler,
}; };
} }
@ -511,7 +512,7 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
return undefined; return undefined;
} }
return buildVideoFromDocument(media.document); return buildVideoFromDocument(media.document, media.spoiler);
} }
function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined { function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
@ -1393,6 +1394,7 @@ function buildUploadingMedia(
size, size,
audio, audio,
shouldSendAsFile, shouldSendAsFile,
shouldSendAsSpoiler,
} = attachment; } = attachment;
if (!shouldSendAsFile) { if (!shouldSendAsFile) {
@ -1403,8 +1405,9 @@ function buildUploadingMedia(
photo: { photo: {
id: LOCAL_MEDIA_UPLOADING_TEMP_ID, id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
sizes: [], sizes: [],
thumbnail: { width, height, dataUri: '' }, // Used only for dimensions thumbnail: { width, height, dataUri: blobUrl },
blobUrl, blobUrl,
isSpoiler: shouldSendAsSpoiler,
}, },
}; };
} }
@ -1421,6 +1424,7 @@ function buildUploadingMedia(
blobUrl, blobUrl,
...(previewBlobUrl && { thumbnail: { width, height, dataUri: previewBlobUrl } }), ...(previewBlobUrl && { thumbnail: { width, height, dataUri: previewBlobUrl } }),
size, size,
isSpoiler: shouldSendAsSpoiler,
}, },
}; };
} }

View File

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

View File

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

View File

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

View File

@ -46,7 +46,8 @@ export interface ApiAttachment {
}; };
previewBlobUrl?: string; previewBlobUrl?: string;
shouldSendAsFile?: boolean; shouldSendAsFile?: true;
shouldSendAsSpoiler?: true;
uniqueId?: string; 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; bottom: 0.625rem;
} }
.pictogram { .embedded-thumb {
margin-inline-start: 0.5rem; margin-inline-start: 0.5rem;
} }
@ -120,19 +120,27 @@
opacity: 0.75; opacity: 0.75;
} }
.pictogram { .embedded-thumb {
position: relative;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
object-fit: cover;
border-radius: 0.25rem; border-radius: 0.25rem;
margin-left: 0.25rem; margin-left: 0.25rem;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
&.round { &.round {
border-radius: 1rem; border-radius: 1rem;
} }
} }
.pictogram {
width: 100%;
height: 100%;
object-fit: cover;
}
&.inside-input { &.inside-input {
padding-inline-start: 0.5625rem; padding-inline-start: 0.5625rem;
width: 100%; width: 100%;
@ -144,7 +152,7 @@
bottom: 0.3125rem; bottom: 0.3125rem;
} }
.pictogram { .embedded-thumb {
margin-left: 0.125rem; margin-left: 0.125rem;
} }

View File

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

View File

@ -17,7 +17,7 @@
line-height: 1.125rem; line-height: 1.125rem;
} }
img { .media-miniature {
position: absolute; position: absolute;
left: 0; left: 0;
top: 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 React, { memo, useCallback, useRef } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { formatMediaDuration } from '../../util/dateFormat'; import { formatMediaDuration } from '../../util/dateFormat';
import stopEvent from '../../util/stopEvent'; import stopEvent from '../../util/stopEvent';
import { import {
getMessageHtmlId, getMessageHtmlId,
getMessageIsSpoiler,
getMessageMediaHash, getMessageMediaHash,
getMessageMediaThumbDataUri, getMessageMediaThumbDataUri,
getMessageVideo, getMessageVideo,
} from '../../global/helpers'; } from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition'; import useMediaTransition from '../../hooks/useMediaTransition';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import useFlag from '../../hooks/useFlag';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import MediaSpoiler from './MediaSpoiler';
import './Media.scss'; import './Media.scss';
type OwnProps = { type OwnProps = {
@ -44,9 +49,13 @@ const Media: FC<OwnProps> = ({
const video = getMessageVideo(message); const video = getMessageVideo(message);
const hasSpoiler = getMessageIsSpoiler(message);
const [isSpoilerShown, , hideSpoiler] = useFlag(hasSpoiler);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
hideSpoiler();
onClick!(message.id, message.chatId); onClick!(message.id, message.chatId);
}, [message.id, message.chatId, onClick]); }, [hideSpoiler, message, onClick]);
return ( return (
<div <div
@ -57,6 +66,7 @@ const Media: FC<OwnProps> = ({
> >
<img <img
src={thumbDataUri} src={thumbDataUri}
className="media-miniature"
alt="" alt=""
draggable={!isProtected} draggable={!isProtected}
decoding="async" decoding="async"
@ -64,12 +74,19 @@ const Media: FC<OwnProps> = ({
/> />
<img <img
src={mediaBlobUrl} src={mediaBlobUrl}
className={buildClassName('full-media', transitionClassNames)} className={buildClassName('full-media', 'media-miniature', transitionClassNames)}
alt="" alt=""
draggable={!isProtected} draggable={!isProtected}
decoding="async" decoding="async"
onContextMenu={isProtected ? stopEvent : undefined} 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>} {video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>}
{isProtected && <span className="protector" />} {isProtected && <span className="protector" />}
</div> </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 reactionThumbsPath from '../../assets/reaction-thumbs.png';
import lockPreviewPath from '../../assets/lock.png'; import lockPreviewPath from '../../assets/lock.png';
import monkeyPath from '../../assets/monkey.svg'; import monkeyPath from '../../assets/monkey.svg';
import spoilerMaskPath from '../../assets/spoilers/mask.svg';
export type UiLoaderPage = export type UiLoaderPage =
'main' 'main'
@ -76,6 +77,7 @@ const preloadTasks = {
.then(preloadFonts), .then(preloadFonts),
preloadAvatars(), preloadAvatars(),
preloadImage(reactionThumbsPath), preloadImage(reactionThumbsPath),
preloadImage(spoilerMaskPath),
]), ]),
authPhoneNumber: () => Promise.all([ authPhoneNumber: () => Promise.all([
preloadFonts(), preloadFonts(),

View File

@ -250,6 +250,10 @@
margin-inline-end: 0.25rem; margin-inline-end: 0.25rem;
} }
.media-preview-spoiler {
filter: blur(1px);
}
.media-preview--image { .media-preview--image {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@ -257,6 +261,7 @@
border-radius: 0.125rem; border-radius: 0.125rem;
vertical-align: -0.25rem; vertical-align: -0.25rem;
margin-inline-end: 0.25rem; margin-inline-end: 0.25rem;
margin-inline-start: 0.125rem;
body.is-ios & { body.is-ios & {
width: 1.125rem; 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 { ANIMATION_END_DELAY, CHAT_HEIGHT_PX } from '../../../../config';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import { import {
getMessageIsSpoiler,
getMessageMediaHash, getMessageMediaHash,
getMessageMediaThumbDataUri, getMessageRoundVideo, getMessageMediaThumbDataUri, getMessageRoundVideo,
getMessageSenderName, getMessageSticker, getMessageVideo, isActionMessage, isChatChannel, getMessageSenderName, getMessageSticker, getMessageVideo, isActionMessage, isChatChannel,
@ -214,9 +215,17 @@ function renderSummary(
return messageSummary; return messageSummary;
} }
const isSpoiler = getMessageIsSpoiler(message);
return ( return (
<span className="media-preview"> <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" />} {getMessageVideo(message) && <i className="icon-play" />}
{messageSummary} {messageSummary}
</span> </span>

View File

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

View File

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

View File

@ -5,7 +5,10 @@ import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import { getPictogramDimensions } from '../common/helpers/mediaDimensions'; 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 buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/environment'; import { IS_TOUCH_ENV } from '../../util/environment';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
@ -20,6 +23,7 @@ import ConfirmDialog from '../ui/ConfirmDialog';
import Button from '../ui/Button'; import Button from '../ui/Button';
import PinnedMessageNavigation from './PinnedMessageNavigation'; import PinnedMessageNavigation from './PinnedMessageNavigation';
import MessageSummary from '../common/MessageSummary'; import MessageSummary from '../common/MessageSummary';
import MediaSpoiler from '../common/MediaSpoiler';
type OwnProps = { type OwnProps = {
message: ApiMessage; message: ApiMessage;
@ -40,6 +44,8 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const mediaThumbnail = useThumbnail(message); const mediaThumbnail = useThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram')); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const isSpoiler = getMessageIsSpoiler(message);
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
const handleUnpinMessage = useCallback(() => { const handleUnpinMessage = useCallback(() => {
@ -102,7 +108,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
count={count} count={count}
index={index} index={index}
/> />
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl)} {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)}
<div className="message-text"> <div className="message-text">
<div className="title" dir="auto"> <div className="title" dir="auto">
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} {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 { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
return ( 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; height: 1rem;
} }
& > img { .pinned-thumb {
position: relative;
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
object-fit: cover;
border-radius: 0.25rem;
margin-inline-start: 0.375rem; margin-inline-start: 0.375rem;
margin-top: 0.125rem; margin-top: 0.125rem;
flex-shrink: 0; flex-shrink: 0;
border-radius: 0.25rem;
overflow: hidden;
& + .message-text { & + .message-text {
max-width: 12rem; max-width: 12rem;
} }
} }
.pinned-thumb-image {
object-fit: cover;
}
} }
.HeaderActions { .HeaderActions {

View File

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

View File

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

View File

@ -5,13 +5,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
border-radius: var(--border-radius-default);
overflow: hidden;
} }
.preview { .preview {
width: 100%; width: 100%;
height: 12rem; height: 12rem;
object-fit: cover; object-fit: cover;
border-radius: var(--border-radius-default);
} }
.duration { .duration {
@ -51,6 +53,8 @@
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
border-radius: var(--border-radius-default); border-radius: var(--border-radius-default);
display: flex;
overflow: hidden; overflow: hidden;
backdrop-filter: blur(10px); 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 { FC } from '../../../lib/teact/teact';
import type { ApiAttachment } from '../../../api/types'; 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 { getFileExtension } from '../../common/helpers/documentInfo';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dateFormat'; import { formatMediaDuration } from '../../../util/dateFormat';
import { REM } from '../../common/helpers/mediaDimensions';
import File from '../../common/File'; import File from '../../common/File';
import MediaSpoiler from '../../common/MediaSpoiler';
import styles from './AttachmentModalItem.module.scss'; import styles from './AttachmentModalItem.module.scss';
@ -20,8 +22,11 @@ type OwnProps = {
isSingle?: boolean; isSingle?: boolean;
index: number; index: number;
onDelete?: (index: number) => void; onDelete?: (index: number) => void;
onToggleSpoiler?: (index: number) => void;
}; };
const BLUR_CANVAS_SIZE = 15 * REM;
const AttachmentModalItem: FC<OwnProps> = ({ const AttachmentModalItem: FC<OwnProps> = ({
attachment, attachment,
className, className,
@ -30,9 +35,14 @@ const AttachmentModalItem: FC<OwnProps> = ({
shouldDisplayGrouped, shouldDisplayGrouped,
index, index,
onDelete, onDelete,
onToggleSpoiler,
}) => { }) => {
const displayType = getDisplayType(attachment, shouldDisplayCompressed); const displayType = getDisplayType(attachment, shouldDisplayCompressed);
const handleSpoilerClick = useCallback(() => {
onToggleSpoiler?.(index);
}, [index, onToggleSpoiler]);
const content = useMemo(() => { const content = useMemo(() => {
switch (displayType) { switch (displayType) {
case 'image': case 'image':
@ -83,7 +93,8 @@ const AttachmentModalItem: FC<OwnProps> = ({
}, [attachment, displayType, index, onDelete]); }, [attachment, displayType, index, onDelete]);
const shouldSkipGrouping = displayType === 'file' || !shouldDisplayGrouped; const shouldSkipGrouping = displayType === 'file' || !shouldDisplayGrouped;
const shouldRenderOverlay = displayType !== 'file' && onDelete; const shouldDisplaySpoiler = Boolean(displayType !== 'file' && attachment.shouldSendAsSpoiler);
const shouldRenderOverlay = displayType !== 'file';
const rootClassName = buildClassName( const rootClassName = buildClassName(
className, styles.root, isSingle && styles.single, shouldSkipGrouping && styles.noGrouping, className, styles.root, isSingle && styles.single, shouldSkipGrouping && styles.noGrouping,
@ -91,14 +102,27 @@ const AttachmentModalItem: FC<OwnProps> = ({
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
{content}
<MediaSpoiler
isVisible={shouldDisplaySpoiler}
thumbDataUri={attachment.previewBlobUrl || attachment.blobUrl}
width={BLUR_CANVAS_SIZE}
height={BLUR_CANVAS_SIZE}
/>
{shouldRenderOverlay && ( {shouldRenderOverlay && (
<div className={styles.overlay}> <div className={styles.overlay}>
<i
className={buildClassName(
attachment.shouldSendAsSpoiler ? 'icon-spoiler-disable' : 'icon-spoiler',
styles.actionItem,
)}
onClick={handleSpoilerClick}
/>
{onDelete && ( {onDelete && (
<i className={buildClassName('icon-delete', styles.actionItem)} onClick={() => onDelete(index)} /> <i className={buildClassName('icon-delete', styles.actionItem)} onClick={() => onDelete(index)} />
)} )}
</div> </div>
)} )}
{content}
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -24,14 +24,14 @@ export default function useAttachmentModal({
setAttachments(MEMO_EMPTY_ARRAY); setAttachments(MEMO_EMPTY_ARRAY);
}, [setAttachments]); }, [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( const handleSetAttachments = useCallback(
(newValue: ApiAttachment[] | ((current: ApiAttachment[]) => ApiAttachment[])) => { (newValue: ApiAttachment[] | ((current: ApiAttachment[]) => ApiAttachment[])) => {
const newAttachments = typeof newValue === 'function' ? newValue(attachments) : newValue; const newAttachments = typeof newValue === 'function' ? newValue(attachments) : newValue;
if (!newAttachments.length) {
setAttachments(MEMO_EMPTY_ARRAY);
return;
}
if (newAttachments.some(({ size }) => size > fileSizeLimit)) { if (newAttachments.some(({ size }) => size > fileSizeLimit)) {
openLimitReachedModal({ openLimitReachedModal({
limit: 'uploadMaxFileparts', limit: 'uploadMaxFileparts',
@ -42,10 +42,12 @@ export default function useAttachmentModal({
}, [attachments, fileSizeLimit, openLimitReachedModal, setAttachments], }, [attachments, fileSizeLimit, openLimitReachedModal, setAttachments],
); );
const handleAppendFiles = useCallback(async (files: File[]) => { const handleAppendFiles = useCallback(async (files: File[], isSpoiler?: boolean) => {
handleSetAttachments([ handleSetAttachments([
...attachments, ...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]); }, [attachments, handleSetAttachments]);
@ -60,7 +62,6 @@ export default function useAttachmentModal({
handleFileSelect, handleFileSelect,
onCaptionUpdate: setHtml, onCaptionUpdate: setHtml,
handleClearAttachments, handleClearAttachments,
handleDeleteAttachment,
handleSetAttachments, handleSetAttachments,
}; };
} }

View File

@ -6,62 +6,6 @@
cursor: pointer; 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 { .duration {
z-index: 2; z-index: 2;
position: absolute; position: absolute;
@ -97,16 +41,6 @@
line-height: 1rem; line-height: 1rem;
} }
.canvas { .spoiler {
display: block; position: static;
max-width: 100%;
}
@keyframes dots {
0% {
background-position: 0 0;
}
100% {
background-position: var(--x-direction) var(--y-direction);
}
} }

View File

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

View File

@ -16,6 +16,7 @@ import {
getMediaTransferState, getMediaTransferState,
isOwnMessage, isOwnMessage,
getMessageMediaFormat, getMessageMediaFormat,
getMessageMediaThumbDataUri,
} from '../../../global/helpers'; } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import getCustomAppendixBg from './helpers/getCustomAppendixBg'; import getCustomAppendixBg from './helpers/getCustomAppendixBg';
@ -28,8 +29,10 @@ import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useMediaTransition from '../../../hooks/useMediaTransition'; import useMediaTransition from '../../../hooks/useMediaTransition';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps'; import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import useFlag from '../../../hooks/useFlag';
import ProgressSpinner from '../../ui/ProgressSpinner'; import ProgressSpinner from '../../ui/ProgressSpinner';
import MediaSpoiler from '../../common/MediaSpoiler';
export type OwnProps = { export type OwnProps = {
id?: string; id?: string;
@ -91,6 +94,9 @@ const Photo: FC<OwnProps> = ({
const noThumb = Boolean(fullMediaData); const noThumb = Boolean(fullMediaData);
const thumbRef = useBlurredMediaThumbRef(message, noThumb); const thumbRef = useBlurredMediaThumbRef(message, noThumb);
const thumbClassNames = useMediaTransition(!noThumb); const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message);
const [isSpoilerShown, , hideSpoiler] = useFlag(photo.isSpoiler);
const { const {
loadProgress: downloadProgress, loadProgress: downloadProgress,
@ -118,15 +124,22 @@ const Photo: FC<OwnProps> = ({
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (isUploading) { if (isUploading) {
if (onCancelUpload) { onCancelUpload?.(message);
onCancelUpload(message); return;
}
} else if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (onClick) {
onClick(message.id);
} }
}, [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); const isOwn = isOwnMessage(message);
useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => { useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => {
@ -184,6 +197,14 @@ const Photo: FC<OwnProps> = ({
</div> </div>
)} )}
{shouldRenderDownloadButton && <i className={buildClassName('icon-download', downloadButtonClassNames)} />} {shouldRenderDownloadButton && <i className={buildClassName('icon-download', downloadButtonClassNames)} />}
<MediaSpoiler
isVisible={isSpoilerShown}
withAnimation
thumbDataUri={thumbDataUri}
width={width}
height={height}
className="media-spoiler"
/>
{isTransferring && ( {isTransferring && (
<span className="message-transfer-progress">{Math.round(transferProgress * 100)}%</span> <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 ProgressSpinner from '../../ui/ProgressSpinner';
import OptimizedVideo from '../../ui/OptimizedVideo'; import OptimizedVideo from '../../ui/OptimizedVideo';
import MediaSpoiler from '../../common/MediaSpoiler';
export type OwnProps = { export type OwnProps = {
id?: string; id?: string;
@ -74,6 +75,8 @@ const Video: FC<OwnProps> = ({
const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!; const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!;
const localBlobUrl = video.blobUrl; const localBlobUrl = video.blobUrl;
const [isSpoilerShown, , hideSpoiler] = useFlag(video.isSpoiler);
const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading); const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading);
const isIntersectingForPlaying = ( const isIntersectingForPlaying = (
useIsIntersecting(ref, observeIntersectionForPlaying) useIsIntersecting(ref, observeIntersectionForPlaying)
@ -86,7 +89,7 @@ const Video: FC<OwnProps> = ({
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading && lastSyncTime); const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading && lastSyncTime);
const [isPlayAllowed, setIsPlayAllowed] = useState(canAutoPlay); const [isPlayAllowed, setIsPlayAllowed] = useState(canAutoPlay && !isSpoilerShown);
const fullMediaHash = getMessageMediaHash(message, 'inline'); const fullMediaHash = getMessageMediaHash(message, 'inline');
const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash))); const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash)));
@ -96,7 +99,8 @@ const Video: FC<OwnProps> = ({
const fullMediaData = localBlobUrl || mediaData; const fullMediaData = localBlobUrl || mediaData;
const [isPlayerReady, markPlayerReady] = useFlag(); const [isPlayerReady, markPlayerReady] = useFlag();
const hasThumb = Boolean(getMessageMediaThumbDataUri(message)); const thumbDataUri = getMessageMediaThumbDataUri(message);
const hasThumb = Boolean(thumbDataUri);
const previewMediaHash = getMessageMediaHash(message, 'preview'); const previewMediaHash = getMessageMediaHash(message, 'preview');
const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash))); const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash)));
@ -147,19 +151,34 @@ const Video: FC<OwnProps> = ({
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (isUploading) { if (isUploading) {
if (onCancelUpload) { onCancelUpload?.(message);
onCancelUpload(message); return;
}
} else if (isDownloading) {
getActions().cancelMessageMediaDownload({ message });
} else if (!fullMediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (fullMediaData && !isPlayAllowed) {
setIsPlayAllowed(true);
} else if (onClick) {
onClick(message.id);
} }
}, [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'); const className = buildClassName('media-inner dark', !isUploading && 'interactive');
@ -202,6 +221,14 @@ const Video: FC<OwnProps> = ({
)} )}
{isProtected && <span className="protector" />} {isProtected && <span className="protector" />}
<i className={buildClassName('icon-large-play', playButtonClassNames)} /> <i className={buildClassName('icon-large-play', playButtonClassNames)} />
<MediaSpoiler
isVisible={isSpoilerShown}
withAnimation
thumbDataUri={thumbDataUri}
width={width}
height={height}
className="media-spoiler"
/>
{shouldRenderSpinner && ( {shouldRenderSpinner && (
<div className={buildClassName('media-loading', spinnerClassNames)}> <div className={buildClassName('media-loading', spinnerClassNames)}>
<ProgressSpinner progress={transferProgress} onClick={handleClick} /> <ProgressSpinner progress={transferProgress} onClick={handleClick} />

View File

@ -134,7 +134,8 @@ export function getMessageMediaThumbnail(message: ApiMessage) {
|| getMessageDocument(message) || getMessageDocument(message)
|| getMessageSticker(message) || getMessageSticker(message)
|| getMessageWebPagePhoto(message) || getMessageWebPagePhoto(message)
|| getMessageWebPageVideo(message); || getMessageWebPageVideo(message)
|| getMessageInvoice(message)?.extendedMedia;
if (!media) { if (!media) {
return undefined; return undefined;
@ -147,6 +148,14 @@ export function getMessageMediaThumbDataUri(message: ApiMessage) {
return getMessageMediaThumbnail(message)?.dataUri; 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) { export function getDocumentMediaHash(document: ApiDocument) {
return `document${document.id}`; 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 { IS_CANVAS_FILTER_SUPPORTED } from '../util/environment';
import fastBlur from '../lib/fastBlur'; import fastBlur from '../lib/fastBlur';
import useOnChange from './useOnChange';
const RADIUS = 2; const RADIUS = 2;
const ITERATIONS = 2; const ITERATIONS = 2;
@ -18,6 +19,12 @@ export default function useCanvasBlur(
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const isStarted = useRef(); const isStarted = useRef();
useOnChange(() => {
if (!isDisabled) {
isStarted.current = false;
}
}, [dataUri, isDisabled]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; 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, noOpenTransition = false,
className: string | false = 'fast', className: string | false = 'fast',
noCloseTransition = false, noCloseTransition = false,
closeDuration = CLOSE_DURATION,
) => { ) => {
const [isClosed, setIsClosed] = useState(!isOpen); const [isClosed, setIsClosed] = useState(!isOpen);
const closeTimeoutRef = useRef<number>(); const closeTimeoutRef = useRef<number>();
@ -40,7 +41,7 @@ const useShowTransition = (
if (noCloseTransition) { if (noCloseTransition) {
exec(); exec();
} else { } else {
closeTimeoutRef.current = window.setTimeout(exec, CLOSE_DURATION); closeTimeoutRef.current = window.setTimeout(exec, closeDuration);
} }
} }
} }

View File

@ -2068,21 +2068,19 @@
{ {
"id": 101, "id": 101,
"paths": [ "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", "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",
"M128 128l768 768z", "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"
"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"
], ],
"attrs": [ "attrs": [
{},
{}, {},
{} {}
], ],
"isMulticolor": false,
"isMulticolor2": false,
"grid": 24, "grid": 24,
"tags": [ "tags": [
"spoiler-disable" "spoiler-disable"
] ],
"isMulticolor": false,
"isMulticolor2": false
}, },
{ {
"id": 100, "id": 100,
@ -4526,4 +4524,4 @@
"showLiga": false "showLiga": false
}, },
"uid": -1 "uid": -1
} }