Message: Support "Hidden Media" (#2308)
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,8 @@ export interface ApiAttachment {
|
||||
};
|
||||
previewBlobUrl?: string;
|
||||
|
||||
shouldSendAsFile?: boolean;
|
||||
shouldSendAsFile?: true;
|
||||
shouldSendAsSpoiler?: true;
|
||||
|
||||
uniqueId?: string;
|
||||
}
|
||||
|
||||
6
src/assets/spoilers/mask.svg
Normal 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 |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
img {
|
||||
.media-miniature {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
@ -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>
|
||||
|
||||
105
src/components/common/MediaSpoiler.module.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
65
src/components/common/MediaSpoiler.tsx
Normal 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);
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
17
src/hooks/useScrolledState.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||