diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts
index cc6413416..6ca5ec8df 100644
--- a/src/api/gramjs/apiBuilders/messages.ts
+++ b/src/api/gramjs/apiBuilders/messages.ts
@@ -662,6 +662,10 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep
export function buildUploadingMedia(
attachment: ApiAttachment,
): MediaContent {
+ if (attachment.gif) {
+ return { video: attachment.gif };
+ }
+
const {
filename: fileName,
blobUrl,
diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts
index e32b2af5e..7e5483126 100644
--- a/src/api/gramjs/gramjsBuilders/index.ts
+++ b/src/api/gramjs/gramjsBuilders/index.ts
@@ -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);
if (!inputDocument) {
return undefined;
}
- return new GramJs.InputMediaDocument({ id: inputDocument });
+ return new GramJs.InputMediaDocument({ id: inputDocument, spoiler });
}
export function buildInputPoll(pollParams: ApiNewPoll, randomId: bigint) {
diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts
index 27e53efe6..8006b69f0 100644
--- a/src/api/gramjs/methods/messages.ts
+++ b/src/api/gramjs/methods/messages.ts
@@ -438,6 +438,9 @@ export function sendApiMessage(
}
}
+ if (!media && attachment?.gif) {
+ media = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
+ }
if (!media && attachment) {
try {
media = await uploadMedia(localMessage, attachment, onProgress!);
@@ -607,27 +610,33 @@ function sendGroupedMedia(
const prevMediaQueue = mediaQueue;
mediaQueue = (async () => {
- let media;
- try {
- media = await uploadMedia(localMessage, attachment, onProgress!);
- } catch (err) {
- if (DEBUG) {
- // eslint-disable-next-line no-console
- console.warn(err);
+ let inputMedia: GramJs.TypeInputMedia | undefined;
+
+ if (attachment.gif) {
+ inputMedia = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
+ } else {
+ let media;
+ 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--;
-
- await prevMediaQueue;
-
- return;
+ inputMedia = await fetchInputMedia(
+ buildInputPeer(chat.id, chat.accessHash),
+ media,
+ );
}
- const inputMedia = await fetchInputMedia(
- buildInputPeer(chat.id, chat.accessHash),
- media,
- );
-
await prevMediaQueue;
if (!inputMedia) {
diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts
index 037340d4f..7c9a752f6 100644
--- a/src/api/types/misc.ts
+++ b/src/api/types/misc.ts
@@ -1,7 +1,7 @@
import type { CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons';
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 { ApiBotVerification } from './peers';
import type { ApiStarsSubscriptionPricing } from './stars';
@@ -44,7 +44,7 @@ export interface ApiOnProgress {
}
export interface ApiAttachment {
- blob: Blob;
+ blob?: Blob;
blobUrl: string;
compressedBlobUrl?: string;
filename: string;
@@ -72,6 +72,8 @@ export interface ApiAttachment {
uniqueId?: string;
ttlSeconds?: number;
shouldSendInHighQuality?: boolean;
+
+ gif?: ApiVideo;
}
export interface ApiWallpaper {
diff --git a/src/assets/font-icons/add-caption.svg b/src/assets/font-icons/add-caption.svg
new file mode 100644
index 000000000..5dfcb4480
--- /dev/null
+++ b/src/assets/font-icons/add-caption.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings
index ae945568b..865740426 100644
--- a/src/assets/localization/fallback.strings
+++ b/src/assets/localization/fallback.strings
@@ -2666,6 +2666,8 @@
"AttachmentSendAudio_other" = "Send {count} Audios";
"AttachmentSendFile_one" = "Send File";
"AttachmentSendFile_other" = "Send {count} Files";
+"AttachmentSendGif" = "Send GIF";
+"AttachmentReplaceGif" = "Replace GIF";
"AttachmentDragAddItems" = "Add Items";
"AttachmentCaptionPlaceholder" = "Add a caption...";
"MessageSummaryTitle" = "AI Summary";
@@ -2729,3 +2731,4 @@
"GiftPreviewToggleRegularModels" = "View Primary Models >";
"AriaGiftPreviewPlay" = "Play random previews";
"AriaGiftPreviewStop" = "Pause random previews";
+"MenuAddCaption" = "Add Caption";
diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx
index 8210e867a..6f3907a3c 100644
--- a/src/components/common/Composer.tsx
+++ b/src/components/common/Composer.tsx
@@ -132,7 +132,10 @@ import { getServerTime } from '../../util/serverTime';
import windowSize from '../../util/windowSize';
import { DEFAULT_MAX_MESSAGE_LENGTH } from '../../limits';
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 { isSelectionInsideInput } from '../middle/composer/helpers/selection';
import renderText from './helpers/renderText';
@@ -1021,6 +1024,8 @@ const Composer = ({
theme,
});
+ const hasGifFromPicker = attachments.some((a) => a.gif);
+
useClipboardPaste(
isForCurrentMessageList || isInStoryViewer,
insertFormattedTextAndUpdateCursor,
@@ -1030,6 +1035,7 @@ const Composer = ({
!isCurrentUserPremium && !isChatWithSelf,
showCustomEmojiPremiumNotification,
!attachments.length,
+ hasGifFromPicker,
);
const handleEmbeddedClear = useLastCallback(() => {
@@ -1474,6 +1480,11 @@ const Composer = ({
clearDraft({ chatId, threadId, isLocalOnly: true });
});
+ const handleGifAddCaption = useLastCallback((gif: ApiVideo) => {
+ handleSetAttachments([buildGifAttachment(gif)]);
+ closeSymbolMenu();
+ });
+
const handleStickerSelect = useLastCallback((
sticker: ApiSticker,
isSilent?: boolean,
@@ -2234,6 +2245,7 @@ const Composer = ({
canSendGifs={canSendGifs}
isMessageComposer={isInMessageList}
onGifSelect={handleGifSelect}
+ onGifAddCaption={handleGifAddCaption}
onStickerSelect={handleStickerSelect}
onCustomEmojiSelect={handleCustomEmojiSelect}
onRemoveSymbol={removeSymbol}
diff --git a/src/components/common/GifButton.tsx b/src/components/common/GifButton.tsx
index 3c2018a11..36e38b9fb 100644
--- a/src/components/common/GifButton.tsx
+++ b/src/components/common/GifButton.tsx
@@ -16,6 +16,7 @@ import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
+import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useOldLang from '../../hooks/useOldLang';
@@ -33,9 +34,10 @@ type OwnProps = {
observeIntersection: ObserveFn;
isDisabled?: boolean;
className?: string;
+ isSavedMessages?: boolean;
onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onUnsaveClick?: (gif: ApiVideo) => void;
- isSavedMessages?: boolean;
+ onAddCaption?: (gif: ApiVideo) => void;
};
const GifButton: FC = ({
@@ -43,13 +45,15 @@ const GifButton: FC = ({
isDisabled,
className,
observeIntersection,
+ isSavedMessages,
onClick,
onUnsaveClick,
- isSavedMessages,
+ onAddCaption,
}) => {
const ref = useRef();
- const lang = useOldLang();
+ const oldLang = useOldLang();
+ const lang = useLang();
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const loadAndPlay = isIntersecting && !isDisabled;
@@ -76,6 +80,7 @@ const GifButton: FC = ({
const getTriggerElement = useLastCallback(() => ref.current);
const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll, .no-scrollbar'));
const getMenuElement = useLastCallback(() => ref.current!.querySelector('.gif-context-menu .bubble'));
+ const getLayout = useLastCallback(() => ({ shouldAvoidNegativePosition: true }));
const handleClick = useLastCallback(() => {
if (isContextMenuOpen || !onClick) return;
@@ -109,6 +114,13 @@ const GifButton: FC = ({
}, undefined, true);
});
+ const handleAddCaption = useLastCallback(() => {
+ onAddCaption?.({
+ ...gif,
+ blobUrl: videoData,
+ });
+ });
+
const handleMouseDown = useLastCallback((e: React.MouseEvent) => {
preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e);
@@ -182,17 +194,21 @@ const GifButton: FC = ({
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
+ getLayout={getLayout}
className="gif-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
>
- {!isSavedMessages && }
+ {!isSavedMessages && }
+ {onAddCaption && (
+
+ )}
{onUnsaveClick && (
-
+
)}
)}
diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx
index f3dd18081..3310df42d 100644
--- a/src/components/middle/composer/AttachmentModal.tsx
+++ b/src/components/middle/composer/AttachmentModal.tsx
@@ -9,6 +9,7 @@ import type { Signal } from '../../../util/signals';
import {
BASE_EMOJI_KEYWORD_LANG,
EDITABLE_INPUT_MODAL_ID,
+ GIF_MIME_TYPE,
SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES,
@@ -154,6 +155,7 @@ const AttachmentModal = ({
const svgRef = useRef();
const {
addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest,
+ updateShouldSaveAttachmentsCompression,
} = getActions();
const lang = useLang();
@@ -170,6 +172,7 @@ const AttachmentModal = ({
const isInAlbum = editingMessage && editingMessage?.groupedId;
const isEditingMessageFile = isEditing && attachments?.length && getAttachmentMediaType(attachments[0]);
const notEditingFile = isEditingMessageFile !== 'file';
+ const hasGifFromPicker = renderingAttachments?.some((a) => a.gif);
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [editingAttachmentIndex, setEditingAttachmentIndex] = useState(undefined);
@@ -185,7 +188,7 @@ const AttachmentModal = ({
const shouldSendCompressed = attachmentSettings.shouldCompress;
const isSendingCompressed = Boolean(
- (shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile,
+ (shouldSendCompressed || shouldForceCompression || isInAlbum || hasGifFromPicker) && !shouldForceAsFile,
);
const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped);
const isInvertedMedia = attachmentSettings.isInvertedMedia;
@@ -215,6 +218,13 @@ const AttachmentModal = ({
}
}, [closeSymbolMenu, isOpen]);
+ useEffect(() => {
+ if (hasGifFromPicker) {
+ updateShouldSaveAttachmentsCompression({ shouldSave: false });
+ setShouldSendGrouped(false);
+ }
+ }, [hasGifFromPicker, updateShouldSaveAttachmentsCompression]);
+
const [hasMedia, hasOnlyMedia] = useMemo(() => {
const onlyMedia = Boolean(renderingAttachments?.every((a) => a.quick || a.audio));
if (onlyMedia) return [true, true];
@@ -509,13 +519,25 @@ const AttachmentModal = ({
const isQuickGallery = isSendingCompressed && hasOnlyMedia;
- const [areAllPhotos, areAllVideos, areAllAudios, hasAnyPhoto] = useMemo(() => {
- if (!isQuickGallery || !renderingAttachments) return [false, false, false];
- const everyPhoto = renderingAttachments.every((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType));
- const everyVideo = renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType));
- const everyAudio = renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType));
- const anyPhoto = renderingAttachments.some((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType));
- return [everyPhoto, everyVideo, everyAudio, anyPhoto];
+ const {
+ areAllPhotos, areAllVideos, areAllAudios, areAllGifs, hasAnyPhoto,
+ } = useMemo(() => {
+ if (!isQuickGallery || !renderingAttachments) {
+ return {
+ areAllPhotos: false,
+ 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]);
const hasAnySpoilerable = useMemo(() => {
@@ -553,7 +575,10 @@ const AttachmentModal = ({
let title = '';
const attachmentsLength = renderingAttachments.length;
- if (areAllPhotos) {
+
+ if (areAllGifs) {
+ title = lang(isEditing ? 'AttachmentReplaceGif' : 'AttachmentSendGif');
+ } else if (areAllPhotos) {
title = lang(
`Attachment${isEditing ? 'Replace' : 'Send'}Photo`,
{ count: attachmentsLength },
@@ -602,7 +627,7 @@ const AttachmentModal = ({
trigger={MoreMenuButton}
positionX="right"
>
- {Boolean(!editingMessage) && (
+ {Boolean(!editingMessage) && !hasGifFromPicker && (
)}
{hasMedia && (
@@ -621,7 +646,7 @@ const AttachmentModal = ({
))
}
{
- !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? (
+ !shouldForceAsFile && !shouldForceCompression && !hasGifFromPicker && (isSendingCompressed ? (