Support saving GIFs, support scheduling GIFs and stickers (#1739)

This commit is contained in:
Alexander Zinchuk 2022-03-19 21:19:19 +01:00
parent a348fda4dd
commit d05fecddf4
38 changed files with 804 additions and 222 deletions

View File

@ -98,13 +98,15 @@ export async function fetchInlineBotResults({
} }
export async function sendInlineBotResult({ export async function sendInlineBotResult({
chat, resultId, queryId, replyingTo, sendAs, chat, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate,
}: { }: {
chat: ApiChat; chat: ApiChat;
resultId: string; resultId: string;
queryId: string; queryId: string;
replyingTo?: number; replyingTo?: number;
sendAs?: ApiUser | ApiChat; sendAs?: ApiUser | ApiChat;
isSilent?: boolean;
scheduleDate?: number;
}) { }) {
const randomId = generateRandomBigInt(); const randomId = generateRandomBigInt();
@ -114,6 +116,8 @@ export async function sendInlineBotResult({
queryId: BigInt(queryId), queryId: BigInt(queryId),
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
id: resultId, id: resultId,
scheduleDate,
...(isSilent && { silent: true }),
...(replyingTo && { replyToMsgId: replyingTo }), ...(replyingTo && { replyToMsgId: replyingTo }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
}), true); }), true);

View File

@ -34,7 +34,7 @@ export {
export { export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers, fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
faveSticker, fetchStickers, fetchSavedGifs, searchStickers, installStickerSet, uninstallStickerSet, faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
} from './symbols'; } from './symbols';

View File

