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

View File

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

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 }) {
const result = await invokeRequest(new GramJs.messages.InstallStickerSet({
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 EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
// eslint-disable-next-line import/no-cycle
export { default as LeftSearch } from '../components/left/search/LeftSearch';
// eslint-disable-next-line import/no-cycle
export { default as Settings } from '../components/left/settings/Settings';
export { default as ContactList } from '../components/left/main/ContactList';
export { default as NewChat } from '../components/left/newChat/NewChat';
export { default as NewChatStep1 } from '../components/left/newChat/NewChatStep1';
export { default as NewChatStep2 } from '../components/left/newChat/NewChatStep2';
// eslint-disable-next-line import/no-cycle
export { default as ArchivedChats } from '../components/left/ArchivedChats';
export { default as ChatFolderModal } from '../components/left/ChatFolderModal';
export { default as ContextMenuContainer } from '../components/middle/message/ContextMenuContainer';
// eslint-disable-next-line import/no-cycle
export { default as StickerSetModal } from '../components/common/StickerSetModal';
export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer';
export { default as MobileSearch } from '../components/middle/MobileSearch';
// eslint-disable-next-line import/no-cycle
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/middle/composer/PollModal';
export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu';
@ -44,7 +49,9 @@ export { default as InlineBotTooltip } from '../components/middle/composer/Inlin
export { default as SendAsMenu } from '../components/middle/composer/SendAsMenu';
export { default as RightSearch } from '../components/right/RightSearch';
// eslint-disable-next-line import/no-cycle
export { default as StickerSearch } from '../components/right/StickerSearch';
// eslint-disable-next-line import/no-cycle
export { default as GifSearch } from '../components/right/GifSearch';
export { default as Statistics } from '../components/right/statistics/Statistics';
export { default as PollResults } from '../components/right/PollResults';

View File

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

View File

@ -4,10 +4,14 @@
justify-content: center;
height: 6.25rem;
background-color: transparent;
cursor: pointer;
overflow: hidden;
position: relative;
&:hover {
.gif-unsave-button {
opacity: 0.8;
}
}
&:last-child {
margin-bottom: 1rem;
}
@ -20,6 +24,10 @@
grid-column-end: span 2;
}
&.interactive {
cursor: pointer;
}
.thumbnail {
width: 100%;
height: 100%;
@ -34,10 +42,39 @@
width: 100%;
height: 100%;
object-fit: cover;
-webkit-touch-callout: none;
user-select: none;
}
.Spinner {
position: absolute;
pointer-events: none;
}
.gif-unsave-button {
position: absolute;
top: 0.25rem;
right: 0.25rem;
width: 1rem;
height: 1rem;
padding: 0.125rem;
border-radius: 0.25rem;
transition: 0.15s opacity ease-in-out;
&-icon {
font-size: 0.75rem;
}
opacity: 0;
z-index: 1;
}
.gif-context-menu {
position: absolute;
.bubble {
width: auto;
}
}
}

View File

@ -1,18 +1,26 @@
import React, {
FC, memo, useCallback, useRef,
FC, memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact';
import { ApiMediaFormat, ApiVideo } from '../../api/types';
import { IS_TOUCH_ENV } from '../../util/environment';
import buildClassName from '../../util/buildClassName';
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useMedia from '../../hooks/useMedia';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useLang from '../../hooks/useLang';
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import Spinner from '../ui/Spinner';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import './GifButton.scss';
@ -21,17 +29,27 @@ type OwnProps = {
observeIntersection: ObserveFn;
isDisabled?: boolean;
className?: string;
onClick: (gif: ApiVideo) => void;
onClick?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onUnsaveClick?: (gif: ApiVideo) => void;
isSavedMessages?: boolean;
};
const GifButton: FC<OwnProps> = ({
gif, observeIntersection, isDisabled, className, onClick,
gif,
isDisabled,
className,
observeIntersection,
onClick,
onUnsaveClick,
isSavedMessages,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
const lang = useLang();
const hasThumbnail = Boolean(gif.thumbnail?.dataUri);
const localMediaHash = `gif${gif.id}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection);
@ -46,17 +64,78 @@ const GifButton: FC<OwnProps> = ({
useVideoCleanup(videoRef, [shouldRenderVideo]);
const handleClick = useCallback(
() => onClick({
const {
isContextMenuOpen, contextMenuPosition,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
const getTriggerElement = useCallback(() => ref.current, []);
const getRootElement = useCallback(
() => ref.current!.closest('.custom-scroll, .no-scrollbar'),
[],
);
const getMenuElement = useCallback(
() => ref.current!.querySelector('.gif-context-menu .bubble'),
[],
);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
);
const handleClick = useCallback(() => {
if (isContextMenuOpen || !onClick) return;
onClick({
...gif,
blobUrl: videoData,
}),
[onClick, gif, videoData],
);
});
}, [isContextMenuOpen, onClick, gif, videoData]);
const handleUnsaveClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onUnsaveClick!(gif);
}, [onUnsaveClick, gif]);
const handleContextDelete = useCallback(() => {
onUnsaveClick?.(gif);
}, [gif, onUnsaveClick]);
const handleSendQuiet = useCallback(() => {
onClick!({
...gif,
blobUrl: videoData,
}, true);
}, [gif, onClick, videoData]);
const handleSendScheduled = useCallback(() => {
onClick!({
...gif,
blobUrl: videoData,
}, undefined, true);
}, [gif, onClick, videoData]);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLElement>) => {
preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e);
}, [handleBeforeContextMenu]);
useEffect(() => {
if (isDisabled) handleContextMenuClose();
}, [handleContextMenuClose, isDisabled]);
const fullClassName = buildClassName(
'GifButton',
gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal',
onClick && 'interactive',
localMediaHash,
className,
);
@ -65,9 +144,20 @@ const GifButton: FC<OwnProps> = ({
<div
ref={ref}
className={fullClassName}
onMouseDown={preventMessageInputBlurWithBubbling}
onMouseDown={handleMouseDown}
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 && (
<canvas
ref={thumbRef}
@ -100,6 +190,28 @@ const GifButton: FC<OwnProps> = ({
{shouldRenderSpinner && (
<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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,6 +108,29 @@ addActionHandler('loadSavedGifs', (global) => {
void loadSavedGifs(hash);
});
addActionHandler('saveGif', async (global, actions, payload) => {
const { gif, shouldUnsave } = payload!;
const result = await callApi('saveGif', { gif, shouldUnsave });
if (!result) {
return undefined;
}
global = getGlobal();
const gifs = global.gifs.saved.gifs?.filter(({ id }) => id !== gif.id) || [];
const newGifs = shouldUnsave ? gifs : [gif, ...gifs];
return {
...global,
gifs: {
...global.gifs,
saved: {
...global.gifs.saved,
gifs: newGifs,
},
},
};
});
addActionHandler('faveSticker', (global, actions, payload) => {
const { sticker } = payload!;

View File

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

View File

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

View File

@ -3,16 +3,11 @@ import { useState, useEffect, useCallback } from '../lib/teact/teact';
import { IAnchorPosition } from '../types';
import {
IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT, IS_PWA, IS_IOS,
IS_TOUCH_ENV, IS_PWA, IS_IOS,
} from '../util/environment';
const LONG_TAP_DURATION_MS = 200;
function checkIsDisabledForMobile() {
return IS_SINGLE_COLUMN_LAYOUT
&& window.document.body.classList.contains('enable-symbol-menu-transforms');
}
function stopEvent(e: Event) {
e.stopImmediatePropagation();
e.preventDefault();
@ -106,7 +101,7 @@ const useContextMenuHandlers = (
};
const startLongPressTimer = (e: TouchEvent) => {
if (isMenuDisabled || checkIsDisabledForMobile()) {
if (isMenuDisabled) {
return;
}
clearLongPressTimer();

View File

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

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.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document;
messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs;
messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool;
messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults;
messages.sendInlineBotResult#7aa11297 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates;
messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.15?int = Updates;

View File

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