Send various "typing"-like statuses (#1597)
This commit is contained in:
parent
ffa5697887
commit
cb4749f7c6
@ -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') {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
scheduledMessageArgs, setScheduledMessageArgs,
|
||||
] = useState<GlobalState['messages']['contentToBeScheduled'] | undefined>();
|
||||
const { width: windowWidth } = windowSize.get();
|
||||
const sendMessageAction = useSendMessageAction(chatId, threadId);
|
||||
|
||||
// Cache for frequently updated state
|
||||
const htmlRef = useRef<string>(html);
|
||||
@ -275,6 +278,16 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
</span>
|
||||
)}
|
||||
<StickerTooltip
|
||||
chatId={chatId}
|
||||
threadId={threadId}
|
||||
isOpen={isStickerTooltipOpen}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
/>
|
||||
@ -984,6 +999,8 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
/>
|
||||
)}
|
||||
<SymbolMenu
|
||||
chatId={chatId}
|
||||
threadId={threadId}
|
||||
isOpen={isSymbolMenuOpen}
|
||||
allowedAttachmentOptions={allowedAttachmentOptions}
|
||||
onLoad={onSymbolMenuLoadingComplete}
|
||||
|
||||
@ -18,9 +18,10 @@ import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
|
||||
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
|
||||
import useSendMessageAction from '../../../hooks/useSendMessageAction';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import parseEmojiOnlyString from '../../common/helpers/parseEmojiOnlyString';
|
||||
import { isSelectionInsideInput } from './helpers/selection';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import renderText from '../../common/helpers/renderText';
|
||||
|
||||
import TextFormatter from './TextFormatter';
|
||||
@ -48,7 +49,6 @@ type OwnProps = {
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
chatId?: string;
|
||||
replyingToId?: number;
|
||||
noTabCapture?: boolean;
|
||||
messageSendKeyCombo?: ISettings['messageSendKeyCombo'];
|
||||
@ -76,6 +76,7 @@ function clearSelection() {
|
||||
const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
id,
|
||||
chatId,
|
||||
threadId,
|
||||
isAttachmentModalInput,
|
||||
editableInputId,
|
||||
html,
|
||||
@ -84,12 +85,12 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
canAutoFocus,
|
||||
shouldSuppressFocus,
|
||||
shouldSuppressTextFormatter,
|
||||
onUpdate,
|
||||
onSuppressedFocus,
|
||||
onSend,
|
||||
replyingToId,
|
||||
noTabCapture,
|
||||
messageSendKeyCombo,
|
||||
onUpdate,
|
||||
onSuppressedFocus,
|
||||
onSend,
|
||||
}) => {
|
||||
const {
|
||||
editLastMessage,
|
||||
@ -107,6 +108,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState<IAnchorPosition>();
|
||||
const [selectedRange, setSelectedRange] = useState<Range>();
|
||||
|
||||
const sendMessageAction = useSendMessageAction(chatId, threadId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAttachmentModalInput) return;
|
||||
updateInputHeight(false);
|
||||
@ -280,6 +283,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
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 (
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
chatId,
|
||||
threadId,
|
||||
className,
|
||||
loadAndPlay,
|
||||
canSendStickers,
|
||||
@ -72,6 +77,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSetIndex, setActiveSetIndex] = useState<number>(0);
|
||||
const sendMessageAction = useSendMessageAction(chatId, threadId);
|
||||
|
||||
const { observe: observeIntersection } = useIntersectionObserver({
|
||||
rootRef: containerRef,
|
||||
@ -135,8 +141,9 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
className={buildClassName('StickerPicker-main no-selection', IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll')}
|
||||
>
|
||||
{allSets.map((stickerSet, i) => (
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
chatId,
|
||||
threadId,
|
||||
isOpen,
|
||||
onStickerSelect,
|
||||
stickers,
|
||||
@ -41,6 +46,7 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
className={className}
|
||||
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
|
||||
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{shouldRender && displayedStickers ? (
|
||||
displayedStickers.map((sticker) => (
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
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<number>(0);
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||
@ -138,6 +149,8 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false}
|
||||
canSendStickers={canSendStickers}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
chatId={chatId}
|
||||
threadId={threadId}
|
||||
/>
|
||||
);
|
||||
case SymbolMenuTabs.GIFs:
|
||||
|
||||
@ -50,7 +50,6 @@ export default () => {
|
||||
if (recordButtonRef.current) {
|
||||
recordButtonRef.current.style.boxShadow = 'none';
|
||||
}
|
||||
|
||||
try {
|
||||
return activeVoiceRecording!.pause();
|
||||
} catch (err) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useMemo,
|
||||
FC, memo, useMemo,
|
||||
} from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
22
src/hooks/useInterval.ts
Normal file
22
src/hooks/useInterval.ts
Normal file
@ -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;
|
||||
15
src/hooks/useSendMessageAction.ts
Normal file
15
src/hooks/useSendMessageAction.ts
Normal file
@ -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]);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user