From d05fecddf49fa0390a9ee8fed6f7f940934f5578 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 19 Mar 2022 21:19:19 +0100 Subject: [PATCH] Support saving GIFs, support scheduling GIFs and stickers (#1739) --- src/api/gramjs/methods/bots.ts | 6 +- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/symbols.ts | 9 + src/bundles/extra.ts | 7 + src/bundles/main.ts | 1 + src/components/common/GifButton.scss | 41 ++- src/components/common/GifButton.tsx | 132 +++++++++- src/components/common/StickerButton.scss | 10 + src/components/common/StickerButton.tsx | 146 +++++++++-- src/components/common/StickerSetModal.tsx | 38 ++- .../left/settings/SettingsStickerSet.tsx | 2 + src/components/middle/ContactGreeting.tsx | 1 + .../middle/composer/AttachmentModal.tsx | 26 +- src/components/middle/composer/Composer.tsx | 241 ++++++++++-------- .../composer/ComposerEmbeddedMessage.tsx | 5 +- .../middle/composer/CustomSendMenu.tsx | 21 +- src/components/middle/composer/GifPicker.tsx | 21 +- .../middle/composer/InlineBotTooltip.scss | 2 - .../middle/composer/InlineBotTooltip.tsx | 13 +- .../middle/composer/StickerPicker.tsx | 24 +- src/components/middle/composer/StickerSet.tsx | 30 ++- .../middle/composer/StickerTooltip.tsx | 13 +- src/components/middle/composer/SymbolMenu.tsx | 10 +- .../composer/inlineResults/GifResult.tsx | 15 +- .../composer/inlineResults/StickerResult.tsx | 11 +- .../middle/message/ContextMenuContainer.tsx | 15 +- .../middle/message/MessageContextMenu.tsx | 5 + src/components/right/GifSearch.tsx | 40 ++- src/components/right/StickerSetResult.tsx | 2 + src/global/actions/api/bots.ts | 6 +- src/global/actions/api/symbols.ts | 23 ++ src/global/selectors/messages.ts | 22 +- src/global/types.ts | 2 +- src/hooks/useContextMenuHandlers.ts | 9 +- src/hooks/useContextMenuPosition.ts | 8 +- src/hooks/useSchedule.tsx | 65 +++++ src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.json | 1 + 38 files changed, 804 insertions(+), 222 deletions(-) create mode 100644 src/hooks/useSchedule.tsx diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index f47c0e1b8..92d5f1de5 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -98,13 +98,15 @@ export async function fetchInlineBotResults({ } export async function sendInlineBotResult({ - chat, resultId, queryId, replyingTo, sendAs, + chat, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate, }: { chat: ApiChat; resultId: string; queryId: string; replyingTo?: number; sendAs?: ApiUser | ApiChat; + isSilent?: boolean; + scheduleDate?: number; }) { const randomId = generateRandomBigInt(); @@ -114,6 +116,8 @@ export async function sendInlineBotResult({ queryId: BigInt(queryId), peer: buildInputPeer(chat.id, chat.accessHash), id: resultId, + scheduleDate, + ...(isSilent && { silent: true }), ...(replyingTo && { replyToMsgId: replyingTo }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), }), true); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index fbe3eab97..3f468b300 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -34,7 +34,7 @@ export { export { fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers, - faveSticker, fetchStickers, fetchSavedGifs, searchStickers, installStickerSet, uninstallStickerSet, + faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, } from './symbols'; diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index 0a21fe6c8..bb9997d1a 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -174,6 +174,15 @@ export async function fetchSavedGifs({ hash = '0' }: { hash?: string }) { }; } +export function saveGif({ gif, shouldUnsave }: { gif: ApiVideo; shouldUnsave?: boolean }) { + const request = new GramJs.messages.SaveGif({ + id: buildInputDocument(gif), + unsave: shouldUnsave, + }); + + return invokeRequest(request, true); +} + export async function installStickerSet({ stickerSetId, accessHash }: { stickerSetId: string; accessHash: string }) { const result = await invokeRequest(new GramJs.messages.InstallStickerSet({ stickerset: buildInputStickerSet(stickerSetId, accessHash), diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 6056c275c..6d42f390f 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -15,20 +15,25 @@ export { default as SeenByModal } from '../components/common/SeenByModal'; export { default as ReactorListModal } from '../components/middle/ReactorListModal'; export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation'; +// eslint-disable-next-line import/no-cycle export { default as LeftSearch } from '../components/left/search/LeftSearch'; +// eslint-disable-next-line import/no-cycle export { default as Settings } from '../components/left/settings/Settings'; export { default as ContactList } from '../components/left/main/ContactList'; export { default as NewChat } from '../components/left/newChat/NewChat'; export { default as NewChatStep1 } from '../components/left/newChat/NewChatStep1'; export { default as NewChatStep2 } from '../components/left/newChat/NewChatStep2'; +// eslint-disable-next-line import/no-cycle export { default as ArchivedChats } from '../components/left/ArchivedChats'; export { default as ChatFolderModal } from '../components/left/ChatFolderModal'; export { default as ContextMenuContainer } from '../components/middle/message/ContextMenuContainer'; +// eslint-disable-next-line import/no-cycle export { default as StickerSetModal } from '../components/common/StickerSetModal'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; export { default as MobileSearch } from '../components/middle/MobileSearch'; +// eslint-disable-next-line import/no-cycle export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as PollModal } from '../components/middle/composer/PollModal'; export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; @@ -44,7 +49,9 @@ export { default as InlineBotTooltip } from '../components/middle/composer/Inlin export { default as SendAsMenu } from '../components/middle/composer/SendAsMenu'; export { default as RightSearch } from '../components/right/RightSearch'; +// eslint-disable-next-line import/no-cycle export { default as StickerSearch } from '../components/right/StickerSearch'; +// eslint-disable-next-line import/no-cycle export { default as GifSearch } from '../components/right/GifSearch'; export { default as Statistics } from '../components/right/statistics/Statistics'; export { default as PollResults } from '../components/right/PollResults'; diff --git a/src/bundles/main.ts b/src/bundles/main.ts index 4203958c6..ed00dcae0 100644 --- a/src/bundles/main.ts +++ b/src/bundles/main.ts @@ -2,6 +2,7 @@ import { getActions, getGlobal } from '../global'; import { DEBUG } from '../config'; +// eslint-disable-next-line import/no-cycle export { default as Main } from '../components/main/Main'; if (DEBUG) { diff --git a/src/components/common/GifButton.scss b/src/components/common/GifButton.scss index eeca99320..e09e70bae 100644 --- a/src/components/common/GifButton.scss +++ b/src/components/common/GifButton.scss @@ -4,10 +4,14 @@ justify-content: center; height: 6.25rem; background-color: transparent; - cursor: pointer; - overflow: hidden; position: relative; + &:hover { + .gif-unsave-button { + opacity: 0.8; + } + } + &:last-child { margin-bottom: 1rem; } @@ -20,6 +24,10 @@ grid-column-end: span 2; } + &.interactive { + cursor: pointer; + } + .thumbnail { width: 100%; height: 100%; @@ -34,10 +42,39 @@ width: 100%; height: 100%; object-fit: cover; + + -webkit-touch-callout: none; + user-select: none; } .Spinner { position: absolute; pointer-events: none; } + + .gif-unsave-button { + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 1rem; + height: 1rem; + padding: 0.125rem; + border-radius: 0.25rem; + transition: 0.15s opacity ease-in-out; + + &-icon { + font-size: 0.75rem; + } + + opacity: 0; + z-index: 1; + } + + .gif-context-menu { + position: absolute; + + .bubble { + width: auto; + } + } } diff --git a/src/components/common/GifButton.tsx b/src/components/common/GifButton.tsx index d72ef2ac6..e2ff0b00e 100644 --- a/src/components/common/GifButton.tsx +++ b/src/components/common/GifButton.tsx @@ -1,18 +1,26 @@ import React, { - FC, memo, useCallback, useRef, + FC, memo, useCallback, useEffect, useRef, } from '../../lib/teact/teact'; import { ApiMediaFormat, ApiVideo } from '../../api/types'; +import { IS_TOUCH_ENV } from '../../util/environment'; import buildClassName from '../../util/buildClassName'; import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver'; +import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; + import useMedia from '../../hooks/useMedia'; import useVideoCleanup from '../../hooks/useVideoCleanup'; import useBuffering from '../../hooks/useBuffering'; import useCanvasBlur from '../../hooks/useCanvasBlur'; -import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; +import useLang from '../../hooks/useLang'; +import useContextMenuPosition from '../../hooks/useContextMenuPosition'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import Spinner from '../ui/Spinner'; +import Button from '../ui/Button'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; import './GifButton.scss'; @@ -21,17 +29,27 @@ type OwnProps = { observeIntersection: ObserveFn; isDisabled?: boolean; className?: string; - onClick: (gif: ApiVideo) => void; + onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; + onUnsaveClick?: (gif: ApiVideo) => void; + isSavedMessages?: boolean; }; const GifButton: FC = ({ - gif, observeIntersection, isDisabled, className, onClick, + gif, + isDisabled, + className, + observeIntersection, + onClick, + onUnsaveClick, + isSavedMessages, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); // eslint-disable-next-line no-null/no-null const videoRef = useRef(null); + const lang = useLang(); + const hasThumbnail = Boolean(gif.thumbnail?.dataUri); const localMediaHash = `gif${gif.id}`; const isIntersecting = useIsIntersecting(ref, observeIntersection); @@ -46,17 +64,78 @@ const GifButton: FC = ({ useVideoCleanup(videoRef, [shouldRenderVideo]); - const handleClick = useCallback( - () => onClick({ + const { + isContextMenuOpen, contextMenuPosition, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref); + + const getTriggerElement = useCallback(() => ref.current, []); + + const getRootElement = useCallback( + () => ref.current!.closest('.custom-scroll, .no-scrollbar'), + [], + ); + + const getMenuElement = useCallback( + () => ref.current!.querySelector('.gif-context-menu .bubble'), + [], + ); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useContextMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + ); + + const handleClick = useCallback(() => { + if (isContextMenuOpen || !onClick) return; + onClick({ ...gif, blobUrl: videoData, - }), - [onClick, gif, videoData], - ); + }); + }, [isContextMenuOpen, onClick, gif, videoData]); + + const handleUnsaveClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onUnsaveClick!(gif); + }, [onUnsaveClick, gif]); + + const handleContextDelete = useCallback(() => { + onUnsaveClick?.(gif); + }, [gif, onUnsaveClick]); + + const handleSendQuiet = useCallback(() => { + onClick!({ + ...gif, + blobUrl: videoData, + }, true); + }, [gif, onClick, videoData]); + + const handleSendScheduled = useCallback(() => { + onClick!({ + ...gif, + blobUrl: videoData, + }, undefined, true); + }, [gif, onClick, videoData]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + preventMessageInputBlurWithBubbling(e); + handleBeforeContextMenu(e); + }, [handleBeforeContextMenu]); + + useEffect(() => { + if (isDisabled) handleContextMenuClose(); + }, [handleContextMenuClose, isDisabled]); const fullClassName = buildClassName( 'GifButton', gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal', + onClick && 'interactive', localMediaHash, className, ); @@ -65,9 +144,20 @@ const GifButton: FC = ({
+ {!IS_TOUCH_ENV && onUnsaveClick && ( + + )} {hasThumbnail && ( = ({ {shouldRenderSpinner && ( )} + {onClick && contextMenuPosition !== undefined && ( + + {!isSavedMessages && {lang('SendWithoutSound')}} + + {lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} + + {onUnsaveClick && ( + {lang('Delete')} + )} + + )}
); }; diff --git a/src/components/common/StickerButton.scss b/src/components/common/StickerButton.scss index 756610eba..1a8f468cd 100644 --- a/src/components/common/StickerButton.scss +++ b/src/components/common/StickerButton.scss @@ -50,6 +50,8 @@ img, video { object-fit: contain; + -webkit-touch-callout: none; + user-select: none; } .sticker-unfave-button { @@ -66,4 +68,12 @@ opacity: 0; } + + .sticker-context-menu { + position: absolute; + + .bubble { + width: auto; + } + } } diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 4747c10ed..67cccb5c4 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -1,41 +1,62 @@ import { MouseEvent as ReactMouseEvent } from 'react'; import React, { - FC, memo, useEffect, useRef, + memo, useCallback, useEffect, useRef, } from '../../lib/teact/teact'; -import { ApiMediaFormat, ApiSticker } from '../../api/types'; +import { ApiBotInlineMediaResult, ApiMediaFormat, ApiSticker } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; +import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; +import safePlay from '../../util/safePlay'; +import { IS_TOUCH_ENV, IS_WEBM_SUPPORTED } from '../../util/environment'; import { useIsIntersecting, ObserveFn } from '../../hooks/useIntersectionObserver'; import useMedia from '../../hooks/useMedia'; import useShowTransition from '../../hooks/useShowTransition'; import useFlag from '../../hooks/useFlag'; -import buildClassName from '../../util/buildClassName'; -import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; -import safePlay from '../../util/safePlay'; -import { IS_WEBM_SUPPORTED } from '../../util/environment'; +import useLang from '../../hooks/useLang'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import useContextMenuPosition from '../../hooks/useContextMenuPosition'; import AnimatedSticker from './AnimatedSticker'; import Button from '../ui/Button'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; import './StickerButton.scss'; -type OwnProps = { +type OwnProps = { sticker: ApiSticker; size: number; - observeIntersection: ObserveFn; noAnimate?: boolean; title?: string; className?: string; - onClick?: (arg: any) => void; - clickArg?: any; + clickArg: T; + noContextMenu?: boolean; + isSavedMessages?: boolean; + observeIntersection: ObserveFn; + onClick?: (arg: OwnProps['clickArg'], isSilent?: boolean, shouldSchedule?: boolean) => void; + onFaveClick?: (sticker: ApiSticker) => void; onUnfaveClick?: (sticker: ApiSticker) => void; }; -const StickerButton: FC = ({ - sticker, size, observeIntersection, noAnimate, title, className, onClick, clickArg, onUnfaveClick, -}) => { +const StickerButton = ({ + sticker, + size, + noAnimate, + title, + className, + clickArg, + noContextMenu, + isSavedMessages, + observeIntersection, + onClick, + onFaveClick, + onUnfaveClick, +}: OwnProps) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); + const lang = useLang(); const localMediaHash = `sticker${sticker.id}`; const stickerSelector = `sticker-button-${sticker.id}`; @@ -60,6 +81,33 @@ const StickerButton: FC = ({ 'slow', ); + const { + isContextMenuOpen, contextMenuPosition, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref); + + const getTriggerElement = useCallback(() => ref.current, []); + + const getRootElement = useCallback( + () => ref.current!.closest('.custom-scroll, .no-scrollbar'), + [], + ); + + const getMenuElement = useCallback( + () => ref.current!.querySelector('.sticker-context-menu .bubble'), + [], + ); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useContextMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + ); + // To avoid flickering useEffect(() => { if (!shouldPlay) { @@ -78,18 +126,42 @@ const StickerButton: FC = ({ } }, [isVideo, canVideoPlay]); - function handleClick() { - if (onClick) { - onClick(clickArg); - } - } + useEffect(() => { + if (!isIntersecting) handleContextMenuClose(); + }, [handleContextMenuClose, isIntersecting]); - function handleUnfaveClick(e: ReactMouseEvent) { + const handleClick = () => { + if (isContextMenuOpen) return; + onClick?.(clickArg); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + preventMessageInputBlurWithBubbling(e); + handleBeforeContextMenu(e); + }; + + const handleUnfaveClick = (e: ReactMouseEvent) => { e.stopPropagation(); e.preventDefault(); onUnfaveClick!(sticker); - } + }; + + const handleContextUnfave = () => { + onUnfaveClick!(sticker); + }; + + const handleContextFave = () => { + onFaveClick!(sticker); + }; + + const handleSendQuiet = () => { + onClick?.(clickArg, true); + }; + + const handleSendScheduled = () => { + onClick?.(clickArg, undefined, true); + }; const fullClassName = buildClassName( 'StickerButton', @@ -107,8 +179,9 @@ const StickerButton: FC = ({ title={title || (sticker?.emoji)} style={style} data-sticker-id={sticker.id} - onMouseDown={preventMessageInputBlurWithBubbling} + onMouseDown={handleMouseDown} onClick={handleClick} + onContextMenu={handleContextMenu} > {!canLottiePlay && !canVideoPlay && ( // eslint-disable-next-line jsx-a11y/alt-text @@ -134,7 +207,7 @@ const StickerButton: FC = ({ onLoad={markLoaded} /> )} - {onUnfaveClick && ( + {!IS_TOUCH_ENV && onUnfaveClick && ( )} + {!noContextMenu && onClick && contextMenuPosition !== undefined && ( + + {onUnfaveClick && ( + + {lang('Stickers.RemoveFromFavorites')} + + )} + {onFaveClick && ( + + {lang('AddToFavorites')} + + )} + {!isSavedMessages && {lang('SendWithoutSound')}} + + {lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} + + + )} ); }; diff --git a/src/components/common/StickerSetModal.tsx b/src/components/common/StickerSetModal.tsx index b99297d81..14c80fa40 100644 --- a/src/components/common/StickerSetModal.tsx +++ b/src/components/common/StickerSetModal.tsx @@ -7,12 +7,19 @@ import { ApiSticker, ApiStickerSet } from '../../api/types'; import { STICKER_SIZE_MODAL } from '../../config'; import { - selectChat, selectCurrentMessageList, selectStickerSet, selectStickerSetByShortName, + selectCanScheduleUntilOnline, + selectChat, + selectCurrentMessageList, + selectIsChatWithSelf, + selectShouldSchedule, + selectStickerSet, + selectStickerSetByShortName, } from '../../global/selectors'; import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import useLang from '../../hooks/useLang'; import renderText from './helpers/renderText'; import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers'; +import useSchedule from '../../hooks/useSchedule'; import Modal from '../ui/Modal'; import Button from '../ui/Button'; @@ -31,6 +38,9 @@ export type OwnProps = { type StateProps = { canSendStickers?: boolean; stickerSet?: ApiStickerSet; + canScheduleUntilOnline?: boolean; + shouldSchedule?: boolean; + isSavedMessages?: boolean; }; const INTERSECTION_THROTTLE = 200; @@ -41,6 +51,9 @@ const StickerSetModal: FC = ({ stickerSetShortName, stickerSet, canSendStickers, + canScheduleUntilOnline, + shouldSchedule, + isSavedMessages, onClose, }) => { const { @@ -53,6 +66,8 @@ const StickerSetModal: FC = ({ const containerRef = useRef(null); const lang = useLang(); + const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline); + const { observe: observeIntersection, } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen }); @@ -73,15 +88,22 @@ const StickerSetModal: FC = ({ } }, [isOpen, fromSticker, loadStickers, stickerSetShortName]); - const handleSelect = useCallback((sticker: ApiSticker) => { + const handleSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean) => { sticker = { ...sticker, isPreloadedGlobally: true, }; - sendMessage({ sticker }); - onClose(); - }, [onClose, sendMessage]); + if (shouldSchedule || isScheduleRequested) { + requestCalendar((scheduledAt) => { + sendMessage({ sticker, isSilent, scheduledAt }); + onClose(); + }); + } else { + sendMessage({ sticker, isSilent }); + onClose(); + } + }, [onClose, requestCalendar, sendMessage, shouldSchedule]); const handleButtonClick = useCallback(() => { if (stickerSet) { @@ -108,6 +130,7 @@ const StickerSetModal: FC = ({ observeIntersection={observeIntersection} onClick={canSendStickers ? handleSelect : undefined} clickArg={sticker} + isSavedMessages={isSavedMessages} /> ))} @@ -129,6 +152,7 @@ const StickerSetModal: FC = ({ ) : ( )} + {calendar} ); }; @@ -142,9 +166,13 @@ export default memo(withGlobal( const canSendStickers = Boolean( chat && threadId && getCanPostInChat(chat, threadId) && sendOptions?.canSendStickers, ); + const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); return { + canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId), canSendStickers, + isSavedMessages, + shouldSchedule: selectShouldSchedule(global), stickerSet: fromSticker ? selectStickerSet(global, fromSticker.stickerSetId) : stickerSetShortName diff --git a/src/components/left/settings/SettingsStickerSet.tsx b/src/components/left/settings/SettingsStickerSet.tsx index 60d0f07a9..5d14abf05 100644 --- a/src/components/left/settings/SettingsStickerSet.tsx +++ b/src/components/left/settings/SettingsStickerSet.tsx @@ -78,6 +78,8 @@ const SettingsStickerSet: FC = ({ size={STICKER_SIZE_GENERAL_SETTINGS} title={stickerSet.title} observeIntersection={observeIntersection} + clickArg={undefined} + noContextMenu />
{stickerSet.title}
diff --git a/src/components/middle/ContactGreeting.tsx b/src/components/middle/ContactGreeting.tsx index d1b2cb28e..6c71c3383 100644 --- a/src/components/middle/ContactGreeting.tsx +++ b/src/components/middle/ContactGreeting.tsx @@ -83,6 +83,7 @@ const ContactGreeting: FC = ({ observeIntersection={observeIntersection} size={160} className="large" + noContextMenu /> )}
diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 061a1495a..41ab657bb 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -12,6 +12,7 @@ import { } from '../../../config'; import { getFileExtension } from '../../common/helpers/documentInfo'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; + import usePrevious from '../../../hooks/usePrevious'; import useMentionTooltip from './hooks/useMentionTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip'; @@ -43,13 +44,14 @@ export type OwnProps = { recentEmojis: string[]; baseEmojiKeywords?: Record; emojiKeywords?: Record; + shouldSchedule?: boolean; addRecentEmoji: AnyToVoidFunction; onCaptionUpdate: (html: string) => void; onSend: () => void; onFileAppend: (files: File[], isQuick: boolean) => void; onClear: () => void; - onSilentSend: () => void; - openCalendar: () => void; + onSendSilent: () => void; + onSendScheduled: () => void; }; const DROP_LEAVE_TIMEOUT_MS = 150; @@ -67,13 +69,14 @@ const AttachmentModal: FC = ({ recentEmojis, baseEmojiKeywords, emojiKeywords, + shouldSchedule, addRecentEmoji, onCaptionUpdate, onSend, onFileAppend, onClear, - onSilentSend, - openCalendar, + onSendSilent, + onSendScheduled, }) => { const captionRef = useStateRef(caption); // eslint-disable-next-line no-null/no-null @@ -121,9 +124,13 @@ const AttachmentModal: FC = ({ const sendAttachments = useCallback(() => { if (isOpen) { - onSend(); + if (shouldSchedule) { + onSendScheduled(); + } else { + onSend(); + } } - }, [isOpen, onSend]); + }, [isOpen, onSendScheduled, onSend, shouldSchedule]); const handleDragLeave = (e: React.DragEvent) => { const { relatedTarget: toTarget, target: fromTarget } = e; @@ -217,10 +224,11 @@ const AttachmentModal: FC = ({ )} @@ -288,7 +296,7 @@ const AttachmentModal: FC = ({ editableInputId={EDITABLE_INPUT_MODAL_ID} placeholder={lang('Caption')} onUpdate={onCaptionUpdate} - onSend={onSend} + onSend={sendAttachments} canAutoFocus={Boolean(isReady && attachments.length)} /> diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index b53d22f8e..111a63cb1 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -22,7 +22,7 @@ import { import { InlineBotSettings } from '../../../types'; import { - BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, REPLIES_USER_ID, SCHEDULED_WHEN_ONLINE, SEND_MESSAGE_ACTION_INTERVAL, + BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, REPLIES_USER_ID, SEND_MESSAGE_ACTION_INTERVAL, } from '../../../config'; import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; @@ -36,20 +36,18 @@ import { selectEditingMessage, selectIsChatWithSelf, selectChatBot, - selectChatUser, selectChatMessage, selectUser, - selectUserStatus, + selectCanScheduleUntilOnline, } from '../../../global/selectors'; import { getAllowedAttachmentOptions, getChatSlowModeOptions, - isUserId, isChatAdmin, isChatSuperGroup, isChatChannel, } from '../../../global/helpers'; -import { formatMediaDuration, formatVoiceRecordDuration, getDayStartAt } from '../../../util/dateFormat'; +import { formatMediaDuration, formatVoiceRecordDuration } from '../../../util/dateFormat'; import focusEditableElement from '../../../util/focusEditableElement'; import parseMessageInput from '../../../util/parseMessageInput'; import buildAttachment from './helpers/buildAttachment'; @@ -79,12 +77,12 @@ import useEmojiTooltip from './hooks/useEmojiTooltip'; import useMentionTooltip from './hooks/useMentionTooltip'; import useInlineBotTooltip from './hooks/useInlineBotTooltip'; import useBotCommandTooltip from './hooks/useBotCommandTooltip'; +import useSchedule from '../../../hooks/useSchedule'; import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import Button from '../../ui/Button'; import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; import Spinner from '../../ui/Spinner'; -import CalendarModal from '../../common/CalendarModal.async'; import AttachMenu from './AttachMenu'; import Avatar from '../../common/Avatar'; import SymbolMenu from './SymbolMenu.async'; @@ -159,6 +157,10 @@ enum MainButtonState { Schedule = 'schedule', } +type ScheduledMessageArgs = GlobalState['messages']['contentToBeScheduled'] | { + id: string; queryId: string; isSilent?: boolean; +}; + const VOICE_RECORDING_FILENAME = 'wonderful-voice-message.ogg'; // When voice recording is active, composer placeholder will hide to prevent overlapping const SCREEN_WIDTH_TO_HIDE_PLACEHOLDER = 600; // px @@ -236,15 +238,18 @@ const Composer: FC = ({ const htmlRef = useStateRef(html); const lastMessageSendTimeSeconds = useRef(); const prevDropAreaState = usePrevious(dropAreaState); - const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); - const [ - scheduledMessageArgs, setScheduledMessageArgs, - ] = useState(); const { width: windowWidth } = windowSize.get(); const sendAsIds = chat?.sendAsIds; const canShowSendAs = sendAsIds && (sendAsIds.length > 1 || !sendAsIds.includes(currentUserId!)); + // Prevent Symbol Menu from closing when calendar is open + const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const sendMessageAction = useSendMessageAction(chatId, threadId); + const handleScheduleCancel = useCallback(() => { + cancelForceShowSymbolMenu(); + }, [cancelForceShowSymbolMenu]); + const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, handleScheduleCancel); + useEffect(() => { lastMessageSendTimeSeconds.current = undefined; }, [chatId]); @@ -279,13 +284,6 @@ const Composer: FC = ({ appendixRef.current.innerHTML = APPENDIX; }, []); - useEffect(() => { - if (contentToBeScheduled) { - setScheduledMessageArgs(contentToBeScheduled); - openCalendar(); - } - }, [contentToBeScheduled, openCalendar]); - const [attachments, setAttachments] = useState([]); const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag(); @@ -438,8 +436,6 @@ const Composer: FC = ({ } setAttachments(MEMO_EMPTY_ARRAY); closeStickerTooltip(); - closeCalendar(); - setScheduledMessageArgs(undefined); closeMentionTooltip(); closeEmojiTooltip(); @@ -449,7 +445,7 @@ const Composer: FC = ({ } else { closeSymbolMenu(); } - }, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); + }, [closeStickerTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); // Handle chat change (ref is used to avoid redundant effect calls) const stopRecordingVoiceRef = useRef(); @@ -600,44 +596,111 @@ const Composer: FC = ({ openSymbolMenu(); }, [closeBotCommandMenu, closeSendAsMenu, openSymbolMenu]); - const handleStickerSelect = useCallback((sticker: ApiSticker, shouldPreserveInput = false) => { + const handleMessageSchedule = useCallback(( + args: ScheduledMessageArgs, scheduledAt: number, + ) => { + if (args && 'queryId' in args) { + const { id, queryId, isSilent } = args; + sendInlineBotResult({ + id, + queryId, + scheduledAt, + isSilent, + }); + return; + } + + const { isSilent, ...restArgs } = args || {}; + + if (!args || Object.keys(restArgs).length === 0) { + void handleSend(Boolean(isSilent), scheduledAt); + } else { + sendMessage({ + ...args, + scheduledAt, + }); + } + }, [handleSend, sendInlineBotResult, sendMessage]); + + useEffect(() => { + if (contentToBeScheduled) { + requestCalendar((scheduledAt) => { + handleMessageSchedule(contentToBeScheduled, scheduledAt); + }); + } + }, [contentToBeScheduled, handleMessageSchedule, requestCalendar]); + + const handleStickerSelect = useCallback(( + sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, + ) => { sticker = { ...sticker, isPreloadedGlobally: true, }; - if (shouldSchedule) { - setScheduledMessageArgs({ sticker }); - openCalendar(); + if (shouldSchedule || isScheduleRequested) { + forceShowSymbolMenu(); + requestCalendar((scheduledAt) => { + cancelForceShowSymbolMenu(); + handleMessageSchedule({ sticker, isSilent }, scheduledAt); + requestAnimationFrame(() => { + resetComposer(shouldPreserveInput); + }); + }); } else { - sendMessage({ sticker }); + sendMessage({ sticker, isSilent }); requestAnimationFrame(() => { resetComposer(shouldPreserveInput); }); } - }, [shouldSchedule, openCalendar, sendMessage, resetComposer]); + }, [ + shouldSchedule, forceShowSymbolMenu, requestCalendar, cancelForceShowSymbolMenu, handleMessageSchedule, + resetComposer, sendMessage, + ]); - const handleGifSelect = useCallback((gif: ApiVideo) => { - if (shouldSchedule) { - setScheduledMessageArgs({ gif }); - openCalendar(); + const handleGifSelect = useCallback((gif: ApiVideo, isSilent?: boolean, isScheduleRequested?: boolean) => { + if (shouldSchedule || isScheduleRequested) { + forceShowSymbolMenu(); + requestCalendar((scheduledAt) => { + cancelForceShowSymbolMenu(); + handleMessageSchedule({ gif, isSilent }, scheduledAt); + requestAnimationFrame(() => { + resetComposer(true); + }); + }); } else { - sendMessage({ gif }); + sendMessage({ gif, isSilent }); requestAnimationFrame(() => { resetComposer(true); }); } - }, [shouldSchedule, openCalendar, sendMessage, resetComposer]); + }, [ + shouldSchedule, forceShowSymbolMenu, requestCalendar, cancelForceShowSymbolMenu, handleMessageSchedule, + resetComposer, sendMessage, + ]); - const handleInlineBotSelect = useCallback((inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult) => { + const handleInlineBotSelect = useCallback(( + inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult, isSilent?: boolean, isScheduleRequested?: boolean, + ) => { if (connectionState !== 'connectionStateReady') { return; } - sendInlineBotResult({ - id: inlineResult.id, - queryId: inlineResult.queryId, - }); + if (shouldSchedule || isScheduleRequested) { + requestCalendar((scheduledAt) => { + handleMessageSchedule({ + id: inlineResult.id, + queryId: inlineResult.queryId, + isSilent, + }, scheduledAt); + }); + } else { + sendInlineBotResult({ + id: inlineResult.id, + queryId: inlineResult.queryId, + isSilent, + }); + } const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; if (IS_IOS && messageInput === document.activeElement) { @@ -648,7 +711,10 @@ const Composer: FC = ({ requestAnimationFrame(() => { resetComposer(); }); - }, [chatId, clearDraft, connectionState, resetComposer, sendInlineBotResult]); + }, [ + chatId, clearDraft, connectionState, handleMessageSchedule, requestCalendar, resetComposer, sendInlineBotResult, + shouldSchedule, + ]); const handleBotCommandSelect = useCallback(() => { clearDraft({ chatId, localOnly: true }); @@ -659,56 +725,25 @@ const Composer: FC = ({ const handlePollSend = useCallback((poll: ApiNewPoll) => { if (shouldSchedule) { - setScheduledMessageArgs({ poll }); + requestCalendar((scheduledAt) => { + handleMessageSchedule({ poll }, scheduledAt); + }); closePollModal(); - openCalendar(); } else { sendMessage({ poll }); closePollModal(); } - }, [closePollModal, openCalendar, sendMessage, shouldSchedule]); + }, [closePollModal, handleMessageSchedule, requestCalendar, sendMessage, shouldSchedule]); - const handleSilentSend = useCallback(() => { + const handleSendSilent = useCallback(() => { if (shouldSchedule) { - setScheduledMessageArgs({ isSilent: true }); - openCalendar(); + requestCalendar((scheduledAt) => { + handleMessageSchedule({ isSilent: true }, scheduledAt); + }); } else { void handleSend(true); } - }, [handleSend, openCalendar, shouldSchedule]); - - const handleMessageSchedule = useCallback((date: Date, isWhenOnline = false) => { - const { isSilent, ...restArgs } = scheduledMessageArgs || {}; - - // No need to subscribe on updates in `mapStateToProps` - const { serverTimeOffset } = getGlobal(); - - // Scheduled time can not be less than 10 seconds in future - const scheduledAt = Math.round(Math.max(date.getTime(), Date.now() + 60 * 1000) / 1000) - + (isWhenOnline ? 0 : serverTimeOffset); - - if (!scheduledMessageArgs || Object.keys(restArgs).length === 0) { - void handleSend(Boolean(isSilent), scheduledAt); - } else { - sendMessage({ - ...scheduledMessageArgs, - scheduledAt, - }); - requestAnimationFrame(() => { - resetComposer(); - }); - } - closeCalendar(); - }, [closeCalendar, handleSend, resetComposer, scheduledMessageArgs, sendMessage]); - - const handleMessageScheduleUntilOnline = useCallback(() => { - handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000), true); - }, [handleMessageSchedule]); - - const handleCloseCalendar = useCallback(() => { - closeCalendar(); - setScheduledMessageArgs(undefined); - }, [closeCalendar]); + }, [handleMessageSchedule, handleSend, requestCalendar, shouldSchedule]); const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => { if (type === 'stickers') { @@ -790,14 +825,16 @@ const Composer: FC = ({ if (activeVoiceRecording) { pauseRecordingVoice(); } - openCalendar(); + requestCalendar((scheduledAt) => { + handleMessageSchedule({}, scheduledAt); + }); break; default: break; } }, [ - mainButtonState, handleSend, startRecordingVoice, handleEditComplete, - activeVoiceRecording, openCalendar, pauseRecordingVoice, + mainButtonState, handleSend, startRecordingVoice, handleEditComplete, activeVoiceRecording, requestCalendar, + pauseRecordingVoice, handleMessageSchedule, ]); const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record && !canAttachMedia; @@ -837,9 +874,15 @@ const Composer: FC = ({ : (isSymbolMenuOpen && 'is-loading'), ); + const handleSendScheduled = useCallback(() => { + requestCalendar((scheduledAt) => { + handleMessageSchedule({}, scheduledAt); + }); + }, [handleMessageSchedule, requestCalendar]); + const onSend = mainButtonState === MainButtonState.Edit ? handleEditComplete - : mainButtonState === MainButtonState.Schedule ? openCalendar + : mainButtonState === MainButtonState.Schedule ? handleSendScheduled : handleSend; return ( @@ -867,9 +910,10 @@ const Composer: FC = ({ baseEmojiKeywords={baseEmojiKeywords} emojiKeywords={emojiKeywords} addRecentEmoji={addRecentEmoji} - onSilentSend={handleSilentSend} - openCalendar={openCalendar} - onSend={shouldSchedule ? openCalendar : handleSend} + shouldSchedule={shouldSchedule} + onSendSilent={handleSendSilent} + onSend={handleSend} + onSendScheduled={handleSendScheduled} onFileAppend={handleAppendFiles} onClear={handleClearAttachment} /> @@ -909,6 +953,8 @@ const Composer: FC = ({ onSelectResult={handleInlineBotSelect} loadMore={loadMoreForInlineBot} onClose={closeInlineBotTooltip} + isSavedMessages={isChatWithSelf} + canSendGifs={canSendGifs} /> = ({ = ({ {canShowCustomSendMenu && ( )} - + {calendar} ); }; @@ -1132,7 +1169,6 @@ const Composer: FC = ({ export default memo(withGlobal( (global, { chatId, threadId, messageListType }): StateProps => { const chat = selectChat(global, chatId); - const chatUser = chat && selectChatUser(global, chat); const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined; const isChatWithBot = Boolean(chatBot); const isChatWithSelf = selectIsChatWithSelf(global, chatId); @@ -1158,11 +1194,8 @@ export default memo(withGlobal( chat, isChatWithBot, isChatWithSelf, + canScheduleUntilOnline: selectCanScheduleUntilOnline(global, chatId), isChannel: chat ? isChatChannel(chat) : undefined, - canScheduleUntilOnline: Boolean( - !isChatWithSelf && !isChatWithBot && chat && chatUser - && isUserId(chatId) && selectUserStatus(global, chatId)?.wasOnline, - ), isRightColumnShown: selectIsRightColumnShown(global), isSelectModeActive: selectIsInSelectMode(global), withScheduledButton: ( diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index 3ace90f08..c9afb9c39 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -18,11 +18,12 @@ import { selectEditingMessage, } from '../../../global/selectors'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; -import useAsyncRendering from '../../right/hooks/useAsyncRendering'; -import useShowTransition from '../../../hooks/useShowTransition'; import buildClassName from '../../../util/buildClassName'; import { isUserId } from '../../../global/helpers'; +import useAsyncRendering from '../../right/hooks/useAsyncRendering'; +import useShowTransition from '../../../hooks/useShowTransition'; + import Button from '../../ui/Button'; import EmbeddedMessage from '../../common/EmbeddedMessage'; diff --git a/src/components/middle/composer/CustomSendMenu.tsx b/src/components/middle/composer/CustomSendMenu.tsx index 65e843073..7bd19a5f9 100644 --- a/src/components/middle/composer/CustomSendMenu.tsx +++ b/src/components/middle/composer/CustomSendMenu.tsx @@ -12,14 +12,21 @@ import './CustomSendMenu.scss'; export type OwnProps = { isOpen: boolean; isOpenToBottom?: boolean; - onSilentSend?: NoneToVoidFunction; - onScheduleSend?: NoneToVoidFunction; + isSavedMessages?: boolean; + onSendSilent?: NoneToVoidFunction; + onSendSchedule?: NoneToVoidFunction; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; const CustomSendMenu: FC = ({ - isOpen, isOpenToBottom = false, onSilentSend, onScheduleSend, onClose, onCloseAnimationEnd, + isOpen, + isOpenToBottom = false, + isSavedMessages, + onSendSilent, + onSendSchedule, + onClose, + onCloseAnimationEnd, }) => { const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose); @@ -38,8 +45,12 @@ const CustomSendMenu: FC = ({ onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} noCloseOnBackdrop={!IS_TOUCH_ENV} > - {onSilentSend && {lang('SendWithoutSound')}} - {onScheduleSend && {lang('ScheduleMessage')}} + {onSendSilent && {lang('SendWithoutSound')}} + {onSendSchedule && ( + + {lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} + + )} ); }; diff --git a/src/components/middle/composer/GifPicker.tsx b/src/components/middle/composer/GifPicker.tsx index dddc005db..fc3ac6949 100644 --- a/src/components/middle/composer/GifPicker.tsx +++ b/src/components/middle/composer/GifPicker.tsx @@ -1,5 +1,5 @@ import React, { - FC, useEffect, memo, useRef, + FC, useEffect, memo, useRef, useCallback, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; @@ -8,6 +8,8 @@ import { ApiVideo } from '../../../api/types'; import { SLIDE_TRANSITION_DURATION } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/environment'; import buildClassName from '../../../util/buildClassName'; +import { selectCurrentMessageList, selectIsChatWithSelf } from '../../../global/selectors'; + import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useAsyncRendering from '../../right/hooks/useAsyncRendering'; @@ -20,11 +22,12 @@ type OwnProps = { className: string; loadAndPlay: boolean; canSendGifs: boolean; - onGifSelect: (gif: ApiVideo) => void; + onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; }; type StateProps = { savedGifs?: ApiVideo[]; + isSavedMessages?: boolean; }; const INTERSECTION_DEBOUNCE = 300; @@ -34,9 +37,10 @@ const GifPicker: FC = ({ loadAndPlay, canSendGifs, savedGifs, + isSavedMessages, onGifSelect, }) => { - const { loadSavedGifs } = getActions(); + const { loadSavedGifs, saveGif } = getActions(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); @@ -51,6 +55,10 @@ const GifPicker: FC = ({ } }, [loadAndPlay, loadSavedGifs]); + const handleUnsaveClick = useCallback((gif: ApiVideo) => { + saveGif({ gif, shouldUnsave: true }); + }, [saveGif]); + const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION); return ( @@ -67,7 +75,9 @@ const GifPicker: FC = ({ gif={gif} observeIntersection={observeIntersection} isDisabled={!loadAndPlay} - onClick={onGifSelect} + onClick={canSendGifs ? onGifSelect : undefined} + onUnsaveClick={handleUnsaveClick} + isSavedMessages={isSavedMessages} /> )) ) : canRenderContents && savedGifs ? ( @@ -81,8 +91,11 @@ const GifPicker: FC = ({ export default memo(withGlobal( (global): StateProps => { + const { chatId } = selectCurrentMessageList(global) || {}; + const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); return { savedGifs: global.gifs.saved.gifs, + isSavedMessages, }; }, )(GifPicker)); diff --git a/src/components/middle/composer/InlineBotTooltip.scss b/src/components/middle/composer/InlineBotTooltip.scss index c819ebb5f..8d1e8c080 100644 --- a/src/components/middle/composer/InlineBotTooltip.scss +++ b/src/components/middle/composer/InlineBotTooltip.scss @@ -4,8 +4,6 @@ font-weight: 500; } - --border-radius-default: 0; - &.gallery { display: grid; grid-template-columns: repeat(4, 1fr); diff --git a/src/components/middle/composer/InlineBotTooltip.tsx b/src/components/middle/composer/InlineBotTooltip.tsx index cadfac0bc..35b5e8c0f 100644 --- a/src/components/middle/composer/InlineBotTooltip.tsx +++ b/src/components/middle/composer/InlineBotTooltip.tsx @@ -1,6 +1,7 @@ import React, { FC, memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; @@ -22,7 +23,6 @@ import ListItem from '../../ui/ListItem'; import InfiniteScroll from '../../ui/InfiniteScroll'; import './InlineBotTooltip.scss'; -import { getActions } from '../../../global'; const INTERSECTION_DEBOUNCE_MS = 200; const runThrottled = throttle((cb) => cb(), 500, true); @@ -33,7 +33,11 @@ export type OwnProps = { isGallery?: boolean; inlineBotResults?: (ApiBotInlineResult | ApiBotInlineMediaResult)[]; switchPm?: ApiBotInlineSwitchPm; - onSelectResult: (inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult) => void; + isSavedMessages?: boolean; + canSendGifs?: boolean; + onSelectResult: ( + inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean + ) => void; loadMore: NoneToVoidFunction; onClose: NoneToVoidFunction; }; @@ -44,6 +48,8 @@ const InlineBotTooltip: FC = ({ isGallery, inlineBotResults, switchPm, + isSavedMessages, + canSendGifs, loadMore, onClose, onSelectResult, @@ -127,6 +133,8 @@ const InlineBotTooltip: FC = ({ inlineResult={inlineBotResult} observeIntersection={observeIntersection} onClick={onSelectResult} + isSavedMessages={isSavedMessages} + canSendGifs={canSendGifs} /> ); @@ -147,6 +155,7 @@ const InlineBotTooltip: FC = ({ inlineResult={inlineBotResult} observeIntersection={observeIntersection} onClick={onSelectResult} + isSavedMessages={isSavedMessages} /> ); diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index 15b89ed3a..55e2b5296 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -12,6 +12,8 @@ import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import fastSmoothScroll from '../../../util/fastSmoothScroll'; import buildClassName from '../../../util/buildClassName'; import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import { selectIsChatWithSelf } from '../../../global/selectors'; + import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; @@ -33,7 +35,7 @@ type OwnProps = { className: string; loadAndPlay: boolean; canSendStickers: boolean; - onStickerSelect: (sticker: ApiSticker) => void; + onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; }; type StateProps = { @@ -42,6 +44,7 @@ type StateProps = { stickerSetsById: Record; addedSetIds?: string[]; shouldPlay?: boolean; + isSavedMessages?: boolean; }; const SMOOTH_SCROLL_DISTANCE = 500; @@ -61,12 +64,14 @@ const StickerPicker: FC = ({ addedSetIds, stickerSetsById, shouldPlay, + isSavedMessages, onStickerSelect, }) => { const { loadRecentStickers, addRecentSticker, unfaveSticker, + faveSticker, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -164,8 +169,8 @@ const StickerPicker: FC = ({ fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); }, []); - const handleStickerSelect = useCallback((sticker: ApiSticker) => { - onStickerSelect(sticker); + const handleStickerSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => { + onStickerSelect(sticker, isSilent, shouldSchedule); addRecentSticker({ sticker }); }, [addRecentSticker, onStickerSelect]); @@ -173,6 +178,10 @@ const StickerPicker: FC = ({ unfaveSticker({ sticker }); }, [unfaveSticker]); + const handleStickerFave = useCallback((sticker: ApiSticker) => { + faveSticker({ sticker }); + }, [faveSticker]); + const handleMouseMove = useCallback(() => { sendMessageAction({ type: 'chooseSticker' }); }, [sendMessageAction]); @@ -225,6 +234,7 @@ const StickerPicker: FC = ({ observeIntersection={observeIntersectionForCovers} onClick={selectStickerSet} clickArg={index} + noContextMenu /> ); } @@ -269,6 +279,9 @@ const StickerPicker: FC = ({ shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} onStickerSelect={handleStickerSelect} onStickerUnfave={handleStickerUnfave} + onStickerFave={handleStickerFave} + favoriteStickers={favoriteStickers} + isSavedMessages={isSavedMessages} /> ))} @@ -277,7 +290,7 @@ const StickerPicker: FC = ({ }; export default memo(withGlobal( - (global): StateProps => { + (global, { chatId }): StateProps => { const { setsById, added, @@ -285,12 +298,15 @@ export default memo(withGlobal( favorite, } = global.stickers; + const isSavedMessages = selectIsChatWithSelf(global, chatId); + return { recentStickers: recent.stickers, favoriteStickers: favorite.stickers, stickerSetsById: setsById, addedSetIds: added.setIds, shouldPlay: global.settings.byKey.shouldLoopStickers, + isSavedMessages, }; }, )(StickerPicker)); diff --git a/src/components/middle/composer/StickerSet.tsx b/src/components/middle/composer/StickerSet.tsx index c14f07056..ede645eec 100644 --- a/src/components/middle/composer/StickerSet.tsx +++ b/src/components/middle/composer/StickerSet.tsx @@ -1,4 +1,6 @@ -import React, { FC, memo, useRef } from '../../../lib/teact/teact'; +import React, { + FC, memo, useMemo, useRef, +} from '../../../lib/teact/teact'; import { ApiSticker } from '../../../api/types'; import { StickerSetOrRecent } from '../../../types'; @@ -7,18 +9,23 @@ import { ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserve import { STICKER_SIZE_PICKER } from '../../../config'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import windowSize from '../../../util/windowSize'; -import StickerButton from '../../common/StickerButton'; -import useMediaTransition from '../../../hooks/useMediaTransition'; import buildClassName from '../../../util/buildClassName'; +import useMediaTransition from '../../../hooks/useMediaTransition'; + +import StickerButton from '../../common/StickerButton'; + type OwnProps = { stickerSet: StickerSetOrRecent; loadAndPlay: boolean; index: number; - observeIntersection: ObserveFn; shouldRender: boolean; - onStickerSelect: (sticker: ApiSticker) => void; + favoriteStickers?: ApiSticker[]; + isSavedMessages?: boolean; + observeIntersection: ObserveFn; + onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; onStickerUnfave: (sticker: ApiSticker) => void; + onStickerFave: (sticker: ApiSticker) => void; }; const STICKERS_PER_ROW_ON_DESKTOP = 5; @@ -29,10 +36,13 @@ const StickerSet: FC = ({ stickerSet, loadAndPlay, index, - observeIntersection, shouldRender, + favoriteStickers, + isSavedMessages, + observeIntersection, onStickerSelect, onStickerUnfave, + onStickerFave, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -46,6 +56,10 @@ const StickerSet: FC = ({ : STICKERS_PER_ROW_ON_DESKTOP; const height = Math.ceil(stickerSet.count / stickersPerRow) * (STICKER_SIZE_PICKER + STICKER_MARGIN); + const favoriteStickerIdsSet = useMemo(() => ( + favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined + ), [favoriteStickers]); + return (
= ({ noAnimate={!loadAndPlay} onClick={onStickerSelect} clickArg={sticker} - onUnfaveClick={stickerSet.id === 'favorite' ? onStickerUnfave : undefined} + onUnfaveClick={favoriteStickerIdsSet?.has(sticker.id) ? onStickerUnfave : undefined} + onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined} + isSavedMessages={isSavedMessages} /> ))}
diff --git a/src/components/middle/composer/StickerTooltip.tsx b/src/components/middle/composer/StickerTooltip.tsx index fcd4a56f9..cb0ba3d56 100644 --- a/src/components/middle/composer/StickerTooltip.tsx +++ b/src/components/middle/composer/StickerTooltip.tsx @@ -12,6 +12,7 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver' import useShowTransition from '../../../hooks/useShowTransition'; import usePrevious from '../../../hooks/usePrevious'; import useSendMessageAction from '../../../hooks/useSendMessageAction'; +import { selectIsChatWithSelf } from '../../../global/selectors'; import Loading from '../../ui/Loading'; import StickerButton from '../../common/StickerButton'; @@ -22,11 +23,12 @@ export type OwnProps = { chatId: string; threadId?: number; isOpen: boolean; - onStickerSelect: (sticker: ApiSticker) => void; + onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; }; type StateProps = { stickers?: ApiSticker[]; + isSavedMessages?: boolean; }; const INTERSECTION_THROTTLE = 200; @@ -35,8 +37,9 @@ const StickerTooltip: FC = ({ chatId, threadId, isOpen, - onStickerSelect, stickers, + isSavedMessages, + onStickerSelect, }) => { const { clearStickersForEmoji } = getActions(); @@ -78,6 +81,7 @@ const StickerTooltip: FC = ({ observeIntersection={observeIntersection} onClick={onStickerSelect} clickArg={sticker} + isSavedMessages={isSavedMessages} /> )) ) : shouldRender ? ( @@ -88,9 +92,10 @@ const StickerTooltip: FC = ({ }; export default memo(withGlobal( - (global): StateProps => { + (global, { chatId }): StateProps => { const { stickers } = global.stickers.forEmoji; + const isSavedMessages = selectIsChatWithSelf(global, chatId); - return { stickers }; + return { stickers, isSavedMessages }; }, )(StickerTooltip)); diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 66b9477cd..4348c3e8c 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -34,8 +34,10 @@ export type OwnProps = { onLoad: () => void; onClose: () => void; onEmojiSelect: (emoji: string) => void; - onStickerSelect: (sticker: ApiSticker, shouldPreserveInput?: boolean) => void; - onGifSelect: (gif: ApiVideo) => void; + onStickerSelect: ( + sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean + ) => void; + onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onRemoveSymbol: () => void; onSearchOpen: (type: 'stickers' | 'gifs') => void; addRecentEmoji: AnyToVoidFunction; @@ -126,8 +128,8 @@ const SymbolMenu: FC = ({ onSearchOpen(type); }, [onClose, onSearchOpen]); - const handleStickerSelect = useCallback((sticker: ApiSticker) => { - onStickerSelect(sticker, true); + const handleStickerSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => { + onStickerSelect(sticker, isSilent, shouldSchedule, true); }, [onStickerSelect]); const lang = useLang(); diff --git a/src/components/middle/composer/inlineResults/GifResult.tsx b/src/components/middle/composer/inlineResults/GifResult.tsx index 4794ee76b..297a4d577 100644 --- a/src/components/middle/composer/inlineResults/GifResult.tsx +++ b/src/components/middle/composer/inlineResults/GifResult.tsx @@ -2,7 +2,7 @@ import React, { FC, memo, useCallback, } from '../../../../lib/teact/teact'; -import { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types'; +import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiVideo } from '../../../../api/types'; import { ObserveFn } from '../../../../hooks/useIntersectionObserver'; @@ -10,17 +10,19 @@ import GifButton from '../../../common/GifButton'; type OwnProps = { inlineResult: ApiBotInlineMediaResult; + isSavedMessages?: boolean; + canSendGifs?: boolean; observeIntersection: ObserveFn; - onClick: (result: ApiBotInlineResult) => void; + onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void; }; const GifResult: FC = ({ - inlineResult, observeIntersection, onClick, + inlineResult, isSavedMessages, canSendGifs, observeIntersection, onClick, }) => { const { gif } = inlineResult; - const handleClick = useCallback(() => { - onClick(inlineResult); + const handleClick = useCallback((_gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => { + onClick(inlineResult, isSilent, shouldSchedule); }, [inlineResult, onClick]); if (!gif) { @@ -32,7 +34,8 @@ const GifResult: FC = ({ gif={gif} observeIntersection={observeIntersection} className="chat-item-clickable" - onClick={handleClick} + onClick={canSendGifs ? handleClick : undefined} + isSavedMessages={isSavedMessages} /> ); }; diff --git a/src/components/middle/composer/inlineResults/StickerResult.tsx b/src/components/middle/composer/inlineResults/StickerResult.tsx index 678e98be8..cce6e1134 100644 --- a/src/components/middle/composer/inlineResults/StickerResult.tsx +++ b/src/components/middle/composer/inlineResults/StickerResult.tsx @@ -9,11 +9,17 @@ import StickerButton from '../../../common/StickerButton'; type OwnProps = { inlineResult: ApiBotInlineMediaResult; + isSavedMessages?: boolean; observeIntersection: ObserveFn; - onClick: (result: ApiBotInlineResult) => void; + onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void; }; -const StickerResult: FC = ({ inlineResult, observeIntersection, onClick }) => { +const StickerResult: FC = ({ + inlineResult, + isSavedMessages, + observeIntersection, + onClick, +}) => { const { sticker } = inlineResult; if (!sticker) { @@ -29,6 +35,7 @@ const StickerResult: FC = ({ inlineResult, observeIntersection, onClic className="chat-item-clickable" onClick={onClick} clickArg={inlineResult} + isSavedMessages={isSavedMessages} /> ); }; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 8f67b01c5..1a30ff58b 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -15,7 +15,7 @@ import { } from '../../../global/selectors'; import { isActionMessage, isChatChannel, - isChatGroup, isOwnMessage, areReactionsEmpty, isUserId, isMessageLocal, + isChatGroup, isOwnMessage, areReactionsEmpty, isUserId, isMessageLocal, getMessageVideo, } from '../../../global/helpers'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; import { getDayStartAt } from '../../../util/dateFormat'; @@ -67,6 +67,7 @@ type StateProps = { canCopyLink?: boolean; canSelect?: boolean; canDownload?: boolean; + canSaveGif?: boolean; activeDownloads: number[]; canShowSeenBy?: boolean; enabledReactions?: string[]; @@ -104,6 +105,7 @@ const ContextMenuContainer: FC = ({ canCopyLink, canSelect, canDownload, + canSaveGif, activeDownloads, canShowSeenBy, }) => { @@ -126,6 +128,7 @@ const ContextMenuContainer: FC = ({ loadFullChat, loadReactors, copyMessagesByIds, + saveGif, } = getActions(); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); @@ -310,6 +313,12 @@ const ContextMenuContainer: FC = ({ closeMenu(); }, [album, message, closeMenu, isDownloading, cancelMessageMediaDownload, downloadMessageMedia]); + const handleSaveGif = useCallback(() => { + const video = getMessageVideo(message); + saveGif({ gif: video }); + closeMenu(); + }, [closeMenu, message, saveGif]); + const handleSendReaction = useCallback((reaction: string | undefined, x: number, y: number) => { sendReaction({ chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE, @@ -355,6 +364,7 @@ const ContextMenuContainer: FC = ({ canCopyLink={canCopyLink} canSelect={canSelect} canDownload={canDownload} + canSaveGif={canSaveGif} canShowSeenBy={canShowSeenBy} isDownloading={isDownloading} seenByRecentUsers={seenByRecentUsers} @@ -374,6 +384,7 @@ const ContextMenuContainer: FC = ({ onCopyLink={handleCopyLink} onCopyMessages={handleCopyMessages} onDownload={handleDownloadClick} + onSaveGif={handleSaveGif} onShowSeenBy={handleOpenSeenByModal} onSendReaction={handleSendReaction} onShowReactors={handleOpenReactorListModal} @@ -432,6 +443,7 @@ export default memo(withGlobal( canCopyLink, canSelect, canDownload, + canSaveGif, } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; const isPinned = messageListType === 'pinned'; const isScheduled = messageListType === 'scheduled'; @@ -471,6 +483,7 @@ export default memo(withGlobal( canCopyLink: !isProtected && !isScheduled && canCopyLink, canSelect, canDownload: !isProtected && canDownload, + canSaveGif: !isProtected && canSaveGif, activeDownloads, canShowSeenBy, enabledReactions: chat?.fullInfo?.enabledReactions, diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index a891f2cba..b1190eb60 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -46,6 +46,7 @@ type OwnProps = { canSelect?: boolean; isPrivate?: boolean; canDownload?: boolean; + canSaveGif?: boolean; isDownloading?: boolean; canShowSeenBy?: boolean; seenByRecentUsers?: ApiUser[]; @@ -66,6 +67,7 @@ type OwnProps = { onCopyLink?: () => void; onCopyMessages?: (messageIds: number[]) => void; onDownload?: () => void; + onSaveGif?: () => void; onShowSeenBy?: () => void; onShowReactors?: () => void; onSendReaction: (reaction: string | undefined, x: number, y: number) => void; @@ -97,6 +99,7 @@ const MessageContextMenu: FC = ({ canCopyLink, canSelect, canDownload, + canSaveGif, isDownloading, canShowSeenBy, canShowReactionsCount, @@ -119,6 +122,7 @@ const MessageContextMenu: FC = ({ onCloseAnimationEnd, onCopyLink, onDownload, + onSaveGif, onShowSeenBy, onShowReactors, onSendReaction, @@ -240,6 +244,7 @@ const MessageContextMenu: FC = ({ ))} {canPin && {lang('DialogPin')}} {canUnpin && {lang('DialogUnpin')}} + {canSaveGif && {lang('lng_context_save_gif')}} {canDownload && ( {isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')} diff --git a/src/components/right/GifSearch.tsx b/src/components/right/GifSearch.tsx index 6680cb16f..343ba8659 100644 --- a/src/components/right/GifSearch.tsx +++ b/src/components/right/GifSearch.tsx @@ -11,12 +11,15 @@ import { selectChat, selectIsChatWithBot, selectCurrentMessageList, + selectCanScheduleUntilOnline, + selectIsChatWithSelf, } from '../../global/selectors'; -import { getAllowedAttachmentOptions } from '../../global/helpers'; +import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import useLang from '../../hooks/useLang'; import useHistoryBack from '../../hooks/useHistoryBack'; +import useSchedule from '../../hooks/useSchedule'; import InfiniteScroll from '../ui/InfiniteScroll'; import GifButton from '../common/GifButton'; @@ -34,18 +37,24 @@ type StateProps = { results?: ApiVideo[]; chat?: ApiChat; isChatWithBot?: boolean; + canScheduleUntilOnline?: boolean; + isSavedMessages?: boolean; + canPostInChat?: boolean; }; const PRELOAD_BACKWARDS = 96; // GIF Search bot results are multiplied by 24 const INTERSECTION_DEBOUNCE = 300; const GifSearch: FC = ({ - onClose, isActive, query, results, chat, isChatWithBot, + canScheduleUntilOnline, + isSavedMessages, + canPostInChat, + onClose, }) => { const { searchMoreGifs, @@ -56,21 +65,29 @@ const GifSearch: FC = ({ // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); + const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline); + const { observe: observeIntersection, } = useIntersectionObserver({ rootRef: containerRef, debounceMs: INTERSECTION_DEBOUNCE }); - const { canSendGifs } = getAllowedAttachmentOptions(chat, isChatWithBot); + const canSendGifs = canPostInChat && getAllowedAttachmentOptions(chat, isChatWithBot).canSendGifs; - const handleGifClick = useCallback((gif: ApiVideo) => { + const handleGifClick = useCallback((gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => { if (canSendGifs) { - sendMessage({ gif }); + if (shouldSchedule) { + requestCalendar((scheduledAt) => { + sendMessage({ gif, scheduledAt, isSilent }); + }); + } else { + sendMessage({ gif, isSilent }); + } } if (IS_TOUCH_ENV) { setGifSearchQuery({ query: undefined }); } - }, [canSendGifs, sendMessage, setGifSearchQuery]); + }, [canSendGifs, requestCalendar, sendMessage, setGifSearchQuery]); const lang = useLang(); @@ -98,7 +115,8 @@ const GifSearch: FC = ({ key={gif.id} gif={gif} observeIntersection={observeIntersection} - onClick={handleGifClick} + onClick={canSendGifs ? handleGifClick : undefined} + isSavedMessages={isSavedMessages} /> )); } @@ -118,6 +136,7 @@ const GifSearch: FC = ({ > {renderContent()} + {calendar} ); }; @@ -126,15 +145,20 @@ export default memo(withGlobal( (global): StateProps => { const currentSearch = selectCurrentGifSearch(global); const { query, results } = currentSearch || {}; - const { chatId } = selectCurrentMessageList(global) || {}; + const { chatId, threadId } = selectCurrentMessageList(global) || {}; const chat = chatId ? selectChat(global, chatId) : undefined; const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined; + const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); + const canPostInChat = Boolean(chat) && Boolean(threadId) && getCanPostInChat(chat, threadId); return { query, results, chat, isChatWithBot, + isSavedMessages, + canPostInChat, + canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId), }; }, )(GifSearch)); diff --git a/src/components/right/StickerSetResult.tsx b/src/components/right/StickerSetResult.tsx index e19c2cabe..1f140284b 100644 --- a/src/components/right/StickerSetResult.tsx +++ b/src/components/right/StickerSetResult.tsx @@ -101,7 +101,9 @@ const StickerSetResult: FC = ({ size={STICKER_SIZE_SEARCH} observeIntersection={observeIntersection} noAnimate={!shouldPlay || isModalOpen || isSomeModalOpen} + clickArg={undefined} onClick={openModal} + noContextMenu /> ))} diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index a774612dc..55ace09db 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -194,7 +194,9 @@ addActionHandler('queryInlineBot', async (global, actions, payload) => { }); addActionHandler('sendInlineBotResult', (global, actions, payload) => { - const { id, queryId } = payload; + const { + id, queryId, isSilent, scheduledAt, + } = payload; const currentMessageList = selectCurrentMessageList(global); if (!currentMessageList || !id) { return; @@ -213,6 +215,8 @@ addActionHandler('sendInlineBotResult', (global, actions, payload) => { queryId, replyingTo: selectReplyingToId(global, chatId, threadId), sendAs: selectSendAs(global, chatId), + isSilent, + scheduleDate: scheduledAt, }); }); diff --git a/src/global/actions/api/symbols.ts b/src/global/actions/api/symbols.ts index 5f24247d2..058fc4df3 100644 --- a/src/global/actions/api/symbols.ts +++ b/src/global/actions/api/symbols.ts @@ -108,6 +108,29 @@ addActionHandler('loadSavedGifs', (global) => { void loadSavedGifs(hash); }); +addActionHandler('saveGif', async (global, actions, payload) => { + const { gif, shouldUnsave } = payload!; + const result = await callApi('saveGif', { gif, shouldUnsave }); + if (!result) { + return undefined; + } + + global = getGlobal(); + const gifs = global.gifs.saved.gifs?.filter(({ id }) => id !== gif.id) || []; + const newGifs = shouldUnsave ? gifs : [gif, ...gifs]; + + return { + ...global, + gifs: { + ...global.gifs, + saved: { + ...global.gifs.saved, + gifs: newGifs, + }, + }, + }; +}); + addActionHandler('faveSticker', (global, actions, payload) => { const { sticker } = payload!; diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 069ef6e71..f411f98d5 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -7,11 +7,11 @@ import { MAIN_THREAD_ID, } from '../../api/types'; -import { LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIONS_USER_ID } from '../../config'; +import { LOCAL_MESSAGE_ID_BASE, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID } from '../../config'; import { - selectChat, selectIsChatWithBot, selectIsChatWithSelf, + selectChat, selectChatBot, selectIsChatWithBot, selectIsChatWithSelf, } from './chats'; -import { selectIsUserOrChatContact, selectUser } from './users'; +import { selectIsUserOrChatContact, selectUser, selectUserStatus } from './users'; import { getSendingState, isChatChannel, @@ -427,6 +427,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes const canDownload = Boolean(content.webPage?.document || content.webPage?.video || content.webPage?.photo || content.audio || content.voice || content.photo || content.video || content.document || content.sticker); + const canSaveGif = message.content.video?.isGif; + const noOptions = [ canReply, canEdit, @@ -442,6 +444,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes canCopyLink, canSelect, canDownload, + canSaveGif, ].every((ability) => !ability); return { @@ -460,6 +463,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes canCopyLink, canSelect, canDownload, + canSaveGif, }; } @@ -922,3 +926,15 @@ export function selectVisibleUsers(global: GlobalState) { return senderId ? selectUser(global, senderId) : undefined; }).filter(Boolean); } + +export function selectShouldSchedule(global: GlobalState) { + return selectCurrentMessageList(global)?.type === 'scheduled'; +} + +export function selectCanScheduleUntilOnline(global: GlobalState, id: string) { + const isChatWithSelf = selectIsChatWithSelf(global, id); + const chatBot = id === REPLIES_USER_ID && selectChatBot(global, id); + return Boolean( + !isChatWithSelf && !chatBot && isUserId(id) && selectUserStatus(global, id)?.wasOnline, + ); +} diff --git a/src/global/types.ts b/src/global/types.ts index f63253d4f..a91f90838 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -593,7 +593,7 @@ export type NonTypedActionNames = ( 'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' | // stickers & GIFs 'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' | - 'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'setGifSearchQuery' | 'searchMoreGifs' | + 'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'saveGif' | 'setGifSearchQuery' | 'searchMoreGifs' | 'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' | 'loadAnimatedEmojis' | 'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' | 'openStickerSetShortName' | diff --git a/src/hooks/useContextMenuHandlers.ts b/src/hooks/useContextMenuHandlers.ts index f6a8bb6ab..a33a6e8c4 100644 --- a/src/hooks/useContextMenuHandlers.ts +++ b/src/hooks/useContextMenuHandlers.ts @@ -3,16 +3,11 @@ import { useState, useEffect, useCallback } from '../lib/teact/teact'; import { IAnchorPosition } from '../types'; import { - IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT, IS_PWA, IS_IOS, + IS_TOUCH_ENV, IS_PWA, IS_IOS, } from '../util/environment'; const LONG_TAP_DURATION_MS = 200; -function checkIsDisabledForMobile() { - return IS_SINGLE_COLUMN_LAYOUT - && window.document.body.classList.contains('enable-symbol-menu-transforms'); -} - function stopEvent(e: Event) { e.stopImmediatePropagation(); e.preventDefault(); @@ -106,7 +101,7 @@ const useContextMenuHandlers = ( }; const startLongPressTimer = (e: TouchEvent) => { - if (isMenuDisabled || checkIsDisabledForMobile()) { + if (isMenuDisabled) { return; } clearLongPressTimer(); diff --git a/src/hooks/useContextMenuPosition.ts b/src/hooks/useContextMenuPosition.ts index f7cc0a0a7..5083795f6 100644 --- a/src/hooks/useContextMenuPosition.ts +++ b/src/hooks/useContextMenuPosition.ts @@ -99,11 +99,9 @@ export default function useContextMenuPosition( const triggerRect = triggerEl.getBoundingClientRect(); const left = horizontalPosition === 'left' ? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX) - : Math.max((x - triggerRect.left), menuRect.width + MENU_POSITION_VISUAL_COMFORT_SPACE_PX); - const top = Math.min( - rootRect.height - triggerRect.top + triggerRect.height - MENU_POSITION_BOTTOM_MARGIN + (marginTop || 0), - y - triggerRect.top, - ); + : (x - triggerRect.left); + const top = y - triggerRect.top; + const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); setWithScroll(menuMaxHeight < menuRect.height); diff --git a/src/hooks/useSchedule.tsx b/src/hooks/useSchedule.tsx new file mode 100644 index 000000000..4e3a6393e --- /dev/null +++ b/src/hooks/useSchedule.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useState } from '../lib/teact/teact'; +import { getGlobal } from '../lib/teact/teactn'; + +import { SCHEDULED_WHEN_ONLINE } from '../config'; +import { getDayStartAt } from '../util/dateFormat'; +import useLang from './useLang'; + +import CalendarModal from '../components/common/CalendarModal.async'; + +type OnScheduledCallback = (scheduledAt: number) => void; + +const useSchedule = ( + canScheduleUntilOnline?: boolean, + onCancel?: () => void, +) => { + const lang = useLang(); + const [onScheduled, setOnScheduled] = useState(); + + const handleMessageSchedule = useCallback((date: Date, isWhenOnline = false) => { + const { serverTimeOffset } = getGlobal(); + // Scheduled time can not be less than 10 seconds in future + const scheduledAt = Math.round(Math.max(date.getTime(), Date.now() + 60 * 1000) / 1000) + + (isWhenOnline ? 0 : serverTimeOffset); + onScheduled?.(scheduledAt); + setOnScheduled(undefined); + }, [onScheduled]); + + const handleMessageScheduleUntilOnline = useCallback(() => { + handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000), true); + }, [handleMessageSchedule]); + + const handleCloseCalendar = useCallback(() => { + setOnScheduled(undefined); + onCancel?.(); + }, [onCancel]); + + const requestCalendar = useCallback((whenScheduled: OnScheduledCallback) => { + setOnScheduled(() => whenScheduled); + }, []); + + const scheduledDefaultDate = new Date(); + scheduledDefaultDate.setSeconds(0); + scheduledDefaultDate.setMilliseconds(0); + + const scheduledMaxDate = new Date(); + scheduledMaxDate.setFullYear(scheduledMaxDate.getFullYear() + 1); + + const calendar = ( + + ); + + return [requestCalendar, calendar] as const; +}; + +export default useSchedule; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 0b8d081c6..affa1e955 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1052,6 +1052,7 @@ messages.migrateChat#a2875319 chat_id:long = Updates; messages.searchGlobal#4bc6589a flags:# folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; messages.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document; messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; messages.sendInlineBotResult#7aa11297 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int = Updates; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 34a241c57..646d9e4f3 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -98,6 +98,7 @@ "messages.searchGlobal", "messages.getDocumentByHash", "messages.getSavedGifs", + "messages.saveGif", "messages.getInlineBotResults", "messages.sendInlineBotResult", "messages.editMessage",