diff --git a/src/api/gramjs/apiBuilders/common.ts b/src/api/gramjs/apiBuilders/common.ts index a6598a697..a4d9e0272 100644 --- a/src/api/gramjs/apiBuilders/common.ts +++ b/src/api/gramjs/apiBuilders/common.ts @@ -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 }), }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index f90a21cc1..f81a1a725 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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, }, }; } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 9d58af8ce..0497ce705 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -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, }); } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index cb588cb37..d819a9ee4 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -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), }; } diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 2f833e301..71ff93a28 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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; } diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 177407e52..ae3fa0a97 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -46,7 +46,8 @@ export interface ApiAttachment { }; previewBlobUrl?: string; - shouldSendAsFile?: boolean; + shouldSendAsFile?: true; + shouldSendAsSpoiler?: true; uniqueId?: string; } diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 7d6f7cbec..a4f7b575c 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index a08edb587..ec40bd44d 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/assets/spoilers/mask.svg b/src/assets/spoilers/mask.svg new file mode 100644 index 000000000..35f233e80 --- /dev/null +++ b/src/assets/spoilers/mask.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/turbulence_1x.png b/src/assets/spoilers/turbulence_1x.png similarity index 100% rename from src/assets/turbulence_1x.png rename to src/assets/spoilers/turbulence_1x.png diff --git a/src/assets/turbulence_2x.png b/src/assets/spoilers/turbulence_2x.png similarity index 100% rename from src/assets/turbulence_2x.png rename to src/assets/spoilers/turbulence_2x.png diff --git a/src/assets/turbulence_3x.png b/src/assets/spoilers/turbulence_3x.png similarity index 100% rename from src/assets/turbulence_3x.png rename to src/assets/spoilers/turbulence_3x.png diff --git a/src/components/common/EmbeddedMessage.scss b/src/components/common/EmbeddedMessage.scss index ea5b5f1b8..490bd2798 100644 --- a/src/components/common/EmbeddedMessage.scss +++ b/src/components/common/EmbeddedMessage.scss @@ -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; } diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index 893d07a62..a9fbc5717 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -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 = ({ 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 = ({ )} onClick={message ? onClick : undefined} > - {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected)} + {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}

{!message ? ( @@ -112,21 +115,27 @@ function renderPictogram( blobUrl?: string, isRoundVideo?: boolean, isProtected?: boolean, + isSpoiler?: boolean, ) { const { width, height } = getPictogramDimensions(); + const srcUrl = blobUrl || thumbDataUri; + return ( - <> - +

+ {!isSpoiler && ( + + )} + {isProtected && } - +
); } diff --git a/src/components/common/Media.scss b/src/components/common/Media.scss index f4c138f67..00aa0e01d 100644 --- a/src/components/common/Media.scss +++ b/src/components/common/Media.scss @@ -17,7 +17,7 @@ line-height: 1.125rem; } - img { + .media-miniature { position: absolute; left: 0; top: 0; diff --git a/src/components/common/Media.tsx b/src/components/common/Media.tsx index 33c997474..4692737db 100644 --- a/src/components/common/Media.tsx +++ b/src/components/common/Media.tsx @@ -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 = ({ 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 (
= ({ > = ({ /> + {hasSpoiler && ( + + )} {video && {video.isGif ? 'GIF' : formatMediaDuration(video.duration)}} {isProtected && }
diff --git a/src/components/common/MediaSpoiler.module.scss b/src/components/common/MediaSpoiler.module.scss new file mode 100644 index 000000000..acbf100c4 --- /dev/null +++ b/src/components/common/MediaSpoiler.module.scss @@ -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%; + } +} diff --git a/src/components/common/MediaSpoiler.tsx b/src/components/common/MediaSpoiler.tsx new file mode 100644 index 000000000..902ba9646 --- /dev/null +++ b/src/components/common/MediaSpoiler.tsx @@ -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 = ({ + isVisible, + withAnimation, + thumbDataUri, + className, + width, + height, +}) => { + // eslint-disable-next-line no-null/no-null + const ref = useRef(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) => { + 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 ( +
+ +
+
+ ); +}; + +export default memo(MediaSpoiler); diff --git a/src/components/common/UiLoader.tsx b/src/components/common/UiLoader.tsx index 55d63dc9f..59e4017c0 100644 --- a/src/components/common/UiLoader.tsx +++ b/src/components/common/UiLoader.tsx @@ -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(), diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 3937ee0e4..2a0be3913 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -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; diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index e6d22b1ea..7bdb3744c 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -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 ( - + {getMessageVideo(message) && } {messageSummary} diff --git a/src/components/left/search/ChatMessage.scss b/src/components/left/search/ChatMessage.scss index 28df1c3a1..d6a7819a9 100644 --- a/src/components/left/search/ChatMessage.scss +++ b/src/components/left/search/ChatMessage.scss @@ -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 { diff --git a/src/components/left/search/ChatMessage.tsx b/src/components/left/search/ChatMessage.tsx index f0e6f2ec3..847c5a455 100644 --- a/src/components/left/search/ChatMessage.tsx +++ b/src/components/left/search/ChatMessage.tsx @@ -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 ( - + {getMessageVideo(message) && } {renderMessageSummary(lang, message, true, searchQuery)} diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx index d8bf1f1f6..648ea6ffd 100644 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ b/src/components/middle/HeaderPinnedMessage.tsx @@ -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 = ({ 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 = ({ count={count} index={index} /> - {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl)} + {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)}
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} @@ -129,11 +135,15 @@ const HeaderPinnedMessage: FC = ({ ); }; -function renderPictogram(thumbDataUri: string, blobUrl?: string) { +function renderPictogram(thumbDataUri: string, blobUrl?: string, isSpoiler?: boolean) { const { width, height } = getPictogramDimensions(); + const srcUrl = blobUrl || thumbDataUri; return ( - +
+ {!isSpoiler && } + +
); } diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index e321b709d..b2564a9f8 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -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 { diff --git a/src/components/middle/composer/AttachmentModal.scss b/src/components/middle/composer/AttachmentModal.scss index 599b777f7..98b768726 100644 --- a/src/components/middle/composer/AttachmentModal.scss +++ b/src/components/middle/composer/AttachmentModal.scss @@ -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; } } diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 72665db44..baa47d376 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -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 = ({ customEmojiForEmoji, attachmentSettings, shouldSuggestCompression, + onAttachmentsUpdate, onCaptionUpdate, onSend, onFileAppend, - onDelete, onClear, onSendSilent, onSendScheduled, @@ -121,6 +123,14 @@ const AttachmentModal: FC = ({ ); 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 = ({ 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 = ({ const files = await getFilesFromDataTransferItems(dataTransfer.items); if (files?.length) { - onFileAppend(files); + onFileAppend(files, isEverySpoiler); } - }, [onFileAppend, unmarkHovered]); + }, [isEverySpoiler, onFileAppend, unmarkHovered]); function handleDragOver(e: React.MouseEvent) { e.preventDefault(); @@ -258,14 +275,39 @@ const AttachmentModal: FC = ({ 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 }) => (