GIF: Support sending with caption (#6755)

This commit is contained in:
Alexander Zinchuk 2026-03-31 11:28:36 +02:00
parent 07d24e2d43
commit b5fed8276a
23 changed files with 249 additions and 120 deletions

View File

@ -662,6 +662,10 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep
export function buildUploadingMedia( export function buildUploadingMedia(
attachment: ApiAttachment, attachment: ApiAttachment,
): MediaContent { ): MediaContent {
if (attachment.gif) {
return { video: attachment.gif };
}
const { const {
filename: fileName, filename: fileName,
blobUrl, blobUrl,

View File

@ -197,14 +197,14 @@ export function buildInputDocument(media: ApiSticker | ApiVideo) {
])); ]));
} }
export function buildInputMediaDocument(media: ApiSticker | ApiVideo) { export function buildInputMediaDocument(media: ApiSticker | ApiVideo, spoiler?: true) {
const inputDocument = buildInputDocument(media); const inputDocument = buildInputDocument(media);
if (!inputDocument) { if (!inputDocument) {
return undefined; return undefined;
} }
return new GramJs.InputMediaDocument({ id: inputDocument }); return new GramJs.InputMediaDocument({ id: inputDocument, spoiler });
} }
export function buildInputPoll(pollParams: ApiNewPoll, randomId: bigint) { export function buildInputPoll(pollParams: ApiNewPoll, randomId: bigint) {

View File

@ -438,6 +438,9 @@ export function sendApiMessage(
} }
} }
if (!media && attachment?.gif) {
media = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
}
if (!media && attachment) { if (!media && attachment) {
try { try {
media = await uploadMedia(localMessage, attachment, onProgress!); media = await uploadMedia(localMessage, attachment, onProgress!);
@ -607,27 +610,33 @@ function sendGroupedMedia(
const prevMediaQueue = mediaQueue; const prevMediaQueue = mediaQueue;
mediaQueue = (async () => { mediaQueue = (async () => {
let media; let inputMedia: GramJs.TypeInputMedia | undefined;
try {
media = await uploadMedia(localMessage, attachment, onProgress!); if (attachment.gif) {
} catch (err) { inputMedia = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
if (DEBUG) { } else {
// eslint-disable-next-line no-console let media;
console.warn(err); try {
media = await uploadMedia(localMessage, attachment, onProgress!);
} catch (err) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn(err);
}
groupedUploads[groupedId].counter--;
await prevMediaQueue;
return;
} }
groupedUploads[groupedId].counter--; inputMedia = await fetchInputMedia(
buildInputPeer(chat.id, chat.accessHash),
await prevMediaQueue; media,
);
return;
} }
const inputMedia = await fetchInputMedia(
buildInputPeer(chat.id, chat.accessHash),
media,
);
await prevMediaQueue; await prevMediaQueue;
if (!inputMedia) { if (!inputMedia) {

View File

@ -1,7 +1,7 @@
import type { CallbackAction } from '../../global/types'; import type { CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons'; import type { IconName } from '../../types/icons';
import type { LangFnParameters, RegularLangFnParameters } from '../../util/localization'; import type { LangFnParameters, RegularLangFnParameters } from '../../util/localization';
import type { ApiDocument, ApiFormattedText, ApiMessageEntity, ApiPhoto, ApiReaction } from './messages'; import type { ApiDocument, ApiFormattedText, ApiMessageEntity, ApiPhoto, ApiReaction, ApiVideo } from './messages';
import type { ApiPremiumSection } from './payments'; import type { ApiPremiumSection } from './payments';
import type { ApiBotVerification } from './peers'; import type { ApiBotVerification } from './peers';
import type { ApiStarsSubscriptionPricing } from './stars'; import type { ApiStarsSubscriptionPricing } from './stars';
@ -44,7 +44,7 @@ export interface ApiOnProgress {
} }
export interface ApiAttachment { export interface ApiAttachment {
blob: Blob; blob?: Blob;
blobUrl: string; blobUrl: string;
compressedBlobUrl?: string; compressedBlobUrl?: string;
filename: string; filename: string;
@ -72,6 +72,8 @@ export interface ApiAttachment {
uniqueId?: string; uniqueId?: string;
ttlSeconds?: number; ttlSeconds?: number;
shouldSendInHighQuality?: boolean; shouldSendInHighQuality?: boolean;
gif?: ApiVideo;
} }
export interface ApiWallpaper { export interface ApiWallpaper {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 32 32"><path d="M24.241 8.014H3.18c-.741 0-1.342-.604-1.342-1.35 0-.745.601-1.349 1.342-1.349H24.24c.74 0 1.342.604 1.342 1.35 0 .745-.601 1.35-1.342 1.35" /><rect width="13.437" height="2.699" x="1.837" y="22.019" rx="1.35" ry="1.35"/><rect width="19.521" height="2.699" x="1.837" y="13.667" rx="1.35" ry="1.35"/><rect width="2.699" height="11.86" x="22.883" y="17.439" rx="1.35" ry="1.35"/><rect width="11.86" height="2.699" x="18.302" y="22.019" rx="1.35" ry="1.35"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@ -2666,6 +2666,8 @@
"AttachmentSendAudio_other" = "Send {count} Audios"; "AttachmentSendAudio_other" = "Send {count} Audios";
"AttachmentSendFile_one" = "Send File"; "AttachmentSendFile_one" = "Send File";
"AttachmentSendFile_other" = "Send {count} Files"; "AttachmentSendFile_other" = "Send {count} Files";
"AttachmentSendGif" = "Send GIF";
"AttachmentReplaceGif" = "Replace GIF";
"AttachmentDragAddItems" = "Add Items"; "AttachmentDragAddItems" = "Add Items";
"AttachmentCaptionPlaceholder" = "Add a caption..."; "AttachmentCaptionPlaceholder" = "Add a caption...";
"MessageSummaryTitle" = "AI Summary"; "MessageSummaryTitle" = "AI Summary";
@ -2729,3 +2731,4 @@
"GiftPreviewToggleRegularModels" = "View Primary Models >"; "GiftPreviewToggleRegularModels" = "View Primary Models >";
"AriaGiftPreviewPlay" = "Play random previews"; "AriaGiftPreviewPlay" = "Play random previews";
"AriaGiftPreviewStop" = "Pause random previews"; "AriaGiftPreviewStop" = "Pause random previews";
"MenuAddCaption" = "Add Caption";

View File

@ -132,7 +132,10 @@ import { getServerTime } from '../../util/serverTime';
import windowSize from '../../util/windowSize'; import windowSize from '../../util/windowSize';
import { DEFAULT_MAX_MESSAGE_LENGTH } from '../../limits'; import { DEFAULT_MAX_MESSAGE_LENGTH } from '../../limits';
import applyIosAutoCapitalizationFix from '../middle/composer/helpers/applyIosAutoCapitalizationFix'; import applyIosAutoCapitalizationFix from '../middle/composer/helpers/applyIosAutoCapitalizationFix';
import buildAttachment, { prepareAttachmentsToSend } from '../middle/composer/helpers/buildAttachment'; import buildAttachment, {
buildGifAttachment,
prepareAttachmentsToSend,
} from '../middle/composer/helpers/buildAttachment';
import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji'; import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji';
import { isSelectionInsideInput } from '../middle/composer/helpers/selection'; import { isSelectionInsideInput } from '../middle/composer/helpers/selection';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
@ -1021,6 +1024,8 @@ const Composer = ({
theme, theme,
}); });
const hasGifFromPicker = attachments.some((a) => a.gif);
useClipboardPaste( useClipboardPaste(
isForCurrentMessageList || isInStoryViewer, isForCurrentMessageList || isInStoryViewer,
insertFormattedTextAndUpdateCursor, insertFormattedTextAndUpdateCursor,
@ -1030,6 +1035,7 @@ const Composer = ({
!isCurrentUserPremium && !isChatWithSelf, !isCurrentUserPremium && !isChatWithSelf,
showCustomEmojiPremiumNotification, showCustomEmojiPremiumNotification,
!attachments.length, !attachments.length,
hasGifFromPicker,
); );
const handleEmbeddedClear = useLastCallback(() => { const handleEmbeddedClear = useLastCallback(() => {
@ -1474,6 +1480,11 @@ const Composer = ({
clearDraft({ chatId, threadId, isLocalOnly: true }); clearDraft({ chatId, threadId, isLocalOnly: true });
}); });
const handleGifAddCaption = useLastCallback((gif: ApiVideo) => {
handleSetAttachments([buildGifAttachment(gif)]);
closeSymbolMenu();
});
const handleStickerSelect = useLastCallback(( const handleStickerSelect = useLastCallback((
sticker: ApiSticker, sticker: ApiSticker,
isSilent?: boolean, isSilent?: boolean,
@ -2234,6 +2245,7 @@ const Composer = ({
canSendGifs={canSendGifs} canSendGifs={canSendGifs}
isMessageComposer={isInMessageList} isMessageComposer={isInMessageList}
onGifSelect={handleGifSelect} onGifSelect={handleGifSelect}
onGifAddCaption={handleGifAddCaption}
onStickerSelect={handleStickerSelect} onStickerSelect={handleStickerSelect}
onCustomEmojiSelect={handleCustomEmojiSelect} onCustomEmojiSelect={handleCustomEmojiSelect}
onRemoveSymbol={removeSymbol} onRemoveSymbol={removeSymbol}

View File

@ -16,6 +16,7 @@ import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur'; import useCanvasBlur from '../../hooks/useCanvasBlur';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
@ -33,9 +34,10 @@ type OwnProps = {
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
isDisabled?: boolean; isDisabled?: boolean;
className?: string; className?: string;
isSavedMessages?: boolean;
onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onUnsaveClick?: (gif: ApiVideo) => void; onUnsaveClick?: (gif: ApiVideo) => void;
isSavedMessages?: boolean; onAddCaption?: (gif: ApiVideo) => void;
}; };
const GifButton: FC<OwnProps> = ({ const GifButton: FC<OwnProps> = ({
@ -43,13 +45,15 @@ const GifButton: FC<OwnProps> = ({
isDisabled, isDisabled,
className, className,
observeIntersection, observeIntersection,
isSavedMessages,
onClick, onClick,
onUnsaveClick, onUnsaveClick,
isSavedMessages, onAddCaption,
}) => { }) => {
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const loadAndPlay = isIntersecting && !isDisabled; const loadAndPlay = isIntersecting && !isDisabled;
@ -76,6 +80,7 @@ const GifButton: FC<OwnProps> = ({
const getTriggerElement = useLastCallback(() => ref.current); const getTriggerElement = useLastCallback(() => ref.current);
const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll, .no-scrollbar')); const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll, .no-scrollbar'));
const getMenuElement = useLastCallback(() => ref.current!.querySelector('.gif-context-menu .bubble')); const getMenuElement = useLastCallback(() => ref.current!.querySelector('.gif-context-menu .bubble'));
const getLayout = useLastCallback(() => ({ shouldAvoidNegativePosition: true }));
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
if (isContextMenuOpen || !onClick) return; if (isContextMenuOpen || !onClick) return;
@ -109,6 +114,13 @@ const GifButton: FC<OwnProps> = ({
}, undefined, true); }, undefined, true);
}); });
const handleAddCaption = useLastCallback(() => {
onAddCaption?.({
...gif,
blobUrl: videoData,
});
});
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLElement>) => { const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
preventMessageInputBlurWithBubbling(e); preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e); handleBeforeContextMenu(e);
@ -182,17 +194,21 @@ const GifButton: FC<OwnProps> = ({
getTriggerElement={getTriggerElement} getTriggerElement={getTriggerElement}
getRootElement={getRootElement} getRootElement={getRootElement}
getMenuElement={getMenuElement} getMenuElement={getMenuElement}
getLayout={getLayout}
className="gif-context-menu" className="gif-context-menu"
autoClose autoClose
onClose={handleContextMenuClose} onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide} onCloseAnimationEnd={handleContextMenuHide}
> >
{!isSavedMessages && <MenuItem onClick={handleSendQuiet} icon="mute">{lang('SendWithoutSound')}</MenuItem>} {!isSavedMessages && <MenuItem onClick={handleSendQuiet} icon="mute">{oldLang('SendWithoutSound')}</MenuItem>}
<MenuItem onClick={handleSendScheduled} icon="calendar"> <MenuItem onClick={handleSendScheduled} icon="calendar">
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} {oldLang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
</MenuItem> </MenuItem>
{onAddCaption && (
<MenuItem icon="add-caption" onClick={handleAddCaption}>{lang('MenuAddCaption')}</MenuItem>
)}
{onUnsaveClick && ( {onUnsaveClick && (
<MenuItem destructive icon="delete" onClick={handleContextDelete}>{lang('Delete')}</MenuItem> <MenuItem destructive icon="delete" onClick={handleContextDelete}>{oldLang('Delete')}</MenuItem>
)} )}
</Menu> </Menu>
)} )}

View File

@ -9,6 +9,7 @@ import type { Signal } from '../../../util/signals';
import { import {
BASE_EMOJI_KEYWORD_LANG, BASE_EMOJI_KEYWORD_LANG,
EDITABLE_INPUT_MODAL_ID, EDITABLE_INPUT_MODAL_ID,
GIF_MIME_TYPE,
SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
@ -154,6 +155,7 @@ const AttachmentModal = ({
const svgRef = useRef<SVGSVGElement>(); const svgRef = useRef<SVGSVGElement>();
const { const {
addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest, addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest,
updateShouldSaveAttachmentsCompression,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -170,6 +172,7 @@ const AttachmentModal = ({
const isInAlbum = editingMessage && editingMessage?.groupedId; const isInAlbum = editingMessage && editingMessage?.groupedId;
const isEditingMessageFile = isEditing && attachments?.length && getAttachmentMediaType(attachments[0]); const isEditingMessageFile = isEditing && attachments?.length && getAttachmentMediaType(attachments[0]);
const notEditingFile = isEditingMessageFile !== 'file'; const notEditingFile = isEditingMessageFile !== 'file';
const hasGifFromPicker = renderingAttachments?.some((a) => a.gif);
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag(); const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [editingAttachmentIndex, setEditingAttachmentIndex] = useState<number | undefined>(undefined); const [editingAttachmentIndex, setEditingAttachmentIndex] = useState<number | undefined>(undefined);
@ -185,7 +188,7 @@ const AttachmentModal = ({
const shouldSendCompressed = attachmentSettings.shouldCompress; const shouldSendCompressed = attachmentSettings.shouldCompress;
const isSendingCompressed = Boolean( const isSendingCompressed = Boolean(
(shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile, (shouldSendCompressed || shouldForceCompression || isInAlbum || hasGifFromPicker) && !shouldForceAsFile,
); );
const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped);
const isInvertedMedia = attachmentSettings.isInvertedMedia; const isInvertedMedia = attachmentSettings.isInvertedMedia;
@ -215,6 +218,13 @@ const AttachmentModal = ({
} }
}, [closeSymbolMenu, isOpen]); }, [closeSymbolMenu, isOpen]);
useEffect(() => {
if (hasGifFromPicker) {
updateShouldSaveAttachmentsCompression({ shouldSave: false });
setShouldSendGrouped(false);
}
}, [hasGifFromPicker, updateShouldSaveAttachmentsCompression]);
const [hasMedia, hasOnlyMedia] = useMemo(() => { const [hasMedia, hasOnlyMedia] = useMemo(() => {
const onlyMedia = Boolean(renderingAttachments?.every((a) => a.quick || a.audio)); const onlyMedia = Boolean(renderingAttachments?.every((a) => a.quick || a.audio));
if (onlyMedia) return [true, true]; if (onlyMedia) return [true, true];
@ -509,13 +519,25 @@ const AttachmentModal = ({
const isQuickGallery = isSendingCompressed && hasOnlyMedia; const isQuickGallery = isSendingCompressed && hasOnlyMedia;
const [areAllPhotos, areAllVideos, areAllAudios, hasAnyPhoto] = useMemo(() => { const {
if (!isQuickGallery || !renderingAttachments) return [false, false, false]; areAllPhotos, areAllVideos, areAllAudios, areAllGifs, hasAnyPhoto,
const everyPhoto = renderingAttachments.every((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)); } = useMemo(() => {
const everyVideo = renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType)); if (!isQuickGallery || !renderingAttachments) {
const everyAudio = renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)); return {
const anyPhoto = renderingAttachments.some((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)); areAllPhotos: false,
return [everyPhoto, everyVideo, everyAudio, anyPhoto]; areAllVideos: false,
areAllAudios: false,
areAllGifs: false,
hasAnyPhoto: false,
};
}
return {
areAllPhotos: renderingAttachments.every((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)),
areAllVideos: renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType)),
areAllAudios: renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)),
areAllGifs: renderingAttachments.every((a) => a.gif || a.mimeType === GIF_MIME_TYPE),
hasAnyPhoto: renderingAttachments.some((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)),
};
}, [renderingAttachments, isQuickGallery]); }, [renderingAttachments, isQuickGallery]);
const hasAnySpoilerable = useMemo(() => { const hasAnySpoilerable = useMemo(() => {
@ -553,7 +575,10 @@ const AttachmentModal = ({
let title = ''; let title = '';
const attachmentsLength = renderingAttachments.length; const attachmentsLength = renderingAttachments.length;
if (areAllPhotos) {
if (areAllGifs) {
title = lang(isEditing ? 'AttachmentReplaceGif' : 'AttachmentSendGif');
} else if (areAllPhotos) {
title = lang( title = lang(
`Attachment${isEditing ? 'Replace' : 'Send'}Photo`, `Attachment${isEditing ? 'Replace' : 'Send'}Photo`,
{ count: attachmentsLength }, { count: attachmentsLength },
@ -602,7 +627,7 @@ const AttachmentModal = ({
trigger={MoreMenuButton} trigger={MoreMenuButton}
positionX="right" positionX="right"
> >
{Boolean(!editingMessage) && ( {Boolean(!editingMessage) && !hasGifFromPicker && (
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem> <MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem>
)} )}
{hasMedia && ( {hasMedia && (
@ -621,7 +646,7 @@ const AttachmentModal = ({
)) ))
} }
{ {
!shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( !shouldForceAsFile && !shouldForceCompression && !hasGifFromPicker && (isSendingCompressed ? (
<MenuItem icon="document" onClick={handleToggleShouldCompress}> <MenuItem icon="document" onClick={handleToggleShouldCompress}>
{lang(isMultiple ? 'AttachmentMenuSendAllAsFiles' : 'AttachmentMenuSendAsFiles')} {lang(isMultiple ? 'AttachmentMenuSendAllAsFiles' : 'AttachmentMenuSendAsFiles')}

View File

@ -4,6 +4,7 @@
overflow-y: auto; overflow-y: auto;
width: calc(100% - 0.375rem);
height: calc(100% - 0.1875rem); height: calc(100% - 0.1875rem);
margin: 0 0.1875rem; margin: 0 0.1875rem;
padding-bottom: 0.1875rem; padding-bottom: 0.1875rem;

View File

@ -24,6 +24,7 @@ type OwnProps = {
loadAndPlay: boolean; loadAndPlay: boolean;
canSendGifs?: boolean; canSendGifs?: boolean;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onGifAddCaption?: (gif: ApiVideo) => void;
}; };
type StateProps = { type StateProps = {
@ -40,6 +41,7 @@ const GifPicker: FC<OwnProps & StateProps> = ({
savedGifs, savedGifs,
isSavedMessages, isSavedMessages,
onGifSelect, onGifSelect,
onGifAddCaption,
}) => { }) => {
const { loadSavedGifs, saveGif } = getActions(); const { loadSavedGifs, saveGif } = getActions();
@ -80,9 +82,10 @@ const GifPicker: FC<OwnProps & StateProps> = ({
gif={gif} gif={gif}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
isDisabled={!loadAndPlay} isDisabled={!loadAndPlay}
isSavedMessages={isSavedMessages}
onClick={canSendGifs ? onGifSelect : undefined} onClick={canSendGifs ? onGifSelect : undefined}
onUnsaveClick={handleUnsaveClick} onUnsaveClick={handleUnsaveClick}
isSavedMessages={isSavedMessages} onAddCaption={canSendGifs ? onGifAddCaption : undefined}
/> />
)) ))
) : canRenderContents && savedGifs ? ( ) : canRenderContents && savedGifs ? (

View File

@ -56,6 +56,7 @@ export type OwnProps = {
canUpdateStickerSetsOrder?: boolean, canUpdateStickerSetsOrder?: boolean,
) => void; ) => void;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onGifAddCaption?: (gif: ApiVideo) => void;
onRemoveSymbol: () => void; onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void; onSearchOpen: (type: 'stickers' | 'gifs') => void;
addRecentEmoji: GlobalActions['addRecentEmoji']; addRecentEmoji: GlobalActions['addRecentEmoji'];
@ -91,6 +92,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect, onCustomEmojiSelect,
onStickerSelect, onStickerSelect,
onGifSelect, onGifSelect,
onGifAddCaption,
onRemoveSymbol, onRemoveSymbol,
onSearchOpen, onSearchOpen,
addRecentEmoji, addRecentEmoji,
@ -239,6 +241,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
loadAndPlay={canSendGifs ? isOpen && (isActive || isFrom) : false} loadAndPlay={canSendGifs ? isOpen && (isActive || isFrom) : false}
canSendGifs={canSendGifs} canSendGifs={canSendGifs}
onGifSelect={onGifSelect} onGifSelect={onGifSelect}
onGifAddCaption={onGifAddCaption}
/> />
); );
} }

View File

@ -41,6 +41,7 @@ type OwnProps = {
canUpdateStickerSetsOrder?: boolean, canUpdateStickerSetsOrder?: boolean,
) => void; ) => void;
onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onGifAddCaption?: (gif: ApiVideo) => void;
onRemoveSymbol: VoidFunction; onRemoveSymbol: VoidFunction;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
closeBotCommandMenu?: VoidFunction; closeBotCommandMenu?: VoidFunction;
@ -73,6 +74,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
onCustomEmojiSelect, onCustomEmojiSelect,
onStickerSelect, onStickerSelect,
onGifSelect, onGifSelect,
onGifAddCaption,
onRemoveSymbol, onRemoveSymbol,
onEmojiSelect, onEmojiSelect,
closeBotCommandMenu, closeBotCommandMenu,
@ -181,6 +183,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
onStickerSelect={onStickerSelect} onStickerSelect={onStickerSelect}
onCustomEmojiSelect={onCustomEmojiSelect} onCustomEmojiSelect={onCustomEmojiSelect}
onGifSelect={onGifSelect} onGifSelect={onGifSelect}
onGifAddCaption={onGifAddCaption}
onRemoveSymbol={onRemoveSymbol} onRemoveSymbol={onRemoveSymbol}
onSearchOpen={handleSearchOpen} onSearchOpen={handleSearchOpen}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}

View File

@ -1,4 +1,4 @@
import type { ApiAttachment } from '../../../../api/types'; import type { ApiAttachment, ApiVideo } from '../../../../api/types';
import { import {
GIF_MIME_TYPE, GIF_MIME_TYPE,
@ -132,3 +132,26 @@ function validateAspectRatio(width: number, height: number) {
const maxAspectRatio = Math.max(width, height) / Math.min(width, height); const maxAspectRatio = Math.max(width, height) / Math.min(width, height);
return maxAspectRatio <= MAX_ASPECT_RATIO; return maxAspectRatio <= MAX_ASPECT_RATIO;
} }
export function buildGifAttachment(gif: ApiVideo): ApiAttachment {
const {
blobUrl,
thumbnail,
fileName,
mimeType,
size,
width,
height,
duration,
} = gif;
return {
gif,
blobUrl: blobUrl || '',
previewBlobUrl: thumbnail?.dataUri,
filename: fileName,
mimeType,
size,
quick: width && height ? { width, height, duration } : undefined,
} satisfies ApiAttachment;
}

View File

@ -3,6 +3,7 @@ import { getActions } from '../../../../global';
import type { ApiAttachment, ApiMessage } from '../../../../api/types'; import type { ApiAttachment, ApiMessage } from '../../../../api/types';
import { GIF_MIME_TYPE } from '../../../../config';
import { canReplaceMessageMedia, getAttachmentMediaType } from '../../../../global/helpers'; import { canReplaceMessageMedia, getAttachmentMediaType } from '../../../../global/helpers';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import buildAttachment from '../helpers/buildAttachment'; import buildAttachment from '../helpers/buildAttachment';
@ -86,6 +87,11 @@ export default function useAttachmentModal({
const handleAppendFiles = useLastCallback(async (files: File[], isSpoiler?: boolean) => { const handleAppendFiles = useLastCallback(async (files: File[], isSpoiler?: boolean) => {
if (editedMessage) { if (editedMessage) {
if (editedMessage.groupedId && files[0].type === GIF_MIME_TYPE) {
showNotification({ message: lang('MediaReplaceInvalidError', undefined, { pluralValue: 1 }) });
return;
}
const newAttachment = await buildAttachment(files[0].name, files[0]); const newAttachment = await buildAttachment(files[0].name, files[0]);
const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment); const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment);
@ -109,6 +115,11 @@ export default function useAttachmentModal({
const handleFileSelect = useLastCallback(async (files: File[]) => { const handleFileSelect = useLastCallback(async (files: File[]) => {
if (editedMessage) { if (editedMessage) {
if (editedMessage.groupedId && files[0].type === GIF_MIME_TYPE) {
showNotification({ message: lang('MediaReplaceInvalidError', undefined, { pluralValue: 1 }) });
return;
}
const newAttachment = await buildAttachment(files[0].name, files[0]); const newAttachment = await buildAttachment(files[0].name, files[0]);
const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment); const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment);
@ -129,8 +140,10 @@ export default function useAttachmentModal({
}); });
const handleUpdateAttachmentsQuality = useLastCallback(async () => { const handleUpdateAttachmentsQuality = useLastCallback(async () => {
const newAttachments = await Promise.all(attachments.map((attachment) => const newAttachments = await Promise.all(attachments.map(async (attachment) => {
buildAttachment(attachment.filename, attachment.blob, { shouldSendInHighQuality }))); if (!attachment.blob) return attachment;
return buildAttachment(attachment.filename, attachment.blob, { shouldSendInHighQuality });
}));
handleSetAttachments(newAttachments); handleSetAttachments(newAttachments);
}); });

View File

@ -32,6 +32,7 @@ const useClipboardPaste = (
shouldStripCustomEmoji?: boolean, shouldStripCustomEmoji?: boolean,
onCustomEmojiStripped?: VoidFunction, onCustomEmojiStripped?: VoidFunction,
shouldUpdateAttachmentCompression?: boolean, shouldUpdateAttachmentCompression?: boolean,
shouldSkipFilePaste?: boolean,
) => { ) => {
const { const {
showNotification, showNotification,
@ -100,7 +101,7 @@ const useClipboardPaste = (
} }
const hasText = textToPaste && textToPaste.text; const hasText = textToPaste && textToPaste.text;
let shouldSetAttachments = files?.length && !isWordDocument; let shouldSetAttachments = files?.length && !isWordDocument && !shouldSkipFilePaste;
const newAttachments = files ? await Promise.all(files.map((file) => buildAttachment(file.name, file))) : []; const newAttachments = files ? await Promise.all(files.map((file) => buildAttachment(file.name, file))) : [];
const canReplace = (editedMessage && newAttachments?.length const canReplace = (editedMessage && newAttachments?.length
@ -154,7 +155,7 @@ const useClipboardPaste = (
}; };
}, [ }, [
insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji,
onCustomEmojiStripped, setNextText, lang, shouldUpdateAttachmentCompression, onCustomEmojiStripped, setNextText, lang, shouldUpdateAttachmentCompression, shouldSkipFilePaste,
]); ]);
}; };

View File

@ -299,14 +299,16 @@ const Video = <T,>({
style={forcedWidth ? `width: ${forcedWidth}px` : undefined} style={forcedWidth ? `width: ${forcedWidth}px` : undefined}
/> />
)} )}
<img {previewBlobUrl && (
ref={previewRef} <img
src={previewBlobUrl} ref={previewRef}
className={buildClassName('thumbnail', withBlurredBackground && 'with-blurred-bg')} src={previewBlobUrl}
alt="" className={buildClassName('thumbnail', withBlurredBackground && 'with-blurred-bg')}
style={forcedWidth ? `width: ${forcedWidth}px;` : undefined} alt=""
draggable={!isProtected} style={forcedWidth ? `width: ${forcedWidth}px;` : undefined}
/> draggable={!isProtected}
/>
)}
{hasThumb && !isPreviewPreloaded && ( {hasThumb && !isPreviewPreloaded && (
<canvas ref={thumbRef} className="thumbnail" /> <canvas ref={thumbRef} className="thumbnail" />
)} )}

View File

@ -3,8 +3,8 @@
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
src: url("./icons.woff2?d1a9cacb64e401206f928a39a587a2bd") format("woff2"), src: url("./icons.woff2?c37e5ad179a86df12e2f9214af6a855a") format("woff2"),
url("./icons.woff?d1a9cacb64e401206f928a39a587a2bd") format("woff"); url("./icons.woff?c37e5ad179a86df12e2f9214af6a855a") format("woff");
} }
.icon-char::before { .icon-char::before {
@ -900,87 +900,90 @@ url("./icons.woff?d1a9cacb64e401206f928a39a587a2bd") format("woff");
.icon-add-filled::before { .icon-add-filled::before {
content: "\f225"; content: "\f225";
} }
.icon-active-sessions::before { .icon-add-caption::before {
content: "\f226"; content: "\f226";
} }
.icon-folder-tabs-user::before { .icon-active-sessions::before {
content: "\f227"; content: "\f227";
} }
.icon-folder-tabs-star::before { .icon-rating-icons-negative::before {
content: "\f228"; content: "\f228";
} }
.icon-folder-tabs-group::before { .icon-rating-icons-level90::before {
content: "\f229"; content: "\f229";
} }
.icon-folder-tabs-folder::before { .icon-rating-icons-level9::before {
content: "\f22a"; content: "\f22a";
} }
.icon-folder-tabs-chats::before { .icon-rating-icons-level80::before {
content: "\f22b"; content: "\f22b";
} }
.icon-folder-tabs-chat::before { .icon-rating-icons-level8::before {
content: "\f22c"; content: "\f22c";
} }
.icon-folder-tabs-channel::before { .icon-rating-icons-level70::before {
content: "\f22d"; content: "\f22d";
} }
.icon-folder-tabs-bot::before { .icon-rating-icons-level7::before {
content: "\f22e"; content: "\f22e";
} }
.icon-rating-icons-negative::before { .icon-rating-icons-level60::before {
content: "\f22f"; content: "\f22f";
} }
.icon-rating-icons-level90::before { .icon-rating-icons-level6::before {
content: "\f230"; content: "\f230";
} }
.icon-rating-icons-level9::before { .icon-rating-icons-level50::before {
content: "\f231"; content: "\f231";
} }
.icon-rating-icons-level80::before { .icon-rating-icons-level5::before {
content: "\f232"; content: "\f232";
} }
.icon-rating-icons-level8::before { .icon-rating-icons-level40::before {
content: "\f233"; content: "\f233";
} }
.icon-rating-icons-level70::before { .icon-rating-icons-level4::before {
content: "\f234"; content: "\f234";
} }
.icon-rating-icons-level7::before { .icon-rating-icons-level30::before {
content: "\f235"; content: "\f235";
} }
.icon-rating-icons-level60::before { .icon-rating-icons-level3::before {
content: "\f236"; content: "\f236";
} }
.icon-rating-icons-level6::before { .icon-rating-icons-level20::before {
content: "\f237"; content: "\f237";
} }
.icon-rating-icons-level50::before { .icon-rating-icons-level2::before {
content: "\f238"; content: "\f238";
} }
.icon-rating-icons-level5::before { .icon-rating-icons-level10::before {
content: "\f239"; content: "\f239";
} }
.icon-rating-icons-level40::before { .icon-rating-icons-level1::before {
content: "\f23a"; content: "\f23a";
} }
.icon-rating-icons-level4::before { .icon-folder-tabs-user::before {
content: "\f23b"; content: "\f23b";
} }
.icon-rating-icons-level30::before { .icon-folder-tabs-star::before {
content: "\f23c"; content: "\f23c";
} }
.icon-rating-icons-level3::before { .icon-folder-tabs-group::before {
content: "\f23d"; content: "\f23d";
} }
.icon-rating-icons-level20::before { .icon-folder-tabs-folder::before {
content: "\f23e"; content: "\f23e";
} }
.icon-rating-icons-level2::before { .icon-folder-tabs-chats::before {
content: "\f23f"; content: "\f23f";
} }
.icon-rating-icons-level10::before { .icon-folder-tabs-chat::before {
content: "\f240"; content: "\f240";
} }
.icon-rating-icons-level1::before { .icon-folder-tabs-channel::before {
content: "\f241"; content: "\f241";
} }
.icon-folder-tabs-bot::before {
content: "\f242";
}

View File

@ -309,32 +309,33 @@ $icons-map: (
"add-user-filled": "\f223", "add-user-filled": "\f223",
"add-one-badge": "\f224", "add-one-badge": "\f224",
"add-filled": "\f225", "add-filled": "\f225",
"active-sessions": "\f226", "add-caption": "\f226",
"folder-tabs-user": "\f227", "active-sessions": "\f227",
"folder-tabs-star": "\f228", "rating-icons-negative": "\f228",
"folder-tabs-group": "\f229", "rating-icons-level90": "\f229",
"folder-tabs-folder": "\f22a", "rating-icons-level9": "\f22a",
"folder-tabs-chats": "\f22b", "rating-icons-level80": "\f22b",
"folder-tabs-chat": "\f22c", "rating-icons-level8": "\f22c",
"folder-tabs-channel": "\f22d", "rating-icons-level70": "\f22d",
"folder-tabs-bot": "\f22e", "rating-icons-level7": "\f22e",
"rating-icons-negative": "\f22f", "rating-icons-level60": "\f22f",
"rating-icons-level90": "\f230", "rating-icons-level6": "\f230",
"rating-icons-level9": "\f231", "rating-icons-level50": "\f231",
"rating-icons-level80": "\f232", "rating-icons-level5": "\f232",
"rating-icons-level8": "\f233", "rating-icons-level40": "\f233",
"rating-icons-level70": "\f234", "rating-icons-level4": "\f234",
"rating-icons-level7": "\f235", "rating-icons-level30": "\f235",
"rating-icons-level60": "\f236", "rating-icons-level3": "\f236",
"rating-icons-level6": "\f237", "rating-icons-level20": "\f237",
"rating-icons-level50": "\f238", "rating-icons-level2": "\f238",
"rating-icons-level5": "\f239", "rating-icons-level10": "\f239",
"rating-icons-level40": "\f23a", "rating-icons-level1": "\f23a",
"rating-icons-level4": "\f23b", "folder-tabs-user": "\f23b",
"rating-icons-level30": "\f23c", "folder-tabs-star": "\f23c",
"rating-icons-level3": "\f23d", "folder-tabs-group": "\f23d",
"rating-icons-level20": "\f23e", "folder-tabs-folder": "\f23e",
"rating-icons-level2": "\f23f", "folder-tabs-chats": "\f23f",
"rating-icons-level10": "\f240", "folder-tabs-chat": "\f240",
"rating-icons-level1": "\f241", "folder-tabs-channel": "\f241",
"folder-tabs-bot": "\f242",
); );

Binary file not shown.

Binary file not shown.

View File

@ -292,15 +292,8 @@ export type FontIconName =
| 'add-user-filled' | 'add-user-filled'
| 'add-one-badge' | 'add-one-badge'
| 'add-filled' | 'add-filled'
| 'add-caption'
| 'active-sessions' | 'active-sessions'
| 'folder-tabs-user'
| 'folder-tabs-star'
| 'folder-tabs-group'
| 'folder-tabs-folder'
| 'folder-tabs-chats'
| 'folder-tabs-chat'
| 'folder-tabs-channel'
| 'folder-tabs-bot'
| 'rating-icons-negative' | 'rating-icons-negative'
| 'rating-icons-level90' | 'rating-icons-level90'
| 'rating-icons-level9' | 'rating-icons-level9'
@ -319,4 +312,12 @@ export type FontIconName =
| 'rating-icons-level20' | 'rating-icons-level20'
| 'rating-icons-level2' | 'rating-icons-level2'
| 'rating-icons-level10' | 'rating-icons-level10'
| 'rating-icons-level1'; | 'rating-icons-level1'
| 'folder-tabs-user'
| 'folder-tabs-star'
| 'folder-tabs-group'
| 'folder-tabs-folder'
| 'folder-tabs-chats'
| 'folder-tabs-chat'
| 'folder-tabs-channel'
| 'folder-tabs-bot';

View File

@ -1958,6 +1958,8 @@ export interface LangPair {
'AttachmentMenuUngroupAllMedia': undefined; 'AttachmentMenuUngroupAllMedia': undefined;
'AttachmentMenuEnableSpoiler': undefined; 'AttachmentMenuEnableSpoiler': undefined;
'AttachmentMenuDisableSpoiler': undefined; 'AttachmentMenuDisableSpoiler': undefined;
'AttachmentSendGif': undefined;
'AttachmentReplaceGif': undefined;
'AttachmentDragAddItems': undefined; 'AttachmentDragAddItems': undefined;
'AttachmentCaptionPlaceholder': undefined; 'AttachmentCaptionPlaceholder': undefined;
'MessageSummaryTitle': undefined; 'MessageSummaryTitle': undefined;
@ -2002,6 +2004,7 @@ export interface LangPair {
'GiftPreviewToggleRegularModels': undefined; 'GiftPreviewToggleRegularModels': undefined;
'AriaGiftPreviewPlay': undefined; 'AriaGiftPreviewPlay': undefined;
'AriaGiftPreviewStop': undefined; 'AriaGiftPreviewStop': undefined;
'MenuAddCaption': undefined;
} }
export interface LangPairWithVariables<V = LangVariable> { export interface LangPairWithVariables<V = LangVariable> {