Send various "typing"-like statuses (#1597)

This commit is contained in:
Alexander Zinchuk 2021-12-31 18:17:32 +01:00
parent ffa5697887
commit cb4749f7c6
16 changed files with 166 additions and 15 deletions

View File

@ -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') {

View File

@ -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 {

View File

@ -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,
}: {

View File

@ -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

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,
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}

View File

@ -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 (

View File

@ -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) => (

View File

@ -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) => (

View File

@ -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:

View File

@ -50,7 +50,6 @@ export default () => {
if (recordButtonRef.current) {
recordButtonRef.current.style.boxShadow = 'none';
}
try {
return activeVoiceRecording!.pause();
} catch (err) {

View File

@ -1,5 +1,5 @@
import React, {
FC, memo, useCallback, useMemo,
FC, memo, useMemo,
} from '../../../lib/teact/teact';
import { ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';

View File

@ -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';

View File

@ -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
View 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;

View 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]);
};

View File

@ -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);