diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index ff48a3685..acffdaab4 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -13,6 +13,7 @@ import { ApiMessageEntityTypes, ApiNewPoll, ApiReportReason, + ApiSendMessageAction, ApiSticker, ApiVideo, } from '../../types'; @@ -421,6 +422,20 @@ export function buildInputReportReason(reason: ApiReportReason) { return undefined; } +export function buildSendMessageAction(action: ApiSendMessageAction) { + switch (action.type) { + case 'cancel': + return new GramJs.SendMessageCancelAction(); + case 'typing': + return new GramJs.SendMessageTypingAction(); + case 'recordAudio': + return new GramJs.SendMessageRecordAudioAction(); + case 'chooseSticker': + return new GramJs.SendMessageChooseStickerAction(); + } + return undefined; +} + export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { // Workaround for old-fashioned IDs stored locally if (typeof id === 'number') { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 6d6df177d..1cdd0e34d 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -22,7 +22,7 @@ export { markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal, fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, - reportMessages, fetchSeenBy, + reportMessages, sendMessageAction, fetchSeenBy, } from './messages'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 6887c3b9c..72036207e 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -16,6 +16,7 @@ import { MESSAGE_DELETED, ApiGlobalMessageSearchType, ApiReportReason, + ApiSendMessageAction, } from '../../types'; import { @@ -44,6 +45,7 @@ import { isMessageWithMedia, isServiceMessageWithMedia, buildInputReportReason, + buildSendMessageAction, } from '../gramjsBuilders'; import localDb from '../localDb'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; @@ -662,6 +664,28 @@ export async function reportMessages({ return result; } +export async function sendMessageAction({ + peer, threadId, action, +}: { + peer: ApiChat | ApiUser; threadId?: number; action: ApiSendMessageAction; +}) { + const gramAction = buildSendMessageAction(action); + if (!gramAction) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn('Unsupported message action', action); + } + return undefined; + } + + const result = await invokeRequest(new GramJs.messages.SetTyping({ + peer: buildInputPeer(peer.id, peer.accessHash), + topMsgId: threadId, + action: gramAction, + })); + return result; +} + export async function markMessageListRead({ chat, threadId, maxId, serverTimeOffset, }: { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index e4de2e341..983c8812a 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -301,6 +301,10 @@ export type ApiGlobalMessageSearchType = 'text' | 'media' | 'documents' | 'links export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse' | 'copyright' | 'geoIrrelevant' | 'fake' | 'other'; +export type ApiSendMessageAction = { + type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker'; +}; + export const MAIN_THREAD_ID = -1; // `Symbol` can not be transferred from worker diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 82303e6fa..44b05a14b 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -22,7 +22,7 @@ import { import { InlineBotSettings } from '../../../types'; import { - BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, REPLIES_USER_ID, SCHEDULED_WHEN_ONLINE, + BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, REPLIES_USER_ID, SCHEDULED_WHEN_ONLINE, SEND_MESSAGE_ACTION_INTERVAL, } from '../../../config'; import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment'; import { @@ -71,6 +71,8 @@ import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useLang from '../../../hooks/useLang'; import useInlineBotTooltip from './hooks/useInlineBotTooltip'; import useBotCommandTooltip from './hooks/useBotCommandTooltip'; +import useSendMessageAction from '../../../hooks/useSendMessageAction'; +import useInterval from '../../../hooks/useInterval'; import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import Button from '../../ui/Button'; @@ -225,6 +227,7 @@ const Composer: FC = ({ scheduledMessageArgs, setScheduledMessageArgs, ] = useState(); const { width: windowWidth } = windowSize.get(); + const sendMessageAction = useSendMessageAction(chatId, threadId); // Cache for frequently updated state const htmlRef = useRef(html); @@ -275,6 +278,16 @@ const Composer: FC = ({ startRecordTimeRef, } = useVoiceRecording(); + useInterval(() => { + sendMessageAction({ type: 'recordAudio' }); + }, activeVoiceRecording && SEND_MESSAGE_ACTION_INTERVAL); + + useEffect(() => { + if (!activeVoiceRecording) { + sendMessageAction({ type: 'cancel' }); + } + }, [activeVoiceRecording, sendMessageAction]); + const mainButtonState = editingMessage ? MainButtonState.Edit : !IS_VOICE_RECORDING_SUPPORTED || activeVoiceRecording || (html && !attachments.length) || isForwarding @@ -952,6 +965,8 @@ const Composer: FC = ({ )} @@ -984,6 +999,8 @@ const Composer: FC = ({ /> )} = ({ id, chatId, + threadId, isAttachmentModalInput, editableInputId, html, @@ -84,12 +85,12 @@ const MessageInput: FC = ({ canAutoFocus, shouldSuppressFocus, shouldSuppressTextFormatter, - onUpdate, - onSuppressedFocus, - onSend, replyingToId, noTabCapture, messageSendKeyCombo, + onUpdate, + onSuppressedFocus, + onSend, }) => { const { editLastMessage, @@ -107,6 +108,8 @@ const MessageInput: FC = ({ const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState(); const [selectedRange, setSelectedRange] = useState(); + const sendMessageAction = useSendMessageAction(chatId, threadId); + useEffect(() => { if (!isAttachmentModalInput) return; updateInputHeight(false); @@ -280,6 +283,7 @@ const MessageInput: FC = ({ const { innerHTML, textContent } = e.currentTarget; onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML); + sendMessageAction({ type: 'typing' }); // Reset focus on the input to remove any active styling when input is cleared if ( diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index 30b2b2dc0..3e1b6f727 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -16,6 +16,7 @@ import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useLang from '../../../hooks/useLang'; +import useSendMessageAction from '../../../hooks/useSendMessageAction'; import Loading from '../../ui/Loading'; import Button from '../../ui/Button'; @@ -27,6 +28,8 @@ import StickerSetCoverAnimated from './StickerSetCoverAnimated'; import './StickerPicker.scss'; type OwnProps = { + chatId: string; + threadId?: number; className: string; loadAndPlay: boolean; canSendStickers: boolean; @@ -48,6 +51,8 @@ const STICKER_INTERSECTION_THROTTLE = 200; const stickerSetIntersections: boolean[] = []; const StickerPicker: FC = ({ + chatId, + threadId, className, loadAndPlay, canSendStickers, @@ -72,6 +77,7 @@ const StickerPicker: FC = ({ // eslint-disable-next-line no-null/no-null const headerRef = useRef(null); const [activeSetIndex, setActiveSetIndex] = useState(0); + const sendMessageAction = useSendMessageAction(chatId, threadId); const { observe: observeIntersection } = useIntersectionObserver({ rootRef: containerRef, @@ -135,8 +141,9 @@ const StickerPicker: FC = ({ loadStickerSets(); loadRecentStickers(); loadFavoriteStickers(); + sendMessageAction({ type: 'chooseSticker' }); } - }, [loadAndPlay, loadFavoriteStickers, loadRecentStickers, loadStickerSets]); + }, [loadAndPlay, loadFavoriteStickers, loadRecentStickers, loadStickerSets, sendMessageAction]); useEffect(() => { if (addedSetIds?.length) { @@ -177,6 +184,10 @@ const StickerPicker: FC = ({ unfaveSticker({ sticker }); }, [unfaveSticker]); + const handleMouseMove = useCallback(() => { + sendMessageAction({ type: 'chooseSticker' }); + }, [sendMessageAction]); + const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION); function renderCover(stickerSet: StickerSetOrRecent, index: number) { @@ -256,6 +267,7 @@ const StickerPicker: FC = ({
{allSets.map((stickerSet, i) => ( diff --git a/src/components/middle/composer/StickerTooltip.tsx b/src/components/middle/composer/StickerTooltip.tsx index c442254ca..8482c0f2b 100644 --- a/src/components/middle/composer/StickerTooltip.tsx +++ b/src/components/middle/composer/StickerTooltip.tsx @@ -12,6 +12,7 @@ import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useShowTransition from '../../../hooks/useShowTransition'; import usePrevious from '../../../hooks/usePrevious'; +import useSendMessageAction from '../../../hooks/useSendMessageAction'; import Loading from '../../ui/Loading'; import StickerButton from '../../common/StickerButton'; @@ -19,6 +20,8 @@ import StickerButton from '../../common/StickerButton'; import './StickerTooltip.scss'; export type OwnProps = { + chatId: string; + threadId?: number; isOpen: boolean; onStickerSelect: (sticker: ApiSticker) => void; }; @@ -30,6 +33,8 @@ type StateProps = { const INTERSECTION_THROTTLE = 200; const StickerTooltip: FC = ({ + chatId, + threadId, isOpen, onStickerSelect, stickers, @@ -41,6 +46,7 @@ const StickerTooltip: FC = ({ const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); const prevStickers = usePrevious(stickers, true); const displayedStickers = stickers || prevStickers; + const sendMessageAction = useSendMessageAction(chatId, threadId); const { observe: observeIntersection, @@ -52,6 +58,10 @@ const StickerTooltip: FC = ({ document.body.classList.add('no-select'); }; + const handleMouseMove = () => { + sendMessageAction({ type: 'chooseSticker' }); + }; + const handleMouseLeave = () => { document.body.classList.remove('no-select'); }; @@ -68,6 +78,7 @@ const StickerTooltip: FC = ({ className={className} onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined} onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} + onMouseMove={handleMouseMove} > {shouldRender && displayedStickers ? ( displayedStickers.map((sticker) => ( diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 78a70e775..32aea8007 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -27,6 +27,8 @@ import './SymbolMenu.scss'; const ANIMATION_DURATION = 350; export type OwnProps = { + chatId: string; + threadId?: number; isOpen: boolean; allowedAttachmentOptions: IAllowedAttachmentOptions; onLoad: () => void; @@ -46,10 +48,19 @@ type StateProps = { let isActivated = false; const SymbolMenu: FC = ({ - isOpen, allowedAttachmentOptions, isLeftColumnShown, - onLoad, onClose, - onEmojiSelect, onStickerSelect, onGifSelect, - onRemoveSymbol, onSearchOpen, addRecentEmoji, + chatId, + threadId, + isOpen, + allowedAttachmentOptions, + isLeftColumnShown, + onLoad, + onClose, + onEmojiSelect, + onStickerSelect, + onGifSelect, + onRemoveSymbol, + onSearchOpen, + addRecentEmoji, }) => { const [activeTab, setActiveTab] = useState(0); const [recentEmojis, setRecentEmojis] = useState([]); @@ -138,6 +149,8 @@ const SymbolMenu: FC = ({ loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false} canSendStickers={canSendStickers} onStickerSelect={handleStickerSelect} + chatId={chatId} + threadId={threadId} /> ); case SymbolMenuTabs.GIFs: diff --git a/src/components/middle/composer/hooks/useVoiceRecording.ts b/src/components/middle/composer/hooks/useVoiceRecording.ts index ff3534bb2..ab56a76fb 100644 --- a/src/components/middle/composer/hooks/useVoiceRecording.ts +++ b/src/components/middle/composer/hooks/useVoiceRecording.ts @@ -50,7 +50,6 @@ export default () => { if (recordButtonRef.current) { recordButtonRef.current.style.boxShadow = 'none'; } - try { return activeVoiceRecording!.pause(); } catch (err) { diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index d3ca0b5e9..436a23e0d 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -1,5 +1,5 @@ import React, { - FC, memo, useCallback, useMemo, + FC, memo, useMemo, } from '../../../lib/teact/teact'; import { ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types'; diff --git a/src/config.ts b/src/config.ts index d40f1dcab..fe1bddf61 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,7 @@ export const IOS_DEFAULT_MESSAGE_TEXT_SIZE_PX = 17; export const MACOS_DEFAULT_MESSAGE_TEXT_SIZE_PX = 15; export const DRAFT_DEBOUNCE = 10000; // 10s +export const SEND_MESSAGE_ACTION_INTERVAL = 3000; // 3s export const EDITABLE_INPUT_ID = 'editable-message-text'; export const EDITABLE_INPUT_MODAL_ID = 'editable-message-text-modal'; diff --git a/src/global/types.ts b/src/global/types.ts index 940511b85..b275d85ca 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -502,7 +502,7 @@ export type ActionTypes = ( 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | - 'reportMessages' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' | + 'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' | // downloads 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts new file mode 100644 index 000000000..e85f41bab --- /dev/null +++ b/src/hooks/useInterval.ts @@ -0,0 +1,22 @@ +import { useEffect, useLayoutEffect, useRef } from '../lib/teact/teact'; + +function useInterval(callback: NoneToVoidFunction, delay?: number, noFirst = false) { + const savedCallback = useRef(callback); + + useLayoutEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + if (delay === undefined) { + return undefined; + } + + const id = setInterval(() => savedCallback.current(), delay); + if (!noFirst) savedCallback.current(); + + return () => clearInterval(id); + }, [delay, noFirst]); +} + +export default useInterval; diff --git a/src/hooks/useSendMessageAction.ts b/src/hooks/useSendMessageAction.ts new file mode 100644 index 000000000..41afca970 --- /dev/null +++ b/src/hooks/useSendMessageAction.ts @@ -0,0 +1,15 @@ +import { useMemo } from '../lib/teact/teact'; +import { getDispatch } from '../lib/teact/teactn'; + +import { ApiSendMessageAction } from '../api/types'; + +import { SEND_MESSAGE_ACTION_INTERVAL } from '../config'; +import { throttle } from '../util/schedulers'; + +export default (chatId: string, threadId?: number) => { + return useMemo(() => { + return throttle((action: ApiSendMessageAction) => { + getDispatch().sendMessageAction({ chatId, threadId, action }); + }, SEND_MESSAGE_ACTION_INTERVAL); + }, [chatId, threadId]); +}; diff --git a/src/modules/actions/api/messages.ts b/src/modules/actions/api/messages.ts index f2574345d..544ab6d51 100644 --- a/src/modules/actions/api/messages.ts +++ b/src/modules/actions/api/messages.ts @@ -459,6 +459,20 @@ addReducer('reportMessages', (global, actions, payload) => { })(); }); +addReducer('sendMessageAction', (global, actions, payload) => { + (async () => { + const { action, chatId, threadId } = payload!; + if (chatId === global.currentUserId) return; // Message actions are disabled in Saved Messages + + const chat = selectChat(global, chatId)!; + if (!chat) return; + + await callApi('sendMessageAction', { + peer: chat, threadId, action, + }); + })(); +}); + addReducer('markMessageListRead', (global, actions, payload) => { const { serverTimeOffset } = global; const currentMessageList = selectCurrentMessageList(global);