@ -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 }) { export async function installStickerSet({ stickerSetId, accessHash }: { stickerSetId: string; accessHash: string }) {
const result = await invokeRequest(new GramJs.messages.InstallStickerSet({ const result = await invokeRequest(new GramJs.messages.InstallStickerSet({
stickerset: buildInputStickerSet(stickerSetId, accessHash), stickerset: buildInputStickerSet(stickerSetId, accessHash),

View File

@ -15,20 +15,25 @@ export { default as SeenByModal } from '../components/common/SeenByModal';
export { default as ReactorListModal } from '../components/middle/ReactorListModal'; export { default as ReactorListModal } from '../components/middle/ReactorListModal';
export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation'; export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
// eslint-disable-next-line import/no-cycle
export { default as LeftSearch } from '../components/left/search/LeftSearch'; 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 Settings } from '../components/left/settings/Settings';
export { default as ContactList } from '../components/left/main/ContactList'; export { default as ContactList } from '../components/left/main/ContactList';
export { default as NewChat } from '../components/left/newChat/NewChat'; export { default as NewChat } from '../components/left/newChat/NewChat';
export { default as NewChatStep1 } from '../components/left/newChat/NewChatStep1'; export { default as NewChatStep1 } from '../components/left/newChat/NewChatStep1';
export { default as NewChatStep2 } from '../components/left/newChat/NewChatStep2'; 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 ArchivedChats } from '../components/left/ArchivedChats';
export { default as ChatFolderModal } from '../components/left/ChatFolderModal'; export { default as ChatFolderModal } from '../components/left/ChatFolderModal';
export { default as ContextMenuContainer } from '../components/middle/message/ContextMenuContainer'; 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 StickerSetModal } from '../components/common/StickerSetModal';
export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer';
export { default as MobileSearch } from '../components/middle/MobileSearch'; 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 AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/middle/composer/PollModal'; export { default as PollModal } from '../components/middle/composer/PollModal';
export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; 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 SendAsMenu } from '../components/middle/composer/SendAsMenu';
export { default as RightSearch } from '../components/right/RightSearch'; export { default as RightSearch } from '../components/right/RightSearch';
// eslint-disable-next-line import/no-cycle
export { default as StickerSearch } from '../components/right/StickerSearch'; 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 GifSearch } from '../components/right/GifSearch';
export { default as Statistics } from '../components/right/statistics/Statistics'; export { default as Statistics } from '../components/right/statistics/Statistics';
export { default as PollResults } from '../components/right/PollResults'; export { default as PollResults } from '../components/right/PollResults';

View File

@ -2,6 +2,7 @@ import { getActions, getGlobal } from '../global';
import { DEBUG } from '../config'; import { DEBUG } from '../config';
// eslint-disable-next-line import/no-cycle
export { default as Main } from '../components/main/Main'; export { default as Main } from '../components/main/Main';
if (DEBUG) { if (DEBUG) {

View File

@ -4,10 +4,14 @@
justify-content: center; justify-content: center;
height: 6.25rem; height: 6.25rem;
background-color: transparent; background-color: transparent;
cursor: pointer;
overflow: hidden;
position: relative; position: relative;
&:hover {
.gif-unsave-button {
opacity: 0.8;
}
}
&:last-child { &:last-child {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -20,6 +24,10 @@
grid-column-end: span 2; grid-column-end: span 2;
} }
&.interactive {
cursor: pointer;
}
.thumbnail { .thumbnail {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -34,10 +42,39 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
-webkit-touch-callout: none;
user-select: none;
} }
.Spinner { .Spinner {
position: absolute; position: absolute;
pointer-events: none; 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;
}
}
} }

View File

@ -1,18 +1,26 @@
import React, { import React, {
FC, memo, useCallback, useRef, FC, memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { ApiMediaFormat, ApiVideo } from '../../api/types'; import { ApiMediaFormat, ApiVideo } from '../../api/types';
import { IS_TOUCH_ENV } from '../../util/environment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useVideoCleanup from '../../hooks/useVideoCleanup'; import useVideoCleanup from '../../hooks/useVideoCleanup';
import useBuffering from '../../hooks/useBuffering'; import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur'; 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 Spinner from '../ui/Spinner';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import './GifButton.scss'; import './GifButton.scss';
@ -21,17 +29,27 @@ type OwnProps = {
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
isDisabled?: boolean; isDisabled?: boolean;
className?: string; className?: string;
onClick: (gif: ApiVideo) => void; onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onUnsaveClick?: (gif: ApiVideo) => void;
isSavedMessages?: boolean;
}; };
const GifButton: FC<OwnProps> = ({ const GifButton: FC<OwnProps> = ({
gif, observeIntersection, isDisabled, className, onClick, gif,
isDisabled,
className,
observeIntersection,
onClick,
onUnsaveClick,
isSavedMessages,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const lang = useLang();
const hasThumbnail = Boolean(gif.thumbnail?.dataUri); const hasThumbnail = Boolean(gif.thumbnail?.dataUri);
const localMediaHash = `gif${gif.id}`; const localMediaHash = `gif${gif.id}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
@ -46,17 +64,78 @@ const GifButton: FC<OwnProps> = ({
useVideoCleanup(videoRef, [shouldRenderVideo]); useVideoCleanup(videoRef, [shouldRenderVideo]);
const handleClick = useCallback( const {
() => onClick({ 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, ...gif,
blobUrl: videoData, 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<HTMLElement>) => {
preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e);
}, [handleBeforeContextMenu]);
useEffect(() => {
if (isDisabled) handleContextMenuClose();
}, [handleContextMenuClose, isDisabled]);
const fullClassName = buildClassName( const fullClassName = buildClassName(
'GifButton', 'GifButton',
gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal', gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal',
onClick && 'interactive',
localMediaHash, localMediaHash,
className, className,
); );
@ -65,9 +144,20 @@ const GifButton: FC<OwnProps> = ({
<div <div
ref={ref} ref={ref}
className={fullClassName} className={fullClassName}
onMouseDown={preventMessageInputBlurWithBubbling} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
> >
{!IS_TOUCH_ENV && onUnsaveClick && (
<Button
className="gif-unsave-button"
color="dark"
pill
onClick={handleUnsaveClick}
>
<i className="icon-close gif-unsave-button-icon" />
</Button>
)}
{hasThumbnail && ( {hasThumbnail && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
@ -100,6 +190,28 @@ const GifButton: FC<OwnProps> = ({
{shouldRenderSpinner && ( {shouldRenderSpinner && (
<Spinner color={previewBlobUrl || hasThumbnail ? 'white' : 'black'} /> <Spinner color={previewBlobUrl || hasThumbnail ? 'white' : 'black'} />
)} )}
{onClick && contextMenuPosition !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
className="gif-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
>
{!isSavedMessages && <MenuItem onClick={handleSendQuiet} icon="mute">{lang('SendWithoutSound')}</MenuItem>}
<MenuItem onClick={handleSendScheduled} icon="calendar">
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
</MenuItem>
{onUnsaveClick && (
<MenuItem destructive icon="delete" onClick={handleContextDelete}>{lang('Delete')}</MenuItem>
)}
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -50,6 +50,8 @@
img, img,
video { video {
object-fit: contain; object-fit: contain;
-webkit-touch-callout: none;
user-select: none;
} }
.sticker-unfave-button { .sticker-unfave-button {
@ -66,4 +68,12 @@
opacity: 0; opacity: 0;
} }
.sticker-context-menu {
position: absolute;
.bubble {
width: auto;
}
}
} }

View File

@ -1,41 +1,62 @@
import { MouseEvent as ReactMouseEvent } from 'react'; import { MouseEvent as ReactMouseEvent } from 'react';
import React, { import React, {
FC, memo, useEffect, useRef, memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact'; } 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 { useIsIntersecting, ObserveFn } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useShowTransition from '../../hooks/useShowTransition'; import useShowTransition from '../../hooks/useShowTransition';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import safePlay from '../../util/safePlay'; import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import AnimatedSticker from './AnimatedSticker'; import AnimatedSticker from './AnimatedSticker';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import './StickerButton.scss'; import './StickerButton.scss';
type OwnProps = { type OwnProps<T> = {
sticker: ApiSticker; sticker: ApiSticker;
size: number; size: number;
observeIntersection: ObserveFn;
noAnimate?: boolean; noAnimate?: boolean;
title?: string; title?: string;
className?: string; className?: string;
onClick?: (arg: any) => void; clickArg: T;
clickArg?: any; noContextMenu?: boolean;
isSavedMessages?: boolean;
observeIntersection: ObserveFn;
onClick?: (arg: OwnProps<T>['clickArg'], isSilent?: boolean, shouldSchedule?: boolean) => void;
onFaveClick?: (sticker: ApiSticker) => void;
onUnfaveClick?: (sticker: ApiSticker) => void; onUnfaveClick?: (sticker: ApiSticker) => void;
}; };
const StickerButton: FC<OwnProps> = ({ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult | undefined = undefined>({
sticker, size, observeIntersection, noAnimate, title, className, onClick, clickArg, onUnfaveClick, sticker,
}) => { size,
noAnimate,
title,
className,
clickArg,
noContextMenu,
isSavedMessages,
observeIntersection,
onClick,
onFaveClick,
onUnfaveClick,
}: OwnProps<T>) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const lang = useLang();
const localMediaHash = `sticker${sticker.id}`; const localMediaHash = `sticker${sticker.id}`;
const stickerSelector = `sticker-button-${sticker.id}`; const stickerSelector = `sticker-button-${sticker.id}`;
@ -60,6 +81,33 @@ const StickerButton: FC<OwnProps> = ({
'slow', '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 // To avoid flickering
useEffect(() => { useEffect(() => {
if (!shouldPlay) { if (!shouldPlay) {
@ -78,18 +126,42 @@ const StickerButton: FC<OwnProps> = ({
} }
}, [isVideo, canVideoPlay]); }, [isVideo, canVideoPlay]);
function handleClick() { useEffect(() => {
if (onClick) { if (!isIntersecting) handleContextMenuClose();
onClick(clickArg); }, [handleContextMenuClose, isIntersecting]);
}
}
function handleUnfaveClick(e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) { const handleClick = () => {
if (isContextMenuOpen) return;
onClick?.(clickArg);
};
const handleMouseDown = (e: React.MouseEvent<HTMLElement>) => {
preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e);
};
const handleUnfaveClick = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onUnfaveClick!(sticker); onUnfaveClick!(sticker);
} };
const handleContextUnfave = () => {
onUnfaveClick!(sticker);
};
const handleContextFave = () => {
onFaveClick!(sticker);
};
const handleSendQuiet = () => {
onClick?.(clickArg, true);
};
const handleSendScheduled = () => {
onClick?.(clickArg, undefined, true);
};
const fullClassName = buildClassName( const fullClassName = buildClassName(
'StickerButton', 'StickerButton',
@ -107,8 +179,9 @@ const StickerButton: FC<OwnProps> = ({
title={title || (sticker?.emoji)} title={title || (sticker?.emoji)}
style={style} style={style}
data-sticker-id={sticker.id} data-sticker-id={sticker.id}
onMouseDown={preventMessageInputBlurWithBubbling} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
> >
{!canLottiePlay && !canVideoPlay && ( {!canLottiePlay && !canVideoPlay && (
// eslint-disable-next-line jsx-a11y/alt-text // eslint-disable-next-line jsx-a11y/alt-text
@ -134,7 +207,7 @@ const StickerButton: FC<OwnProps> = ({
onLoad={markLoaded} onLoad={markLoaded}
/> />
)} )}
{onUnfaveClick && ( {!IS_TOUCH_ENV && onUnfaveClick && (
<Button <Button
className="sticker-unfave-button" className="sticker-unfave-button"
color="dark" color="dark"
@ -144,6 +217,35 @@ const StickerButton: FC<OwnProps> = ({
<i className="icon-close" /> <i className="icon-close" />
</Button> </Button>
)} )}
{!noContextMenu && onClick && contextMenuPosition !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
className="sticker-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
>
{onUnfaveClick && (
<MenuItem icon="favorite" onClick={handleContextUnfave}>
{lang('Stickers.RemoveFromFavorites')}
</MenuItem>
)}
{onFaveClick && (
<MenuItem icon="favorite" onClick={handleContextFave}>
{lang('AddToFavorites')}
</MenuItem>
)}
{!isSavedMessages && <MenuItem onClick={handleSendQuiet} icon="muted">{lang('SendWithoutSound')}</MenuItem>}
<MenuItem onClick={handleSendScheduled} icon="calendar">
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
</MenuItem>
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -7,12 +7,19 @@ import { ApiSticker, ApiStickerSet } from '../../api/types';
import { STICKER_SIZE_MODAL } from '../../config'; import { STICKER_SIZE_MODAL } from '../../config';
import { import {
selectChat, selectCurrentMessageList, selectStickerSet, selectStickerSetByShortName, selectCanScheduleUntilOnline,
selectChat,
selectCurrentMessageList,
selectIsChatWithSelf,
selectShouldSchedule,
selectStickerSet,
selectStickerSetByShortName,
} from '../../global/selectors'; } from '../../global/selectors';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers'; import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers';
import useSchedule from '../../hooks/useSchedule';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
import Button from '../ui/Button'; import Button from '../ui/Button';
@ -31,6 +38,9 @@ export type OwnProps = {
type StateProps = { type StateProps = {
canSendStickers?: boolean; canSendStickers?: boolean;
stickerSet?: ApiStickerSet; stickerSet?: ApiStickerSet;
canScheduleUntilOnline?: boolean;
shouldSchedule?: boolean;
isSavedMessages?: boolean;
}; };
const INTERSECTION_THROTTLE = 200; const INTERSECTION_THROTTLE = 200;
@ -41,6 +51,9 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
stickerSetShortName, stickerSetShortName,
stickerSet, stickerSet,
canSendStickers, canSendStickers,
canScheduleUntilOnline,
shouldSchedule,
isSavedMessages,
onClose, onClose,
}) => { }) => {
const { const {
@ -53,6 +66,8 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline);
const { const {
observe: observeIntersection, observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen }); } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen });
@ -73,15 +88,22 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
} }
}, [isOpen, fromSticker, loadStickers, stickerSetShortName]); }, [isOpen, fromSticker, loadStickers, stickerSetShortName]);
const handleSelect = useCallback((sticker: ApiSticker) => { const handleSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean) => {
sticker = { sticker = {
...sticker, ...sticker,
isPreloadedGlobally: true, isPreloadedGlobally: true,
}; };
sendMessage({ sticker }); if (shouldSchedule || isScheduleRequested) {
onClose(); requestCalendar((scheduledAt) => {
}, [onClose, sendMessage]); sendMessage({ sticker, isSilent, scheduledAt });
onClose();
});
} else {
sendMessage({ sticker, isSilent });
onClose();
}
}, [onClose, requestCalendar, sendMessage, shouldSchedule]);
const handleButtonClick = useCallback(() => { const handleButtonClick = useCallback(() => {
if (stickerSet) { if (stickerSet) {
@ -108,6 +130,7 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
onClick={canSendStickers ? handleSelect : undefined} onClick={canSendStickers ? handleSelect : undefined}
clickArg={sticker} clickArg={sticker}
isSavedMessages={isSavedMessages}
/> />
))} ))}
</div> </div>
@ -129,6 +152,7 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
) : ( ) : (
<Loading /> <Loading />
)} )}
{calendar}
</Modal> </Modal>
); );
}; };
@ -142,9 +166,13 @@ export default memo(withGlobal<OwnProps>(
const canSendStickers = Boolean( const canSendStickers = Boolean(
chat && threadId && getCanPostInChat(chat, threadId) && sendOptions?.canSendStickers, chat && threadId && getCanPostInChat(chat, threadId) && sendOptions?.canSendStickers,
); );
const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId);
return { return {
canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId),
canSendStickers, canSendStickers,
isSavedMessages,
shouldSchedule: selectShouldSchedule(global),
stickerSet: fromSticker stickerSet: fromSticker
? selectStickerSet(global, fromSticker.stickerSetId) ? selectStickerSet(global, fromSticker.stickerSetId)
: stickerSetShortName : stickerSetShortName

View File

@ -78,6 +78,8 @@ const SettingsStickerSet: FC<OwnProps> = ({
size={STICKER_SIZE_GENERAL_SETTINGS} size={STICKER_SIZE_GENERAL_SETTINGS}
title={stickerSet.title} title={stickerSet.title}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
clickArg={undefined}
noContextMenu
/> />
<div className="multiline-menu-item"> <div className="multiline-menu-item">
<div className="title">{stickerSet.title}</div> <div className="title">{stickerSet.title}</div>

View File

@ -83,6 +83,7 @@ const ContactGreeting: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
size={160} size={160}
className="large" className="large"
noContextMenu
/> />
)} )}
</div> </div>

View File

@ -12,6 +12,7 @@ import {
} from '../../../config'; } from '../../../config';
import { getFileExtension } from '../../common/helpers/documentInfo'; import { getFileExtension } from '../../common/helpers/documentInfo';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useMentionTooltip from './hooks/useMentionTooltip'; import useMentionTooltip from './hooks/useMentionTooltip';
import useEmojiTooltip from './hooks/useEmojiTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip';
@ -43,13 +44,14 @@ export type OwnProps = {
recentEmojis: string[]; recentEmojis: string[];
baseEmojiKeywords?: Record<string, string[]>; baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>; emojiKeywords?: Record<string, string[]>;
shouldSchedule?: boolean;
addRecentEmoji: AnyToVoidFunction; addRecentEmoji: AnyToVoidFunction;
onCaptionUpdate: (html: string) => void; onCaptionUpdate: (html: string) => void;
onSend: () => void; onSend: () => void;
onFileAppend: (files: File[], isQuick: boolean) => void; onFileAppend: (files: File[], isQuick: boolean) => void;
onClear: () => void; onClear: () => void;
onSilentSend: () => void; onSendSilent: () => void;
openCalendar: () => void; onSendScheduled: () => void;
}; };
const DROP_LEAVE_TIMEOUT_MS = 150; const DROP_LEAVE_TIMEOUT_MS = 150;
@ -67,13 +69,14 @@ const AttachmentModal: FC<OwnProps> = ({
recentEmojis, recentEmojis,
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, emojiKeywords,
shouldSchedule,
addRecentEmoji, addRecentEmoji,
onCaptionUpdate, onCaptionUpdate,
onSend, onSend,
onFileAppend, onFileAppend,
onClear, onClear,
onSilentSend, onSendSilent,
openCalendar, onSendScheduled,
}) => { }) => {
const captionRef = useStateRef(caption); const captionRef = useStateRef(caption);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -121,9 +124,13 @@ const AttachmentModal: FC<OwnProps> = ({
const sendAttachments = useCallback(() => { const sendAttachments = useCallback(() => {
if (isOpen) { if (isOpen) {
onSend(); if (shouldSchedule) {
onSendScheduled();
} else {
onSend();
}
} }
}, [isOpen, onSend]); }, [isOpen, onSendScheduled, onSend, shouldSchedule]);
const handleDragLeave = (e: React.DragEvent<HTMLElement>) => { const handleDragLeave = (e: React.DragEvent<HTMLElement>) => {
const { relatedTarget: toTarget, target: fromTarget } = e; const { relatedTarget: toTarget, target: fromTarget } = e;
@ -217,10 +224,11 @@ const AttachmentModal: FC<OwnProps> = ({
<CustomSendMenu <CustomSendMenu
isOpen={isCustomSendMenuOpen} isOpen={isCustomSendMenuOpen}
isOpenToBottom isOpenToBottom
onSilentSend={!isChatWithSelf ? onSilentSend : undefined} onSendSilent={!isChatWithSelf ? onSendSilent : undefined}
onScheduleSend={openCalendar} onSendSchedule={onSendScheduled}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide} onCloseAnimationEnd={handleContextMenuHide}
isSavedMessages={isChatWithSelf}
/> />
)} )}
</div> </div>
@ -288,7 +296,7 @@ const AttachmentModal: FC<OwnProps> = ({
editableInputId={EDITABLE_INPUT_MODAL_ID} editableInputId={EDITABLE_INPUT_MODAL_ID}
placeholder={lang('Caption')} placeholder={lang('Caption')}
onUpdate={onCaptionUpdate} onUpdate={onCaptionUpdate}
onSend={onSend} onSend={sendAttachments}
canAutoFocus={Boolean(isReady && attachments.length)} canAutoFocus={Boolean(isReady && attachments.length)}
/> />
</div> </div>

View File

@ -22,7 +22,7 @@ import {
import { InlineBotSettings } from '../../../types'; import { InlineBotSettings } from '../../../types';
import { 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'; } from '../../../config';
import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment'; import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -36,20 +36,18 @@ import {
selectEditingMessage, selectEditingMessage,
selectIsChatWithSelf, selectIsChatWithSelf,
selectChatBot, selectChatBot,
selectChatUser,
selectChatMessage, selectChatMessage,
selectUser, selectUser,
selectUserStatus, selectCanScheduleUntilOnline,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
getChatSlowModeOptions, getChatSlowModeOptions,
isUserId,
isChatAdmin, isChatAdmin,
isChatSuperGroup, isChatSuperGroup,
isChatChannel, isChatChannel,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { formatMediaDuration, formatVoiceRecordDuration, getDayStartAt } from '../../../util/dateFormat'; import { formatMediaDuration, formatVoiceRecordDuration } from '../../../util/dateFormat';
import focusEditableElement from '../../../util/focusEditableElement'; import focusEditableElement from '../../../util/focusEditableElement';
import parseMessageInput from '../../../util/parseMessageInput'; import parseMessageInput from '../../../util/parseMessageInput';
import buildAttachment from './helpers/buildAttachment'; import buildAttachment from './helpers/buildAttachment';
@ -79,12 +77,12 @@ import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionTooltip from './hooks/useMentionTooltip'; import useMentionTooltip from './hooks/useMentionTooltip';
import useInlineBotTooltip from './hooks/useInlineBotTooltip'; import useInlineBotTooltip from './hooks/useInlineBotTooltip';
import useBotCommandTooltip from './hooks/useBotCommandTooltip'; import useBotCommandTooltip from './hooks/useBotCommandTooltip';
import useSchedule from '../../../hooks/useSchedule';
import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import DeleteMessageModal from '../../common/DeleteMessageModal.async';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import CalendarModal from '../../common/CalendarModal.async';
import AttachMenu from './AttachMenu'; import AttachMenu from './AttachMenu';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import SymbolMenu from './SymbolMenu.async'; import SymbolMenu from './SymbolMenu.async';
@ -159,6 +157,10 @@ enum MainButtonState {
Schedule = 'schedule', Schedule = 'schedule',
} }
type ScheduledMessageArgs = GlobalState['messages']['contentToBeScheduled'] | {
id: string; queryId: string; isSilent?: boolean;
};
const VOICE_RECORDING_FILENAME = 'wonderful-voice-message.ogg'; const VOICE_RECORDING_FILENAME = 'wonderful-voice-message.ogg';
// When voice recording is active, composer placeholder will hide to prevent overlapping // When voice recording is active, composer placeholder will hide to prevent overlapping
const SCREEN_WIDTH_TO_HIDE_PLACEHOLDER = 600; // px const SCREEN_WIDTH_TO_HIDE_PLACEHOLDER = 600; // px
@ -236,15 +238,18 @@ const Composer: FC<OwnProps & StateProps> = ({
const htmlRef = useStateRef(html); const htmlRef = useStateRef(html);
const lastMessageSendTimeSeconds = useRef<number>(); const lastMessageSendTimeSeconds = useRef<number>();
const prevDropAreaState = usePrevious(dropAreaState); const prevDropAreaState = usePrevious(dropAreaState);
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
const [
scheduledMessageArgs, setScheduledMessageArgs,
] = useState<GlobalState['messages']['contentToBeScheduled'] | undefined>();
const { width: windowWidth } = windowSize.get(); const { width: windowWidth } = windowSize.get();
const sendAsIds = chat?.sendAsIds; const sendAsIds = chat?.sendAsIds;
const canShowSendAs = sendAsIds && (sendAsIds.length > 1 || !sendAsIds.includes(currentUserId!)); 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 sendMessageAction = useSendMessageAction(chatId, threadId);
const handleScheduleCancel = useCallback(() => {
cancelForceShowSymbolMenu();
}, [cancelForceShowSymbolMenu]);
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, handleScheduleCancel);
useEffect(() => { useEffect(() => {
lastMessageSendTimeSeconds.current = undefined; lastMessageSendTimeSeconds.current = undefined;
}, [chatId]); }, [chatId]);
@ -279,13 +284,6 @@ const Composer: FC<OwnProps & StateProps> = ({
appendixRef.current.innerHTML = APPENDIX; appendixRef.current.innerHTML = APPENDIX;
}, []); }, []);
useEffect(() => {
if (contentToBeScheduled) {
setScheduledMessageArgs(contentToBeScheduled);
openCalendar();
}
}, [contentToBeScheduled, openCalendar]);
const [attachments, setAttachments] = useState<ApiAttachment[]>([]); const [attachments, setAttachments] = useState<ApiAttachment[]>([]);
const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag(); const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag();
@ -438,8 +436,6 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
setAttachments(MEMO_EMPTY_ARRAY); setAttachments(MEMO_EMPTY_ARRAY);
closeStickerTooltip(); closeStickerTooltip();
closeCalendar();
setScheduledMessageArgs(undefined);
closeMentionTooltip(); closeMentionTooltip();
closeEmojiTooltip(); closeEmojiTooltip();
@ -449,7 +445,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} else { } else {
closeSymbolMenu(); closeSymbolMenu();
} }
}, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); }, [closeStickerTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]);
// Handle chat change (ref is used to avoid redundant effect calls) // Handle chat change (ref is used to avoid redundant effect calls)
const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>(); const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>();
@ -600,44 +596,111 @@ const Composer: FC<OwnProps & StateProps> = ({
openSymbolMenu(); openSymbolMenu();
}, [closeBotCommandMenu, closeSendAsMenu, 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 = {
...sticker, ...sticker,
isPreloadedGlobally: true, isPreloadedGlobally: true,
}; };
if (shouldSchedule) { if (shouldSchedule || isScheduleRequested) {
setScheduledMessageArgs({ sticker }); forceShowSymbolMenu();
openCalendar(); requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ sticker, isSilent }, scheduledAt);
requestAnimationFrame(() => {
resetComposer(shouldPreserveInput);
});
});
} else { } else {
sendMessage({ sticker }); sendMessage({ sticker, isSilent });
requestAnimationFrame(() => { requestAnimationFrame(() => {
resetComposer(shouldPreserveInput); resetComposer(shouldPreserveInput);
}); });
} }
}, [shouldSchedule, openCalendar, sendMessage, resetComposer]); }, [
shouldSchedule, forceShowSymbolMenu, requestCalendar, cancelForceShowSymbolMenu, handleMessageSchedule,
resetComposer, sendMessage,
]);
const handleGifSelect = useCallback((gif: ApiVideo) => { const handleGifSelect = useCallback((gif: ApiVideo, isSilent?: boolean, isScheduleRequested?: boolean) => {
if (shouldSchedule) { if (shouldSchedule || isScheduleRequested) {
setScheduledMessageArgs({ gif }); forceShowSymbolMenu();
openCalendar(); requestCalendar((scheduledAt) => {
cancelForceShowSymbolMenu();
handleMessageSchedule({ gif, isSilent }, scheduledAt);
requestAnimationFrame(() => {
resetComposer(true);
});
});
} else { } else {
sendMessage({ gif }); sendMessage({ gif, isSilent });
requestAnimationFrame(() => { requestAnimationFrame(() => {
resetComposer(true); 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') { if (connectionState !== 'connectionStateReady') {
return; return;
} }
sendInlineBotResult({ if (shouldSchedule || isScheduleRequested) {
id: inlineResult.id, requestCalendar((scheduledAt) => {
queryId: inlineResult.queryId, 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)!; const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
if (IS_IOS && messageInput === document.activeElement) { if (IS_IOS && messageInput === document.activeElement) {
@ -648,7 +711,10 @@ const Composer: FC<OwnProps & StateProps> = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
resetComposer(); resetComposer();
}); });
}, [chatId, clearDraft, connectionState, resetComposer, sendInlineBotResult]); }, [
chatId, clearDraft, connectionState, handleMessageSchedule, requestCalendar, resetComposer, sendInlineBotResult,
shouldSchedule,
]);
const handleBotCommandSelect = useCallback(() => { const handleBotCommandSelect = useCallback(() => {
clearDraft({ chatId, localOnly: true }); clearDraft({ chatId, localOnly: true });
@ -659,56 +725,25 @@ const Composer: FC<OwnProps & StateProps> = ({
const handlePollSend = useCallback((poll: ApiNewPoll) => { const handlePollSend = useCallback((poll: ApiNewPoll) => {
if (shouldSchedule) { if (shouldSchedule) {
setScheduledMessageArgs({ poll }); requestCalendar((scheduledAt) => {
handleMessageSchedule({ poll }, scheduledAt);
});
closePollModal(); closePollModal();
openCalendar();
} else { } else {
sendMessage({ poll }); sendMessage({ poll });
closePollModal(); closePollModal();
} }
}, [closePollModal, openCalendar, sendMessage, shouldSchedule]); }, [closePollModal, handleMessageSchedule, requestCalendar, sendMessage, shouldSchedule]);
const handleSilentSend = useCallback(() => { const handleSendSilent = useCallback(() => {
if (shouldSchedule) { if (shouldSchedule) {
setScheduledMessageArgs({ isSilent: true }); requestCalendar((scheduledAt) => {
openCalendar(); handleMessageSchedule({ isSilent: true }, scheduledAt);
});
} else { } else {
void handleSend(true); void handleSend(true);
} }
}, [handleSend, openCalendar, shouldSchedule]); }, [handleMessageSchedule, handleSend, requestCalendar, 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]);
const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => { const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => {
if (type === 'stickers') { if (type === 'stickers') {
@ -790,14 +825,16 @@ const Composer: FC<OwnProps & StateProps> = ({
if (activeVoiceRecording) { if (activeVoiceRecording) {
pauseRecordingVoice(); pauseRecordingVoice();
} }
openCalendar(); requestCalendar((scheduledAt) => {
handleMessageSchedule({}, scheduledAt);
});
break; break;
default: default:
break; break;
} }
}, [ }, [
mainButtonState, handleSend, startRecordingVoice, handleEditComplete, mainButtonState, handleSend, startRecordingVoice, handleEditComplete, activeVoiceRecording, requestCalendar,
activeVoiceRecording, openCalendar, pauseRecordingVoice, pauseRecordingVoice, handleMessageSchedule,
]); ]);
const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record && !canAttachMedia; const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record && !canAttachMedia;
@ -837,9 +874,15 @@ const Composer: FC<OwnProps & StateProps> = ({
: (isSymbolMenuOpen && 'is-loading'), : (isSymbolMenuOpen && 'is-loading'),
); );
const handleSendScheduled = useCallback(() => {
requestCalendar((scheduledAt) => {
handleMessageSchedule({}, scheduledAt);
});
}, [handleMessageSchedule, requestCalendar]);
const onSend = mainButtonState === MainButtonState.Edit const onSend = mainButtonState === MainButtonState.Edit
? handleEditComplete ? handleEditComplete
: mainButtonState === MainButtonState.Schedule ? openCalendar : mainButtonState === MainButtonState.Schedule ? handleSendScheduled
: handleSend; : handleSend;
return ( return (
@ -867,9 +910,10 @@ const Composer: FC<OwnProps & StateProps> = ({
baseEmojiKeywords={baseEmojiKeywords} baseEmojiKeywords={baseEmojiKeywords}
emojiKeywords={emojiKeywords} emojiKeywords={emojiKeywords}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
onSilentSend={handleSilentSend} shouldSchedule={shouldSchedule}
openCalendar={openCalendar} onSendSilent={handleSendSilent}
onSend={shouldSchedule ? openCalendar : handleSend} onSend={handleSend}
onSendScheduled={handleSendScheduled}
onFileAppend={handleAppendFiles} onFileAppend={handleAppendFiles}
onClear={handleClearAttachment} onClear={handleClearAttachment}
/> />
@ -909,6 +953,8 @@ const Composer: FC<OwnProps & StateProps> = ({
onSelectResult={handleInlineBotSelect} onSelectResult={handleInlineBotSelect}
loadMore={loadMoreForInlineBot} loadMore={loadMoreForInlineBot}
onClose={closeInlineBotTooltip} onClose={closeInlineBotTooltip}
isSavedMessages={isChatWithSelf}
canSendGifs={canSendGifs}
/> />
<BotCommandTooltip <BotCommandTooltip
isOpen={isBotCommandTooltipOpen} isOpen={isBotCommandTooltipOpen}
@ -1063,7 +1109,7 @@ const Composer: FC<OwnProps & StateProps> = ({
<SymbolMenu <SymbolMenu
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isOpen={isSymbolMenuOpen} isOpen={isSymbolMenuOpen || isSymbolMenuForced}
canSendGifs={canSendGifs} canSendGifs={canSendGifs}
canSendStickers={canSendStickers} canSendStickers={canSendStickers}
onLoad={onSymbolMenuLoadingComplete} onLoad={onSymbolMenuLoadingComplete}
@ -1108,23 +1154,14 @@ const Composer: FC<OwnProps & StateProps> = ({
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu
isOpen={isCustomSendMenuOpen} isOpen={isCustomSendMenuOpen}
onSilentSend={!isChatWithSelf ? handleSilentSend : undefined} onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onScheduleSend={!shouldSchedule ? openCalendar : undefined} onSendSchedule={!shouldSchedule ? handleSendScheduled : undefined}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide} onCloseAnimationEnd={handleContextMenuHide}
isSavedMessages={isChatWithSelf}
/> />
)} )}
<CalendarModal {calendar}
isOpen={isCalendarOpen}
withTimePicker
selectedAt={scheduledDefaultDate.getTime()}
maxAt={getDayStartAt(scheduledMaxDate)}
isFutureMode
secondButtonLabel={canScheduleUntilOnline ? lang('Schedule.SendWhenOnline') : undefined}
onClose={handleCloseCalendar}
onSubmit={handleMessageSchedule}
onSecondButtonClick={canScheduleUntilOnline ? handleMessageScheduleUntilOnline : undefined}
/>
</div> </div>
); );
}; };
@ -1132,7 +1169,6 @@ const Composer: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId, messageListType }): StateProps => { (global, { chatId, threadId, messageListType }): StateProps => {
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const chatUser = chat && selectChatUser(global, chat);
const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined; const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined;
const isChatWithBot = Boolean(chatBot); const isChatWithBot = Boolean(chatBot);
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
@ -1158,11 +1194,8 @@ export default memo(withGlobal<OwnProps>(
chat, chat,
isChatWithBot, isChatWithBot,
isChatWithSelf, isChatWithSelf,
canScheduleUntilOnline: selectCanScheduleUntilOnline(global, chatId),
isChannel: chat ? isChatChannel(chat) : undefined, isChannel: chat ? isChatChannel(chat) : undefined,
canScheduleUntilOnline: Boolean(
!isChatWithSelf && !isChatWithBot && chat && chatUser
&& isUserId(chatId) && selectUserStatus(global, chatId)?.wasOnline,
),
isRightColumnShown: selectIsRightColumnShown(global), isRightColumnShown: selectIsRightColumnShown(global),
isSelectModeActive: selectIsInSelectMode(global), isSelectModeActive: selectIsInSelectMode(global),
withScheduledButton: ( withScheduledButton: (

View File

@ -18,11 +18,12 @@ import {
selectEditingMessage, selectEditingMessage,
} from '../../../global/selectors'; } from '../../../global/selectors';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useShowTransition from '../../../hooks/useShowTransition';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../global/helpers'; import { isUserId } from '../../../global/helpers';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useShowTransition from '../../../hooks/useShowTransition';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import EmbeddedMessage from '../../common/EmbeddedMessage'; import EmbeddedMessage from '../../common/EmbeddedMessage';

View File

@ -12,14 +12,21 @@ import './CustomSendMenu.scss';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
isOpenToBottom?: boolean; isOpenToBottom?: boolean;
onSilentSend?: NoneToVoidFunction; isSavedMessages?: boolean;
onScheduleSend?: NoneToVoidFunction; onSendSilent?: NoneToVoidFunction;
onSendSchedule?: NoneToVoidFunction;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction;
}; };
const CustomSendMenu: FC<OwnProps> = ({ const CustomSendMenu: FC<OwnProps> = ({
isOpen, isOpenToBottom = false, onSilentSend, onScheduleSend, onClose, onCloseAnimationEnd, isOpen,
isOpenToBottom = false,
isSavedMessages,
onSendSilent,
onSendSchedule,
onClose,
onCloseAnimationEnd,
}) => { }) => {
const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose); const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose);
@ -38,8 +45,12 @@ const CustomSendMenu: FC<OwnProps> = ({
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
noCloseOnBackdrop={!IS_TOUCH_ENV} noCloseOnBackdrop={!IS_TOUCH_ENV}
> >
{onSilentSend && <MenuItem icon="mute" onClick={onSilentSend}>{lang('SendWithoutSound')}</MenuItem>} {onSendSilent && <MenuItem icon="mute" onClick={onSendSilent}>{lang('SendWithoutSound')}</MenuItem>}
{onScheduleSend && <MenuItem icon="schedule" onClick={onScheduleSend}>{lang('ScheduleMessage')}</MenuItem>} {onSendSchedule && (
<MenuItem icon="schedule" onClick={onSendSchedule}>
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
</MenuItem>
)}
</Menu> </Menu>
); );
}; };

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, useEffect, memo, useRef, FC, useEffect, memo, useRef, useCallback,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -8,6 +8,8 @@ import { ApiVideo } from '../../../api/types';
import { SLIDE_TRANSITION_DURATION } from '../../../config'; import { SLIDE_TRANSITION_DURATION } from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/environment'; import { IS_TOUCH_ENV } from '../../../util/environment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { selectCurrentMessageList, selectIsChatWithSelf } from '../../../global/selectors';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useAsyncRendering from '../../right/hooks/useAsyncRendering';
@ -20,11 +22,12 @@ type OwnProps = {
className: string; className: string;
loadAndPlay: boolean; loadAndPlay: boolean;
canSendGifs: boolean; canSendGifs: boolean;
onGifSelect: (gif: ApiVideo) => void; onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
}; };
type StateProps = { type StateProps = {
savedGifs?: ApiVideo[]; savedGifs?: ApiVideo[];
isSavedMessages?: boolean;
}; };
const INTERSECTION_DEBOUNCE = 300; const INTERSECTION_DEBOUNCE = 300;
@ -34,9 +37,10 @@ const GifPicker: FC<OwnProps & StateProps> = ({
loadAndPlay, loadAndPlay,
canSendGifs, canSendGifs,
savedGifs, savedGifs,
isSavedMessages,
onGifSelect, onGifSelect,
}) => { }) => {
const { loadSavedGifs } = getActions(); const { loadSavedGifs, saveGif } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -51,6 +55,10 @@ const GifPicker: FC<OwnProps & StateProps> = ({
} }
}, [loadAndPlay, loadSavedGifs]); }, [loadAndPlay, loadSavedGifs]);
const handleUnsaveClick = useCallback((gif: ApiVideo) => {
saveGif({ gif, shouldUnsave: true });
}, [saveGif]);
const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION); const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION);
return ( return (
@ -67,7 +75,9 @@ const GifPicker: FC<OwnProps & StateProps> = ({
gif={gif} gif={gif}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
isDisabled={!loadAndPlay} isDisabled={!loadAndPlay}
onClick={onGifSelect} onClick={canSendGifs ? onGifSelect : undefined}
onUnsaveClick={handleUnsaveClick}
isSavedMessages={isSavedMessages}
/> />
)) ))
) : canRenderContents && savedGifs ? ( ) : canRenderContents && savedGifs ? (
@ -81,8 +91,11 @@ const GifPicker: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { chatId } = selectCurrentMessageList(global) || {};
const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId);
return { return {
savedGifs: global.gifs.saved.gifs, savedGifs: global.gifs.saved.gifs,
isSavedMessages,
}; };
}, },
)(GifPicker)); )(GifPicker));

View File

@ -4,8 +4,6 @@
font-weight: 500; font-weight: 500;
} }
--border-radius-default: 0;
&.gallery { &.gallery {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);

View File

@ -1,6 +1,7 @@
import React, { import React, {
FC, memo, useCallback, useEffect, useRef, FC, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm } from '../../../api/types'; import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm } from '../../../api/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
@ -22,7 +23,6 @@ import ListItem from '../../ui/ListItem';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import './InlineBotTooltip.scss'; import './InlineBotTooltip.scss';
import { getActions } from '../../../global';
const INTERSECTION_DEBOUNCE_MS = 200; const INTERSECTION_DEBOUNCE_MS = 200;
const runThrottled = throttle((cb) => cb(), 500, true); const runThrottled = throttle((cb) => cb(), 500, true);
@ -33,7 +33,11 @@ export type OwnProps = {
isGallery?: boolean; isGallery?: boolean;
inlineBotResults?: (ApiBotInlineResult | ApiBotInlineMediaResult)[]; inlineBotResults?: (ApiBotInlineResult | ApiBotInlineMediaResult)[];
switchPm?: ApiBotInlineSwitchPm; switchPm?: ApiBotInlineSwitchPm;
onSelectResult: (inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult) => void; isSavedMessages?: boolean;
canSendGifs?: boolean;
onSelectResult: (
inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean
) => void;
loadMore: NoneToVoidFunction; loadMore: NoneToVoidFunction;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
}; };
@ -44,6 +48,8 @@ const InlineBotTooltip: FC<OwnProps> = ({
isGallery, isGallery,
inlineBotResults, inlineBotResults,
switchPm, switchPm,
isSavedMessages,
canSendGifs,
loadMore, loadMore,
onClose, onClose,
onSelectResult, onSelectResult,
@ -127,6 +133,8 @@ const InlineBotTooltip: FC<OwnProps> = ({
inlineResult={inlineBotResult} inlineResult={inlineBotResult}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
onClick={onSelectResult} onClick={onSelectResult}
isSavedMessages={isSavedMessages}
canSendGifs={canSendGifs}
/> />
); );
@ -147,6 +155,7 @@ const InlineBotTooltip: FC<OwnProps> = ({
inlineResult={inlineBotResult} inlineResult={inlineBotResult}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
onClick={onSelectResult} onClick={onSelectResult}
isSavedMessages={isSavedMessages}
/> />
); );

View File

@ -12,6 +12,8 @@ import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import fastSmoothScroll from '../../../util/fastSmoothScroll'; import fastSmoothScroll from '../../../util/fastSmoothScroll';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import { selectIsChatWithSelf } from '../../../global/selectors';
import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
@ -33,7 +35,7 @@ type OwnProps = {
className: string; className: string;
loadAndPlay: boolean; loadAndPlay: boolean;
canSendStickers: boolean; canSendStickers: boolean;
onStickerSelect: (sticker: ApiSticker) => void; onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
}; };
type StateProps = { type StateProps = {
@ -42,6 +44,7 @@ type StateProps = {
stickerSetsById: Record<string, ApiStickerSet>; stickerSetsById: Record<string, ApiStickerSet>;
addedSetIds?: string[]; addedSetIds?: string[];
shouldPlay?: boolean; shouldPlay?: boolean;
isSavedMessages?: boolean;
}; };
const SMOOTH_SCROLL_DISTANCE = 500; const SMOOTH_SCROLL_DISTANCE = 500;
@ -61,12 +64,14 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
addedSetIds, addedSetIds,
stickerSetsById, stickerSetsById,
shouldPlay, shouldPlay,
isSavedMessages,
onStickerSelect, onStickerSelect,
}) => { }) => {
const { const {
loadRecentStickers, loadRecentStickers,
addRecentSticker, addRecentSticker,
unfaveSticker, unfaveSticker,
faveSticker,
} = getActions(); } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -164,8 +169,8 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE);
}, []); }, []);
const handleStickerSelect = useCallback((sticker: ApiSticker) => { const handleStickerSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => {
onStickerSelect(sticker); onStickerSelect(sticker, isSilent, shouldSchedule);
addRecentSticker({ sticker }); addRecentSticker({ sticker });
}, [addRecentSticker, onStickerSelect]); }, [addRecentSticker, onStickerSelect]);
@ -173,6 +178,10 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
unfaveSticker({ sticker }); unfaveSticker({ sticker });
}, [unfaveSticker]); }, [unfaveSticker]);
const handleStickerFave = useCallback((sticker: ApiSticker) => {
faveSticker({ sticker });
}, [faveSticker]);
const handleMouseMove = useCallback(() => { const handleMouseMove = useCallback(() => {
sendMessageAction({ type: 'chooseSticker' }); sendMessageAction({ type: 'chooseSticker' });
}, [sendMessageAction]); }, [sendMessageAction]);
@ -225,6 +234,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersectionForCovers} observeIntersection={observeIntersectionForCovers}
onClick={selectStickerSet} onClick={selectStickerSet}
clickArg={index} clickArg={index}
noContextMenu
/> />
); );
} }
@ -269,6 +279,9 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
onStickerSelect={handleStickerSelect} onStickerSelect={handleStickerSelect}
onStickerUnfave={handleStickerUnfave} onStickerUnfave={handleStickerUnfave}
onStickerFave={handleStickerFave}
favoriteStickers={favoriteStickers}
isSavedMessages={isSavedMessages}
/> />
))} ))}
</div> </div>
@ -277,7 +290,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
}; };
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global, { chatId }): StateProps => {
const { const {
setsById, setsById,
added, added,
@ -285,12 +298,15 @@ export default memo(withGlobal<OwnProps>(
favorite, favorite,
} = global.stickers; } = global.stickers;
const isSavedMessages = selectIsChatWithSelf(global, chatId);
return { return {
recentStickers: recent.stickers, recentStickers: recent.stickers,
favoriteStickers: favorite.stickers, favoriteStickers: favorite.stickers,
stickerSetsById: setsById, stickerSetsById: setsById,
addedSetIds: added.setIds, addedSetIds: added.setIds,
shouldPlay: global.settings.byKey.shouldLoopStickers, shouldPlay: global.settings.byKey.shouldLoopStickers,
isSavedMessages,
}; };
}, },
)(StickerPicker)); )(StickerPicker));

View File

@ -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 { ApiSticker } from '../../../api/types';
import { StickerSetOrRecent } from '../../../types'; import { StickerSetOrRecent } from '../../../types';
@ -7,18 +9,23 @@ import { ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserve
import { STICKER_SIZE_PICKER } from '../../../config'; import { STICKER_SIZE_PICKER } from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import windowSize from '../../../util/windowSize'; import windowSize from '../../../util/windowSize';
import StickerButton from '../../common/StickerButton';
import useMediaTransition from '../../../hooks/useMediaTransition';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useMediaTransition from '../../../hooks/useMediaTransition';
import StickerButton from '../../common/StickerButton';
type OwnProps = { type OwnProps = {
stickerSet: StickerSetOrRecent; stickerSet: StickerSetOrRecent;
loadAndPlay: boolean; loadAndPlay: boolean;
index: number; index: number;
observeIntersection: ObserveFn;
shouldRender: boolean; shouldRender: boolean;
onStickerSelect: (sticker: ApiSticker) => void; favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean;
observeIntersection: ObserveFn;
onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave: (sticker: ApiSticker) => void; onStickerUnfave: (sticker: ApiSticker) => void;
onStickerFave: (sticker: ApiSticker) => void;
}; };
const STICKERS_PER_ROW_ON_DESKTOP = 5; const STICKERS_PER_ROW_ON_DESKTOP = 5;
@ -29,10 +36,13 @@ const StickerSet: FC<OwnProps> = ({
stickerSet, stickerSet,
loadAndPlay, loadAndPlay,
index, index,
observeIntersection,
shouldRender, shouldRender,
favoriteStickers,
isSavedMessages,
observeIntersection,
onStickerSelect, onStickerSelect,
onStickerUnfave, onStickerUnfave,
onStickerFave,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -46,6 +56,10 @@ const StickerSet: FC<OwnProps> = ({
: STICKERS_PER_ROW_ON_DESKTOP; : STICKERS_PER_ROW_ON_DESKTOP;
const height = Math.ceil(stickerSet.count / stickersPerRow) * (STICKER_SIZE_PICKER + STICKER_MARGIN); 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 ( return (
<div <div
ref={ref} ref={ref}
@ -67,7 +81,9 @@ const StickerSet: FC<OwnProps> = ({
noAnimate={!loadAndPlay} noAnimate={!loadAndPlay}
onClick={onStickerSelect} onClick={onStickerSelect}
clickArg={sticker} clickArg={sticker}
onUnfaveClick={stickerSet.id === 'favorite' ? onStickerUnfave : undefined} onUnfaveClick={favoriteStickerIdsSet?.has(sticker.id) ? onStickerUnfave : undefined}
onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined}
isSavedMessages={isSavedMessages}
/> />
))} ))}
</div> </div>

View File

@ -12,6 +12,7 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useSendMessageAction from '../../../hooks/useSendMessageAction'; import useSendMessageAction from '../../../hooks/useSendMessageAction';
import { selectIsChatWithSelf } from '../../../global/selectors';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import StickerButton from '../../common/StickerButton'; import StickerButton from '../../common/StickerButton';
@ -22,11 +23,12 @@ export type OwnProps = {
chatId: string; chatId: string;
threadId?: number; threadId?: number;
isOpen: boolean; isOpen: boolean;
onStickerSelect: (sticker: ApiSticker) => void; onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
}; };
type StateProps = { type StateProps = {
stickers?: ApiSticker[]; stickers?: ApiSticker[];
isSavedMessages?: boolean;
}; };
const INTERSECTION_THROTTLE = 200; const INTERSECTION_THROTTLE = 200;
@ -35,8 +37,9 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId, threadId,
isOpen, isOpen,
onStickerSelect,
stickers, stickers,
isSavedMessages,
onStickerSelect,
}) => { }) => {
const { clearStickersForEmoji } = getActions(); const { clearStickersForEmoji } = getActions();
@ -78,6 +81,7 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
onClick={onStickerSelect} onClick={onStickerSelect}
clickArg={sticker} clickArg={sticker}
isSavedMessages={isSavedMessages}
/> />
)) ))
) : shouldRender ? ( ) : shouldRender ? (
@ -88,9 +92,10 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
}; };
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global, { chatId }): StateProps => {
const { stickers } = global.stickers.forEmoji; const { stickers } = global.stickers.forEmoji;
const isSavedMessages = selectIsChatWithSelf(global, chatId);
return { stickers }; return { stickers, isSavedMessages };
}, },
)(StickerTooltip)); )(StickerTooltip));

View File

@ -34,8 +34,10 @@ export type OwnProps = {
onLoad: () => void; onLoad: () => void;
onClose: () => void; onClose: () => void;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
onStickerSelect: (sticker: ApiSticker, shouldPreserveInput?: boolean) => void; onStickerSelect: (
onGifSelect: (gif: ApiVideo) => void; sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean
) => void;
onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onRemoveSymbol: () => void; onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void; onSearchOpen: (type: 'stickers' | 'gifs') => void;
addRecentEmoji: AnyToVoidFunction; addRecentEmoji: AnyToVoidFunction;
@ -126,8 +128,8 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onSearchOpen(type); onSearchOpen(type);
}, [onClose, onSearchOpen]); }, [onClose, onSearchOpen]);
const handleStickerSelect = useCallback((sticker: ApiSticker) => { const handleStickerSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => {
onStickerSelect(sticker, true); onStickerSelect(sticker, isSilent, shouldSchedule, true);
}, [onStickerSelect]); }, [onStickerSelect]);
const lang = useLang(); const lang = useLang();

View File

@ -2,7 +2,7 @@ import React, {
FC, memo, useCallback, FC, memo, useCallback,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types'; import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiVideo } from '../../../../api/types';
import { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import { ObserveFn } from '../../../../hooks/useIntersectionObserver';
@ -10,17 +10,19 @@ import GifButton from '../../../common/GifButton';
type OwnProps = { type OwnProps = {
inlineResult: ApiBotInlineMediaResult; inlineResult: ApiBotInlineMediaResult;
isSavedMessages?: boolean;
canSendGifs?: boolean;
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
onClick: (result: ApiBotInlineResult) => void; onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void;
}; };
const GifResult: FC<OwnProps> = ({ const GifResult: FC<OwnProps> = ({
inlineResult, observeIntersection, onClick, inlineResult, isSavedMessages, canSendGifs, observeIntersection, onClick,
}) => { }) => {
const { gif } = inlineResult; const { gif } = inlineResult;
const handleClick = useCallback(() => { const handleClick = useCallback((_gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => {
onClick(inlineResult); onClick(inlineResult, isSilent, shouldSchedule);
}, [inlineResult, onClick]); }, [inlineResult, onClick]);
if (!gif) { if (!gif) {
@ -32,7 +34,8 @@ const GifResult: FC<OwnProps> = ({
gif={gif} gif={gif}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
className="chat-item-clickable" className="chat-item-clickable"
onClick={handleClick} onClick={canSendGifs ? handleClick : undefined}
isSavedMessages={isSavedMessages}
/> />
); );
}; };

View File

@ -9,11 +9,17 @@ import StickerButton from '../../../common/StickerButton';
type OwnProps = { type OwnProps = {
inlineResult: ApiBotInlineMediaResult; inlineResult: ApiBotInlineMediaResult;
isSavedMessages?: boolean;
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
onClick: (result: ApiBotInlineResult) => void; onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void;
}; };
const StickerResult: FC<OwnProps> = ({ inlineResult, observeIntersection, onClick }) => { const StickerResult: FC<OwnProps> = ({
inlineResult,
isSavedMessages,
observeIntersection,
onClick,
}) => {
const { sticker } = inlineResult; const { sticker } = inlineResult;
if (!sticker) { if (!sticker) {
@ -29,6 +35,7 @@ const StickerResult: FC<OwnProps> = ({ inlineResult, observeIntersection, onClic
className="chat-item-clickable" className="chat-item-clickable"
onClick={onClick} onClick={onClick}
clickArg={inlineResult} clickArg={inlineResult}
isSavedMessages={isSavedMessages}
/> />
); );
}; };

View File

@ -15,7 +15,7 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import { import {
isActionMessage, isChatChannel, isActionMessage, isChatChannel,
isChatGroup, isOwnMessage, areReactionsEmpty, isUserId, isMessageLocal, isChatGroup, isOwnMessage, areReactionsEmpty, isUserId, isMessageLocal, getMessageVideo,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { getDayStartAt } from '../../../util/dateFormat'; import { getDayStartAt } from '../../../util/dateFormat';
@ -67,6 +67,7 @@ type StateProps = {
canCopyLink?: boolean; canCopyLink?: boolean;
canSelect?: boolean; canSelect?: boolean;
canDownload?: boolean; canDownload?: boolean;
canSaveGif?: boolean;
activeDownloads: number[]; activeDownloads: number[];
canShowSeenBy?: boolean; canShowSeenBy?: boolean;
enabledReactions?: string[]; enabledReactions?: string[];
@ -104,6 +105,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload, canDownload,
canSaveGif,
activeDownloads, activeDownloads,
canShowSeenBy, canShowSeenBy,
}) => { }) => {
@ -126,6 +128,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
loadFullChat, loadFullChat,
loadReactors, loadReactors,
copyMessagesByIds, copyMessagesByIds,
saveGif,
} = getActions(); } = getActions();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
@ -310,6 +313,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu(); closeMenu();
}, [album, message, closeMenu, isDownloading, cancelMessageMediaDownload, downloadMessageMedia]); }, [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) => { const handleSendReaction = useCallback((reaction: string | undefined, x: number, y: number) => {
sendReaction({ sendReaction({
chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE, chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE,
@ -355,6 +364,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canCopyLink={canCopyLink} canCopyLink={canCopyLink}
canSelect={canSelect} canSelect={canSelect}
canDownload={canDownload} canDownload={canDownload}
canSaveGif={canSaveGif}
canShowSeenBy={canShowSeenBy} canShowSeenBy={canShowSeenBy}
isDownloading={isDownloading} isDownloading={isDownloading}
seenByRecentUsers={seenByRecentUsers} seenByRecentUsers={seenByRecentUsers}
@ -374,6 +384,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onCopyLink={handleCopyLink} onCopyLink={handleCopyLink}
onCopyMessages={handleCopyMessages} onCopyMessages={handleCopyMessages}
onDownload={handleDownloadClick} onDownload={handleDownloadClick}
onSaveGif={handleSaveGif}
onShowSeenBy={handleOpenSeenByModal} onShowSeenBy={handleOpenSeenByModal}
onSendReaction={handleSendReaction} onSendReaction={handleSendReaction}
onShowReactors={handleOpenReactorListModal} onShowReactors={handleOpenReactorListModal}
@ -432,6 +443,7 @@ export default memo(withGlobal<OwnProps>(
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload, canDownload,
canSaveGif,
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isPinned = messageListType === 'pinned'; const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled'; const isScheduled = messageListType === 'scheduled';
@ -471,6 +483,7 @@ export default memo(withGlobal<OwnProps>(
canCopyLink: !isProtected && !isScheduled && canCopyLink, canCopyLink: !isProtected && !isScheduled && canCopyLink,
canSelect, canSelect,
canDownload: !isProtected && canDownload, canDownload: !isProtected && canDownload,
canSaveGif: !isProtected && canSaveGif,
activeDownloads, activeDownloads,
canShowSeenBy, canShowSeenBy,
enabledReactions: chat?.fullInfo?.enabledReactions, enabledReactions: chat?.fullInfo?.enabledReactions,

View File

@ -46,6 +46,7 @@ type OwnProps = {
canSelect?: boolean; canSelect?: boolean;
isPrivate?: boolean; isPrivate?: boolean;
canDownload?: boolean; canDownload?: boolean;
canSaveGif?: boolean;
isDownloading?: boolean; isDownloading?: boolean;
canShowSeenBy?: boolean; canShowSeenBy?: boolean;
seenByRecentUsers?: ApiUser[]; seenByRecentUsers?: ApiUser[];
@ -66,6 +67,7 @@ type OwnProps = {
onCopyLink?: () => void; onCopyLink?: () => void;
onCopyMessages?: (messageIds: number[]) => void; onCopyMessages?: (messageIds: number[]) => void;
onDownload?: () => void; onDownload?: () => void;
onSaveGif?: () => void;
onShowSeenBy?: () => void; onShowSeenBy?: () => void;
onShowReactors?: () => void; onShowReactors?: () => void;
onSendReaction: (reaction: string | undefined, x: number, y: number) => void; onSendReaction: (reaction: string | undefined, x: number, y: number) => void;
@ -97,6 +99,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload, canDownload,
canSaveGif,
isDownloading, isDownloading,
canShowSeenBy, canShowSeenBy,
canShowReactionsCount, canShowReactionsCount,
@ -119,6 +122,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onCloseAnimationEnd, onCloseAnimationEnd,
onCopyLink, onCopyLink,
onDownload, onDownload,
onSaveGif,
onShowSeenBy, onShowSeenBy,
onShowReactors, onShowReactors,
onSendReaction, onSendReaction,
@ -240,6 +244,7 @@ const MessageContextMenu: FC<OwnProps> = ({
))} ))}
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>} {canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>} {canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
{canSaveGif && <MenuItem icon="gifs" onClick={onSaveGif}>{lang('lng_context_save_gif')}</MenuItem>}
{canDownload && ( {canDownload && (
<MenuItem icon="download" onClick={onDownload}> <MenuItem icon="download" onClick={onDownload}>
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')} {isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}

View File

@ -11,12 +11,15 @@ import {
selectChat, selectChat,
selectIsChatWithBot, selectIsChatWithBot,
selectCurrentMessageList, selectCurrentMessageList,
selectCanScheduleUntilOnline,
selectIsChatWithSelf,
} from '../../global/selectors'; } from '../../global/selectors';
import { getAllowedAttachmentOptions } from '../../global/helpers'; import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useHistoryBack from '../../hooks/useHistoryBack'; import useHistoryBack from '../../hooks/useHistoryBack';
import useSchedule from '../../hooks/useSchedule';
import InfiniteScroll from '../ui/InfiniteScroll'; import InfiniteScroll from '../ui/InfiniteScroll';
import GifButton from '../common/GifButton'; import GifButton from '../common/GifButton';
@ -34,18 +37,24 @@ type StateProps = {
results?: ApiVideo[]; results?: ApiVideo[];
chat?: ApiChat; chat?: ApiChat;
isChatWithBot?: boolean; isChatWithBot?: boolean;
canScheduleUntilOnline?: boolean;
isSavedMessages?: boolean;
canPostInChat?: boolean;
}; };
const PRELOAD_BACKWARDS = 96; // GIF Search bot results are multiplied by 24 const PRELOAD_BACKWARDS = 96; // GIF Search bot results are multiplied by 24
const INTERSECTION_DEBOUNCE = 300; const INTERSECTION_DEBOUNCE = 300;
const GifSearch: FC<OwnProps & StateProps> = ({ const GifSearch: FC<OwnProps & StateProps> = ({
onClose,
isActive, isActive,
query, query,
results, results,
chat, chat,
isChatWithBot, isChatWithBot,
canScheduleUntilOnline,
isSavedMessages,
canPostInChat,
onClose,
}) => { }) => {
const { const {
searchMoreGifs, searchMoreGifs,
@ -56,21 +65,29 @@ const GifSearch: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline);
const { const {
observe: observeIntersection, observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, debounceMs: INTERSECTION_DEBOUNCE }); } = 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) { if (canSendGifs) {
sendMessage({ gif }); if (shouldSchedule) {
requestCalendar((scheduledAt) => {
sendMessage({ gif, scheduledAt, isSilent });
});
} else {
sendMessage({ gif, isSilent });
}
} }
if (IS_TOUCH_ENV) { if (IS_TOUCH_ENV) {
setGifSearchQuery({ query: undefined }); setGifSearchQuery({ query: undefined });
} }
}, [canSendGifs, sendMessage, setGifSearchQuery]); }, [canSendGifs, requestCalendar, sendMessage, setGifSearchQuery]);
const lang = useLang(); const lang = useLang();
@ -98,7 +115,8 @@ const GifSearch: FC<OwnProps & StateProps> = ({
key={gif.id} key={gif.id}
gif={gif} gif={gif}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
onClick={handleGifClick} onClick={canSendGifs ? handleGifClick : undefined}
isSavedMessages={isSavedMessages}
/> />
)); ));
} }
@ -118,6 +136,7 @@ const GifSearch: FC<OwnProps & StateProps> = ({
> >
{renderContent()} {renderContent()}
</InfiniteScroll> </InfiniteScroll>
{calendar}
</div> </div>
); );
}; };
@ -126,15 +145,20 @@ export default memo(withGlobal(
(global): StateProps => { (global): StateProps => {
const currentSearch = selectCurrentGifSearch(global); const currentSearch = selectCurrentGifSearch(global);
const { query, results } = currentSearch || {}; const { query, results } = currentSearch || {};
const { chatId } = selectCurrentMessageList(global) || {}; const { chatId, threadId } = selectCurrentMessageList(global) || {};
const chat = chatId ? selectChat(global, chatId) : undefined; const chat = chatId ? selectChat(global, chatId) : undefined;
const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : 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 { return {
query, query,
results, results,
chat, chat,
isChatWithBot, isChatWithBot,
isSavedMessages,
canPostInChat,
canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId),
}; };
}, },
)(GifSearch)); )(GifSearch));

View File

@ -101,7 +101,9 @@ const StickerSetResult: FC<OwnProps & StateProps> = ({
size={STICKER_SIZE_SEARCH} size={STICKER_SIZE_SEARCH}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
noAnimate={!shouldPlay || isModalOpen || isSomeModalOpen} noAnimate={!shouldPlay || isModalOpen || isSomeModalOpen}
clickArg={undefined}
onClick={openModal} onClick={openModal}
noContextMenu
/> />
))} ))}
</div> </div>

View File

@ -194,7 +194,9 @@ addActionHandler('queryInlineBot', async (global, actions, payload) => {
}); });
addActionHandler('sendInlineBotResult', (global, actions, payload) => { addActionHandler('sendInlineBotResult', (global, actions, payload) => {
const { id, queryId } = payload; const {
id, queryId, isSilent, scheduledAt,
} = payload;
const currentMessageList = selectCurrentMessageList(global); const currentMessageList = selectCurrentMessageList(global);
if (!currentMessageList || !id) { if (!currentMessageList || !id) {
return; return;
@ -213,6 +215,8 @@ addActionHandler('sendInlineBotResult', (global, actions, payload) => {
queryId, queryId,
replyingTo: selectReplyingToId(global, chatId, threadId), replyingTo: selectReplyingToId(global, chatId, threadId),
sendAs: selectSendAs(global, chatId), sendAs: selectSendAs(global, chatId),
isSilent,
scheduleDate: scheduledAt,
}); });
}); });

View File

@ -108,6 +108,29 @@ addActionHandler('loadSavedGifs', (global) => {
void loadSavedGifs(hash); 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) => { addActionHandler('faveSticker', (global, actions, payload) => {
const { sticker } = payload!; const { sticker } = payload!;

View File

@ -7,11 +7,11 @@ import {
MAIN_THREAD_ID, MAIN_THREAD_ID,
} from '../../api/types'; } 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 { import {
selectChat, selectIsChatWithBot, selectIsChatWithSelf, selectChat, selectChatBot, selectIsChatWithBot, selectIsChatWithSelf,
} from './chats'; } from './chats';
import { selectIsUserOrChatContact, selectUser } from './users'; import { selectIsUserOrChatContact, selectUser, selectUserStatus } from './users';
import { import {
getSendingState, getSendingState,
isChatChannel, isChatChannel,
@ -427,6 +427,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
const canDownload = Boolean(content.webPage?.document || content.webPage?.video || content.webPage?.photo const canDownload = Boolean(content.webPage?.document || content.webPage?.video || content.webPage?.photo
|| content.audio || content.voice || content.photo || content.video || content.document || content.sticker); || content.audio || content.voice || content.photo || content.video || content.document || content.sticker);
const canSaveGif = message.content.video?.isGif;
const noOptions = [ const noOptions = [
canReply, canReply,
canEdit, canEdit,
@ -442,6 +444,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload, canDownload,
canSaveGif,
].every((ability) => !ability); ].every((ability) => !ability);
return { return {
@ -460,6 +463,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload, canDownload,
canSaveGif,
}; };
} }
@ -922,3 +926,15 @@ export function selectVisibleUsers(global: GlobalState) {
return senderId ? selectUser(global, senderId) : undefined; return senderId ? selectUser(global, senderId) : undefined;
}).filter(Boolean); }).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,
);
}

View File

@ -593,7 +593,7 @@ export type NonTypedActionNames = (
'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' | 'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' |
// stickers & GIFs // stickers & GIFs
'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' | 'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' |
'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'setGifSearchQuery' | 'searchMoreGifs' | 'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'saveGif' | 'setGifSearchQuery' | 'searchMoreGifs' |
'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' | 'loadAnimatedEmojis' | 'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' | 'loadAnimatedEmojis' |
'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' | 'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' |
'openStickerSetShortName' | 'openStickerSetShortName' |

View File

@ -3,16 +3,11 @@ import { useState, useEffect, useCallback } from '../lib/teact/teact';
import { IAnchorPosition } from '../types'; import { IAnchorPosition } from '../types';
import { import {
IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT, IS_PWA, IS_IOS, IS_TOUCH_ENV, IS_PWA, IS_IOS,
} from '../util/environment'; } from '../util/environment';
const LONG_TAP_DURATION_MS = 200; 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) { function stopEvent(e: Event) {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault(); e.preventDefault();
@ -106,7 +101,7 @@ const useContextMenuHandlers = (
}; };
const startLongPressTimer = (e: TouchEvent) => { const startLongPressTimer = (e: TouchEvent) => {
if (isMenuDisabled || checkIsDisabledForMobile()) { if (isMenuDisabled) {
return; return;
} }
clearLongPressTimer(); clearLongPressTimer();

View File

@ -99,11 +99,9 @@ export default function useContextMenuPosition(
const triggerRect = triggerEl.getBoundingClientRect(); const triggerRect = triggerEl.getBoundingClientRect();
const left = horizontalPosition === 'left' const left = horizontalPosition === 'left'
? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX) ? 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); : (x - triggerRect.left);
const top = Math.min( const top = y - triggerRect.top;
rootRect.height - triggerRect.top + triggerRect.height - MENU_POSITION_BOTTOM_MARGIN + (marginTop || 0),
y - triggerRect.top,
);
const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0);
setWithScroll(menuMaxHeight < menuRect.height); setWithScroll(menuMaxHeight < menuRect.height);

65
src/hooks/useSchedule.tsx Normal file
View File

@ -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<OnScheduledCallback | undefined>();
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 = (
<CalendarModal
isOpen={Boolean(onScheduled)}
withTimePicker
selectedAt={scheduledDefaultDate.getTime()}
maxAt={getDayStartAt(scheduledMaxDate)}
isFutureMode
secondButtonLabel={canScheduleUntilOnline ? lang('Schedule.SendWhenOnline') : undefined}
onClose={handleCloseCalendar}
onSubmit={handleMessageSchedule}
onSecondButtonClick={canScheduleUntilOnline ? handleMessageScheduleUntilOnline : undefined}
/>
);
return [requestCalendar, calendar] as const;
};
export default useSchedule;

View File

@ -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.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.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document;
messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; 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.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.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<MessageEntity> schedule_date:flags.15?int = 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<MessageEntity> schedule_date:flags.15?int = Updates;

View File

@ -98,6 +98,7 @@
"messages.searchGlobal", "messages.searchGlobal",
"messages.getDocumentByHash", "messages.getDocumentByHash",
"messages.getSavedGifs", "messages.getSavedGifs",
"messages.saveGif",
"messages.getInlineBotResults", "messages.getInlineBotResults",
"messages.sendInlineBotResult", "messages.sendInlineBotResult",
"messages.editMessage", "messages.editMessage",