diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index ca1b62e91..9c83871e9 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -22,12 +22,13 @@ import { type ApiSponsoredPeer, type ApiStarsSubscriptionPricing, type ApiThreadInfo, + type ApiTypingStatus, MAIN_THREAD_ID, } from '../../types'; import { omitUndefined, pickTruthy } from '../../../util/iteratees'; import { toJSNumber } from '../../../util/numbers'; -import { getServerTimeOffset } from '../../../util/serverTime'; +import { getServerTime } from '../../../util/serverTime'; import { addPhotoToLocalDb, addUserToLocalDb } from '../helpers/localDb'; import { serializeBytes } from '../helpers/misc'; import { @@ -382,52 +383,73 @@ export function buildChatMembers( export function buildChatTypingStatus( update: GramJs.UpdateUserTyping | GramJs.UpdateChatUserTyping | GramJs.UpdateChannelUserTyping, -) { - let action: string = ''; - let emoticon: string | undefined; - if (update.action instanceof GramJs.SendMessageCancelAction) { +): ApiTypingStatus | undefined { + const action = update.action; + const timestamp = getServerTime(); + const buildTypingStatus = ( + type: Exclude, + ): ApiTypingStatus => ({ + timestamp, + type, + }); + + if (action instanceof GramJs.SendMessageCancelAction) { return undefined; - } else if (update.action instanceof GramJs.SendMessageTypingAction) { - action = 'lng_user_typing'; - } else if (update.action instanceof GramJs.SendMessageRecordVideoAction) { - action = 'lng_send_action_record_video'; - } else if (update.action instanceof GramJs.SendMessageUploadVideoAction) { - action = 'lng_send_action_upload_video'; - } else if (update.action instanceof GramJs.SendMessageRecordAudioAction) { - action = 'lng_send_action_record_audio'; - } else if (update.action instanceof GramJs.SendMessageUploadAudioAction) { - action = 'lng_send_action_upload_audio'; - } else if (update.action instanceof GramJs.SendMessageUploadPhotoAction) { - action = 'lng_send_action_upload_photo'; - } else if (update.action instanceof GramJs.SendMessageUploadDocumentAction) { - action = 'lng_send_action_upload_file'; - } else if (update.action instanceof GramJs.SendMessageGeoLocationAction) { - action = 'selecting a location to share'; - } else if (update.action instanceof GramJs.SendMessageChooseContactAction) { - action = 'selecting a contact to share'; - } else if (update.action instanceof GramJs.SendMessageGamePlayAction) { - action = 'lng_playing_game'; - } else if (update.action instanceof GramJs.SendMessageRecordRoundAction) { - action = 'lng_send_action_record_round'; - } else if (update.action instanceof GramJs.SendMessageUploadRoundAction) { - action = 'lng_send_action_upload_round'; - } else if (update.action instanceof GramJs.SendMessageChooseStickerAction) { - action = 'lng_send_action_choose_sticker'; - } else if (update.action instanceof GramJs.SpeakingInGroupCallAction) { + } + if (action instanceof GramJs.SendMessageTypingAction) { + return buildTypingStatus('typing'); + } + if (action instanceof GramJs.SendMessageRecordVideoAction) { + return buildTypingStatus('recordVideo'); + } + if (action instanceof GramJs.SendMessageUploadVideoAction) { + return buildTypingStatus('uploadVideo'); + } + if (action instanceof GramJs.SendMessageRecordAudioAction) { + return buildTypingStatus('recordAudio'); + } + if (action instanceof GramJs.SendMessageUploadAudioAction) { + return buildTypingStatus('uploadAudio'); + } + if (action instanceof GramJs.SendMessageUploadPhotoAction) { + return buildTypingStatus('uploadPhoto'); + } + if (action instanceof GramJs.SendMessageUploadDocumentAction) { + return buildTypingStatus('uploadFile'); + } + if (action instanceof GramJs.SendMessageGeoLocationAction) { + return buildTypingStatus('chooseLocation'); + } + if (action instanceof GramJs.SendMessageChooseContactAction) { + return buildTypingStatus('chooseContact'); + } + if (action instanceof GramJs.SendMessageGamePlayAction) { + return buildTypingStatus('playingGame'); + } + if (action instanceof GramJs.SendMessageRecordRoundAction) { + return buildTypingStatus('recordRound'); + } + if (action instanceof GramJs.SendMessageUploadRoundAction) { + return buildTypingStatus('uploadRound'); + } + if (action instanceof GramJs.SendMessageChooseStickerAction) { + return buildTypingStatus('chooseSticker'); + } + if (action instanceof GramJs.SpeakingInGroupCallAction) { return undefined; - } else if (update.action instanceof GramJs.SendMessageEmojiInteractionSeen) { - action = 'lng_user_action_watching_animations'; - emoticon = update.action.emoticon; - } else if (update.action instanceof GramJs.SendMessageEmojiInteraction) { + } + if (action instanceof GramJs.SendMessageEmojiInteractionSeen) { + return { + timestamp, + type: 'watchingAnimations', + emoji: action.emoticon, + }; + } + if (action instanceof GramJs.SendMessageEmojiInteraction) { return undefined; } - return { - action, - ...(emoticon && { emoji: emoticon }), - ...(!(update instanceof GramJs.UpdateUserTyping) && { userId: getApiChatIdFromMtpPeer(update.fromId) }), - timestamp: Date.now() + getServerTimeOffset() * 1000, - }; + return undefined; } export function buildApiChatFolder(filter: GramJs.DialogFilter | GramJs.DialogFilterChatlist): ApiChatFolder { diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index a74529cc4..d9d7891ec 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -677,6 +677,9 @@ export function updater(update: Update) { const chatId = update instanceof GramJs.UpdateUserTyping ? buildApiPeerId(update.userId, 'user') : buildApiPeerId(update.chatId, 'chat'); + const peerId = update instanceof GramJs.UpdateUserTyping + ? buildApiPeerId(update.userId, 'user') + : getApiChatIdFromMtpPeer(update.fromId); const threadId = update instanceof GramJs.UpdateUserTyping ? update.topMsgId : undefined; @@ -700,16 +703,19 @@ export function updater(update: Update) { sendApiUpdate({ '@type': 'updateChatTypingStatus', id: chatId, + peerId, threadId, typingStatus: buildChatTypingStatus(update), }); } } else if (update instanceof GramJs.UpdateChannelUserTyping) { const id = buildApiPeerId(update.channelId, 'channel'); + const peerId = getApiChatIdFromMtpPeer(update.fromId); sendApiUpdate({ '@type': 'updateChatTypingStatus', id, + peerId, threadId: update.topMsgId, typingStatus: buildChatTypingStatus(update), }); diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 04f37a7d9..02dfa9ca9 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -100,12 +100,22 @@ export interface ApiChat { paidMessagesStars?: number; } -export interface ApiTypingStatus { - userId?: string; - action: string; +type ApiTypingStatusBase = { timestamp: number; - emoji?: string; -} +}; + +type ApiTypingStatusSimple = ApiTypingStatusBase & { + type: 'typing' | 'recordVideo' | 'uploadVideo' | 'recordAudio' | 'uploadAudio' + | 'uploadPhoto' | 'uploadFile' | 'playingGame' | 'recordRound' | 'uploadRound' + | 'chooseSticker' | 'chooseLocation' | 'chooseContact'; +}; + +type ApiTypingStatusWatchingAnimations = ApiTypingStatusBase & { + type: 'watchingAnimations'; + emoji: string; +}; + +export type ApiTypingStatus = ApiTypingStatusSimple | ApiTypingStatusWatchingAnimations; export interface ApiChatFullInfo { about?: string; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 3944c2303..f5703ad90 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -145,6 +145,7 @@ export type ApiUpdateChatLeave = { export type ApiUpdateChatTypingStatus = { '@type': 'updateChatTypingStatus'; id: string; + peerId: string; threadId?: ThreadId; typingStatus: ApiTypingStatus | undefined; }; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 08d966c50..95e260671 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -15,18 +15,32 @@ "AccDescrGroup" = "Group"; "AccDescrChannel" = "Channel"; "Nothing" = "Nothing"; +"Typing" = "typing"; "UserTyping" = "{user} is typing"; +"UserTypingSeveral" = "{users} are typing"; +"UserTypingMany_one" = "{user} and {count} more is typing"; +"UserTypingMany_other" = "{user} and {count} more are typing"; "SendActionRecordVideo" = "recording a video"; +"UserActionRecordVideo" = "{user} is recording a video"; "SendActionUploadVideo" = "sending a video"; +"UserActionUploadVideo" = "{user} is sending a video"; "SendActionRecordAudio" = "recording a voice message"; +"UserActionRecordAudio" = "{user} is recording a voice message"; "SendActionUploadAudio" = "sending a voice message"; +"UserActionUploadAudio" = "{user} is sending a voice message"; "SendActionUploadPhoto" = "sending a photo"; +"UserActionUploadPhoto" = "{user} is sending a photo"; "SendActionUploadFile" = "sending a file"; +"UserActionUploadFile" = "{user} is sending a file"; "PlayingGame" = "playing a game"; +"UserPlayingGame" = "{user} is playing a game"; "SendActionRecordRound" = "recording a video message"; +"UserActionRecordRound" = "{user} is recording a video message"; "SendActionUploadRound" = "sending a video message"; -"SendActionChooseSticker" = "choosing a sticker"; -"UserActionWatchingAnimations" = "watching {emoji}"; +"UserActionUploadRound" = "{user} is sending a video message"; +"SendActionChooseSticker" = "ch{eyes}sing a sticker"; +"UserActionChooseSticker" = "{user} is ch{eyes}sing a sticker"; +"ActionWatchingAnimations" = "watching {emoji}"; "SetUrlAvailable" = "{url} is available."; "SetUrlInUse" = "Sorry, this link is already taken."; "UsernameAvailable" = "{username} is available."; diff --git a/src/assets/tgs/message/Eyes.tgs b/src/assets/tgs/message/Eyes.tgs new file mode 100644 index 000000000..2f3537c78 Binary files /dev/null and b/src/assets/tgs/message/Eyes.tgs differ diff --git a/src/assets/tgs/message/Typing.tgs b/src/assets/tgs/message/Typing.tgs index f63b6e9a0..3780d37eb 100644 Binary files a/src/assets/tgs/message/Typing.tgs and b/src/assets/tgs/message/Typing.tgs differ diff --git a/src/assets/tgs/message/Writing.tgs b/src/assets/tgs/message/Writing.tgs new file mode 100644 index 000000000..f63b6e9a0 Binary files /dev/null and b/src/assets/tgs/message/Writing.tgs differ diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index 8b90d4bb8..c992771f4 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -45,7 +45,7 @@ type OwnProps = { threadId?: ThreadId; className?: string; statusIcon?: IconName; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; status?: string; withDots?: boolean; @@ -78,7 +78,7 @@ type StateProps = { }; const GroupChatInfo = ({ - typingStatus, + typingStatusByPeerId, className, statusIcon, avatarSize = 'medium', @@ -184,8 +184,8 @@ const GroupChatInfo = ({ return undefined; } - if (typingStatus) { - return ; + if (typingStatusByPeerId) { + return ; } if (isTopic) { diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index 28a500602..fd0b4ae2c 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -41,7 +41,7 @@ import TypingStatus from './TypingStatus'; const TOPIC_ICON_SIZE = 2.5 * REM; type BaseOwnProps = { - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; forceShowSelf?: boolean; status?: string; @@ -97,7 +97,7 @@ const UPDATE_INTERVAL = 1000 * 60; // 1 min const PrivateChatInfo = ({ userId, customPeer, - typingStatus, + typingStatusByPeerId, avatarSize = 'medium', status, statusIcon, @@ -204,8 +204,8 @@ const PrivateChatInfo = ({ return undefined; } - if (typingStatus) { - return ; + if (typingStatusByPeerId) { + return ; } if (isTopic) { diff --git a/src/components/common/TypingStatus.module.scss b/src/components/common/TypingStatus.module.scss new file mode 100644 index 000000000..8a6fea499 --- /dev/null +++ b/src/components/common/TypingStatus.module.scss @@ -0,0 +1,20 @@ +.typingStatus { + display: flex; + gap: 0.125rem; + align-items: center; + color: var(--color-primary); +} + +.typingIcon { + margin-top: -2px; +} + +.eyesIcon { + display: inline-block !important; + margin-inline: -0.0625rem; + vertical-align: middle; +} + +.content { + display: inline; +} diff --git a/src/components/common/TypingStatus.scss b/src/components/common/TypingStatus.scss deleted file mode 100644 index 98d1c804f..000000000 --- a/src/components/common/TypingStatus.scss +++ /dev/null @@ -1,11 +0,0 @@ -.typing-status { - display: flex; - align-items: baseline; - - .sender-name { - &::after { - content: '\00a0'; - color: var(--color-text-secondary); - } - } -} diff --git a/src/components/common/TypingStatus.tsx b/src/components/common/TypingStatus.tsx index 581ae761a..de6c3c657 100644 --- a/src/components/common/TypingStatus.tsx +++ b/src/components/common/TypingStatus.tsx @@ -1,53 +1,205 @@ -import type { FC } from '../../lib/teact/teact'; -import { memo } from '../../lib/teact/teact'; -import { withGlobal } from '../../global'; +import type { TeactNode } from '../../lib/teact/teact'; +import { memo, useCallback, useMemo } from '../../lib/teact/teact'; -import type { ApiTypingStatus, ApiUser } from '../../api/types'; +import type { ApiTypingStatus } from '../../api/types'; +import type { GlobalState } from '../../global/types'; +import type { LangFn } from '../../util/localization'; -import { getUserFirstOrLastName } from '../../global/helpers'; -import { selectUser } from '../../global/selectors'; -import renderText from './helpers/renderText'; +import { getPeerTitle } from '../../global/helpers/peers'; +import { selectPeer } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import { LOCAL_TGS_URLS } from './helpers/animatedAssets'; +import { REM } from './helpers/mediaDimensions'; -import useOldLang from '../../hooks/useOldLang'; +import { useShallowSelector } from '../../hooks/data/useSelector'; +import useLang from '../../hooks/useLang'; -import DotAnimation from './DotAnimation'; +import AnimatedIconWithPreview from './AnimatedIconWithPreview'; -import './TypingStatus.scss'; +import styles from './TypingStatus.module.scss'; type OwnProps = { - typingStatus: ApiTypingStatus; + typingStatusByPeerId: Record; + isPrivate?: boolean; }; -type StateProps = { - typingUser?: ApiUser; -}; +const ICON_SIZE = 1.125 * REM; +const EYES_ICON_SIZE = 1.25 * REM; -const TypingStatus: FC = ({ typingStatus, typingUser }) => { - const lang = useOldLang(); - const typingUserName = typingUser && !typingUser.isSelf && getUserFirstOrLastName(typingUser); - const content = lang(typingStatus.action) - // Fix for translation "{user} is typing" - .replace('{user}', '') - .replace('{emoji}', typingStatus.emoji || '').trim(); +const TypingStatus = ({ typingStatusByPeerId, isPrivate }: OwnProps) => { + const lang = useLang(); + const actionFallbackUser = lang('ActionFallbackUser'); + const typingPeerIds = useMemo(() => Object.keys(typingStatusByPeerId), [typingStatusByPeerId]); + + const sortedTypingStatuses = useMemo( + () => Object.entries(typingStatusByPeerId).sort(([, a], [, b]) => b.timestamp - a.timestamp), + [typingStatusByPeerId], + ); + + const latestTypingStatusEntry = sortedTypingStatuses[0]; + const latestPeerId = latestTypingStatusEntry?.[0]; + const latestTypingStatus = latestTypingStatusEntry?.[1]; + const shouldRenderGroupedTyping = typingPeerIds.length >= 2; + + const groupedPeersSelector = useCallback( + (global: GlobalState) => typingPeerIds.map((peerId) => selectPeer(global, peerId)), + [typingPeerIds], + ); + + const groupedPeers = useShallowSelector(groupedPeersSelector); + const latestPeerIndex = latestPeerId ? typingPeerIds.indexOf(latestPeerId) : -1; + const latestPeer = latestPeerIndex >= 0 ? groupedPeers[latestPeerIndex] : undefined; + + const latestUserName = getTypingPeerName(lang, latestPeer, actionFallbackUser); + const groupedUserNames = useMemo( + () => typingPeerIds + .map((peerId, index) => ({ + peerId, + name: getTypingPeerName(lang, groupedPeers[index], actionFallbackUser), + })) + .sort(compareTypingPeerNames) + .map(({ name }) => name), + [actionFallbackUser, groupedPeers, typingPeerIds, lang], + ); + + if (!latestTypingStatus) { + return undefined; + } + + const user = latestUserName; + let content: string | TeactNode; + + if (shouldRenderGroupedTyping) { + if (sortedTypingStatuses.length === 2) { + content = lang('UserTypingSeveral', { + users: lang.conjunction([ + groupedUserNames[0] || actionFallbackUser, + groupedUserNames[1] || actionFallbackUser, + ]), + }, { withNodes: true }); + } else { + content = lang('UserTypingMany', { + user: groupedUserNames[0] || actionFallbackUser, + count: lang.number(sortedTypingStatuses.length - 1), + }, { withNodes: true, pluralValue: sortedTypingStatuses.length - 1 }); + } + } else if (isPrivate) { + content = getPrivateTypingStatusContent(lang, latestTypingStatus); + } else { + content = getGroupTypingStatusContent(lang, latestTypingStatus, user); + } + + const shouldRenderTypingStatusIcon = shouldRenderGroupedTyping || shouldRenderTypingIcon(latestTypingStatus); return ( -

- {typingUserName && ( - {renderText(typingUserName)} + + {shouldRenderTypingStatusIcon && ( + )} - -

+ {content} + ); }; -export default memo(withGlobal( - (global, { typingStatus }): Complete => { - if (!typingStatus.userId) { - return { typingUser: undefined }; - } +function renderEyesIcon() { + return ( + + ); +} - const typingUser = selectUser(global, typingStatus.userId); +function getPrivateTypingStatusContent(lang: LangFn, typingStatus: ApiTypingStatus) { + switch (typingStatus.type) { + case 'recordVideo': + return lang('SendActionRecordVideo'); + case 'uploadVideo': + return lang('SendActionUploadVideo'); + case 'recordAudio': + return lang('SendActionRecordAudio'); + case 'uploadAudio': + return lang('SendActionUploadAudio'); + case 'uploadPhoto': + return lang('SendActionUploadPhoto'); + case 'uploadFile': + return lang('SendActionUploadFile'); + case 'playingGame': + return lang('PlayingGame'); + case 'recordRound': + return lang('SendActionRecordRound'); + case 'uploadRound': + return lang('SendActionUploadRound'); + case 'chooseSticker': + return lang('SendActionChooseSticker', { eyes: renderEyesIcon() }, { withNodes: true }); + case 'watchingAnimations': + return lang('ActionWatchingAnimations', { emoji: typingStatus.emoji }); + case 'typing': + case 'chooseLocation': + case 'chooseContact': + default: + return lang('Typing'); + } +} - return { typingUser }; - }, -)(TypingStatus)); +function getGroupTypingStatusContent(lang: LangFn, typingStatus: ApiTypingStatus, user: string) { + switch (typingStatus.type) { + case 'recordVideo': + return lang('UserActionRecordVideo', { user }, { withNodes: true }); + case 'uploadVideo': + return lang('UserActionUploadVideo', { user }, { withNodes: true }); + case 'recordAudio': + return lang('UserActionRecordAudio', { user }, { withNodes: true }); + case 'uploadAudio': + return lang('UserActionUploadAudio', { user }, { withNodes: true }); + case 'uploadPhoto': + return lang('UserActionUploadPhoto', { user }, { withNodes: true }); + case 'uploadFile': + return lang('UserActionUploadFile', { user }, { withNodes: true }); + case 'playingGame': + return lang('UserPlayingGame', { user }, { withNodes: true }); + case 'recordRound': + return lang('UserActionRecordRound', { user }, { withNodes: true }); + case 'uploadRound': + return lang('UserActionUploadRound', { user }, { withNodes: true }); + case 'chooseSticker': + return lang('UserActionChooseSticker', { user, eyes: renderEyesIcon() }, { withNodes: true }); + case 'chooseLocation': + case 'chooseContact': + case 'typing': + default: + return lang('UserTyping', { user }, { withNodes: true }); + } +} + +function shouldRenderTypingIcon(typingStatus: ApiTypingStatus) { + return typingStatus.type === 'typing' + || typingStatus.type === 'chooseLocation' + || typingStatus.type === 'chooseContact'; +} + +function getTypingPeerName(lang: LangFn, peer: ReturnType, fallback: string) { + const title = peer ? getPeerTitle(lang, peer) : undefined; + + return title || fallback; +} + +function compareTypingPeerNames( + a: { peerId: string; name: string }, + b: { peerId: string; name: string }, +) { + return a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId); +} + +export default memo(TypingStatus); diff --git a/src/components/common/TypingWrapper.tsx b/src/components/common/TypingWrapper.tsx index 56ee4928a..26252e791 100644 --- a/src/components/common/TypingWrapper.tsx +++ b/src/components/common/TypingWrapper.tsx @@ -209,7 +209,7 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp {renderText(truncatedText)} ; withInterfaceAnimations?: boolean; lastMessageId?: number; lastMessage?: ApiMessage; @@ -158,7 +158,7 @@ const Chat: FC = ({ canScrollDown, canChangeFolder, lastMessageTopic, - typingStatus, + typingStatusByPeerId, lastMessageId, lastMessage, isSavedDialog, @@ -231,7 +231,7 @@ const Chat: FC = ({ chat, chatId, lastMessage, - typingStatus, + typingStatusByPeerId, draft, statefulMediaContent: groupStatefulContent({ story: lastMessageStory }), lastMessageTopic, @@ -567,7 +567,7 @@ export default memo(withGlobal( const userStatus = selectUserStatus(global, chatId); const lastMessageTopic = lastMessage && selectTopicFromMessage(global, lastMessage); - const typingStatus = selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'typingStatus'); + const typingStatusByPeerId = selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'typingStatusByPeerId'); const topicsInfo = selectTopicsInfo(global, chatId); @@ -593,7 +593,7 @@ export default memo(withGlobal( user, userStatus, lastMessageTopic, - typingStatus, + typingStatusByPeerId, withInterfaceAnimations: selectCanAnimateInterface(global), lastMessage, lastMessageId, diff --git a/src/components/left/main/forum/Topic.tsx b/src/components/left/main/forum/Topic.tsx index 6f48e3b80..241162d1f 100644 --- a/src/components/left/main/forum/Topic.tsx +++ b/src/components/left/main/forum/Topic.tsx @@ -71,7 +71,7 @@ type StateProps = { lastMessageStory?: ApiTypeStory; lastMessageOutgoingStatus?: ApiMessageOutgoingStatus; lastMessageSender?: ApiPeer; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; draft?: ApiDraft; canScrollDown?: boolean; wasTopicOpened?: boolean; @@ -98,7 +98,7 @@ const Topic = ({ withInterfaceAnimations, orderDiff, shiftDiff, - typingStatus, + typingStatusByPeerId, draft, wasTopicOpened, topicIds, @@ -153,7 +153,7 @@ const Topic = ({ lastMessageTopic: topic, observeIntersection, isTopic: true, - typingStatus, + typingStatusByPeerId, topicIds, statefulMediaContent: groupStatefulContent({ story: lastMessageStory }), @@ -269,7 +269,7 @@ export default memo(withGlobal( ? selectChatMessage(global, chatId, threadInfo.lastMessageId) : undefined; const { isOutgoing } = lastMessage || {}; const lastMessageSender = lastMessage && selectSender(global, lastMessage); - const typingStatus = selectThreadLocalStateParam(global, chatId, topic.id, 'typingStatus'); + const typingStatusByPeerId = selectThreadLocalStateParam(global, chatId, topic.id, 'typingStatusByPeerId'); const draft = selectDraft(global, chatId, topic.id); const readState = selectThreadReadState(global, chatId, topic.id); @@ -289,7 +289,7 @@ export default memo(withGlobal( chat, lastMessage, lastMessageSender, - typingStatus, + typingStatusByPeerId, isChatMuted, canDelete: selectCanDeleteTopic(global, chatId, topic.id), withInterfaceAnimations: selectCanAnimateInterface(global), diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index 145f84b10..5e8293d5c 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -19,6 +19,7 @@ import { import { getMessageSenderName } from '../../../../global/helpers/peers'; import { waitStartingTransitionsEnd } from '../../../../util/animations/waitTransitionEnd'; import buildClassName from '../../../../util/buildClassName'; +import { isUserId } from '../../../../util/entities/ids'; import renderText from '../../../common/helpers/renderText'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import { ChatAnimationTypes } from './useChatAnimationType'; @@ -34,13 +35,23 @@ import Icon from '../../../common/icons/Icon'; import MessageSummary from '../../../common/MessageSummary'; import TypingStatus from '../../../common/TypingStatus'; +function getLatestTypingStatusTimestamp(typingStatusByPeerId?: Record) { + if (!typingStatusByPeerId) { + return undefined; + } + + const timestamps = Object.values(typingStatusByPeerId).map(({ timestamp }) => timestamp); + + return timestamps.length ? Math.max(...timestamps) : undefined; +} + export default function useChatListEntry({ chat, topicIds, lastMessage, statefulMediaContent, chatId, - typingStatus, + typingStatusByPeerId, draft, lastMessageTopic, lastMessageSender, @@ -61,7 +72,7 @@ export default function useChatListEntry({ lastMessage?: ApiMessage; statefulMediaContent: StatefulMediaContent | undefined; chatId: string; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; draft?: ApiDraft; lastMessageTopic?: ApiTopic; lastMessageSender?: ApiPeer; @@ -97,9 +108,14 @@ export default function useChatListEntry({ const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); const renderLastMessageOrTyping = useCallback(() => { + const latestTypingStatusTimestamp = getLatestTypingStatusTimestamp(typingStatusByPeerId); + if (!isSavedDialog && !isPreview - && typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) { - return ; + && typingStatusByPeerId && lastMessage + && latestTypingStatusTimestamp && latestTypingStatusTimestamp > lastMessage.date) { + return ( + + ); } const isDraftReplyToTopic = draft && draft.replyInfo?.replyToMsgId === lastMessageTopic?.id; @@ -149,7 +165,7 @@ export default function useChatListEntry({ ); }, [ chat, chatId, draft, isRoundVideo, isTopic, lang, lastMessage, lastMessageSender, lastMessageTopic, - mediaBlobUrl, mediaThumbnail, observeIntersection, typingStatus, isSavedDialog, isPreview, + mediaBlobUrl, mediaThumbnail, observeIntersection, typingStatusByPeerId, isSavedDialog, isPreview, ]); function renderSubtitle() { diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index 33b0b29cb..07bc383a1 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -185,19 +185,6 @@ .status, .typing-status { unicode-bidi: plaintext; - display: inline; - - @media (min-width: 1275px) { - #Main.right-column-open & { - max-width: calc(100% - var(--right-column-width)); - } - } - } - - .user-status { - unicode-bidi: plaintext; - overflow: hidden; - text-overflow: ellipsis; @media (min-width: 1275px) { #Main.right-column-open & { @@ -254,16 +241,21 @@ font-size: 1.0625rem; } + .status { + color: var(--color-text-secondary); + &.online { + color: var(--color-primary); + } + } + .status, .typing-status { overflow: hidden; - display: inline-block; margin: 0; font-size: 0.875rem; line-height: 1.125rem; - color: var(--color-text-secondary); text-overflow: ellipsis; white-space: nowrap; @@ -271,10 +263,6 @@ display: inline-flex; } - &.online { - color: var(--color-primary); - } - .font-emoji { line-height: 1rem; } diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index f2f52eb3a..e7478aada 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -75,7 +75,7 @@ type OwnProps = { type StateProps = { chat?: ApiChat; isSavedDialog?: boolean; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; isSelectModeActive?: boolean; isLeftColumnShown?: boolean; isRightColumnShown?: boolean; @@ -96,7 +96,7 @@ const MiddleHeader = ({ threadId, messageListType, isMobile, - typingStatus, + typingStatusByPeerId, isSelectModeActive, isLeftColumnShown, audioMessage, @@ -306,7 +306,7 @@ const MiddleHeader = ({ key={displayChatId} userId={displayChatId} threadId={!isSavedDialog ? threadId : undefined} - typingStatus={typingStatus} + typingStatusByPeerId={typingStatusByPeerId} status={connectionStatusText || savedMessagesStatus} withDots={Boolean(connectionStatusText)} withFullInfo={threadId === MAIN_THREAD_ID} @@ -324,7 +324,7 @@ const MiddleHeader = ({ key={displayChatId} chatId={displayChatId} threadId={!isSavedDialog ? threadId : undefined} - typingStatus={typingStatus} + typingStatusByPeerId={typingStatusByPeerId} withMonoforumStatus={chat?.isMonoforum} status={connectionStatusText || savedMessagesStatus} withDots={Boolean(connectionStatusText)} @@ -427,14 +427,14 @@ export default memo(withGlobal( messagesCount = selectThreadMessagesCount(global, chatId, threadId); } - const typingStatus = selectThreadLocalStateParam(global, chatId, threadId, 'typingStatus'); + const typingStatusByPeerId = selectThreadLocalStateParam(global, chatId, threadId, 'typingStatusByPeerId'); const emojiStatus = peer?.emojiStatus; const emojiStatusSticker = emojiStatus && selectCustomEmoji(global, emojiStatus.documentId); const emojiStatusSlug = emojiStatus?.type === 'collectible' ? emojiStatus.slug : undefined; return { - typingStatus, + typingStatusByPeerId, isLeftColumnShown, isRightColumnShown: selectIsRightColumnShown(global, isMobile), isSelectModeActive: selectIsInSelectMode(global), diff --git a/src/components/middle/composer/MentionTooltip.scss b/src/components/middle/composer/MentionTooltip.scss index 63a7b3156..cc1d01316 100644 --- a/src/components/middle/composer/MentionTooltip.scss +++ b/src/components/middle/composer/MentionTooltip.scss @@ -43,10 +43,6 @@ content: "@"; } } - - .user-status { - display: none !important; - } } @media (max-width: 600px) { diff --git a/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx index a520a4182..f2ae9722d 100644 --- a/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx +++ b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx @@ -36,7 +36,7 @@ type StateProps = { connectionState?: ApiUpdateConnectionStateType; isSyncing?: boolean; isFetchingDifference?: boolean; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; isSavedDialog?: boolean; messagesCount?: number; unreadCount?: number; @@ -52,7 +52,7 @@ const QuickPreviewModalHeader: FC = ({ connectionState, isSyncing, isFetchingDifference, - typingStatus, + typingStatusByPeerId, isSavedDialog, messagesCount, unreadCount, @@ -104,7 +104,7 @@ const QuickPreviewModalHeader: FC = ({ = ({ key={displayChatId} chatId={displayChatId} threadId={!isSavedDialog ? threadId : undefined} - typingStatus={typingStatus} + typingStatusByPeerId={typingStatusByPeerId} withMonoforumStatus={chat?.isMonoforum} status={connectionStatusText || savedMessagesStatus} withDots={Boolean(connectionStatusText)} @@ -142,7 +142,12 @@ const QuickPreviewModalHeader: FC = ({ export default memo(withGlobal( (global, { chatId, threadId }): Complete => { const chat = selectChat(global, chatId); - const typingStatus = selectThreadLocalStateParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus'); + const typingStatusByPeerId = selectThreadLocalStateParam( + global, + chatId, + threadId || MAIN_THREAD_ID, + 'typingStatusByPeerId', + ); const isSavedDialog = getIsSavedDialog(chatId, threadId || MAIN_THREAD_ID, global.currentUserId); const messagesCount = isSavedDialog && threadId ? selectThreadMessagesCount(global, chatId, threadId) @@ -155,7 +160,7 @@ export default memo(withGlobal( connectionState: global.connectionState, isSyncing: global.isSyncing, isFetchingDifference: global.isFetchingDifference, - typingStatus, + typingStatusByPeerId, isSavedDialog, messagesCount, unreadCount, diff --git a/src/components/ui/ListItem.scss b/src/components/ui/ListItem.scss index 2bb4022bf..f42dea604 100644 --- a/src/components/ui/ListItem.scss +++ b/src/components/ui/ListItem.scss @@ -110,8 +110,6 @@ } } - .user-status, - .group-status, .title, .other-usernames, .subtitle { @@ -325,27 +323,24 @@ white-space: nowrap; } + .status { + color: var(--color-text-secondary); + &.online { + color: var(--color-primary); + } + } + .status, .typing-status { display: inline-block; font-size: 0.875rem; line-height: 1.25rem; - color: var(--color-text-secondary); - - &.online { - color: var(--color-primary); - } &[dir="rtl"], &[dir="auto"] { width: 100%; text-align: initial; } - - .group-status:only-child, - .user-status:only-child { - display: flow-root; - } } } diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index 9df68eace..98b413689 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -159,17 +159,53 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } case 'updateChatTypingStatus': { - const { id, threadId = MAIN_THREAD_ID, typingStatus } = update; - global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', typingStatus); + const { + id, threadId = MAIN_THREAD_ID, typingStatus, peerId, + } = update; + const currentTypingStatusByPeerId = selectThreadLocalStateParam(global, id, threadId, 'typingStatusByPeerId'); + + if (!typingStatus) { + if (!currentTypingStatusByPeerId?.[peerId]) { + return undefined; + } + + const nextTypingStatusByPeerId = omit(currentTypingStatusByPeerId, [peerId]); + global = replaceThreadLocalStateParam( + global, + id, + threadId, + 'typingStatusByPeerId', + Object.keys(nextTypingStatusByPeerId).length ? nextTypingStatusByPeerId : undefined, + ); + setGlobal(global); + + return undefined; + } + + const updatedTypingStatusByPeerId = currentTypingStatusByPeerId + ? { ...currentTypingStatusByPeerId, [peerId]: typingStatus } + : { [peerId]: typingStatus }; + global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatusByPeerId', updatedTypingStatusByPeerId); setGlobal(global); setTimeout(() => { global = getGlobal(); - const currentTypingStatus = selectThreadLocalStateParam(global, id, threadId, 'typingStatus'); - if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) { - global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', undefined); - setGlobal(global); + const actualTypingStatusByPeerId = selectThreadLocalStateParam(global, id, threadId, 'typingStatusByPeerId'); + const currentTypingStatus = actualTypingStatusByPeerId?.[peerId]; + + if (!currentTypingStatus || typingStatus.timestamp !== currentTypingStatus.timestamp) { + return; } + + const nextTypingStatusByPeerId = omit(actualTypingStatusByPeerId, [peerId]); + global = replaceThreadLocalStateParam( + global, + id, + threadId, + 'typingStatusByPeerId', + Object.keys(nextTypingStatusByPeerId).length ? nextTypingStatusByPeerId : undefined, + ); + setGlobal(global); }, TYPING_STATUS_CLEAR_DELAY); return undefined; diff --git a/src/global/cache.ts b/src/global/cache.ts index 6f9ff916a..1fd8d6a32 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -720,7 +720,7 @@ function reduceMessages(global: T): GlobalState['messages localState: { ...thread.localState, listedIds: thread.localState?.lastViewportIds, - typingStatus: undefined, + typingStatusByPeerId: undefined, }, }; return acc; diff --git a/src/types/index.ts b/src/types/index.ts index 199fc9f9f..7bd9b38ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -653,7 +653,7 @@ export interface ThreadLocalState { noWebPage?: boolean; - typingStatus?: ApiTypingStatus; + typingStatusByPeerId?: Record; typingDraftIdByRandomId?: Record; } diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 9dbf83e7a..43aa656c7 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -18,6 +18,7 @@ export interface LangPair { 'AccDescrGroup': undefined; 'AccDescrChannel': undefined; 'Nothing': undefined; + 'Typing': undefined; 'SendActionRecordVideo': undefined; 'SendActionUploadVideo': undefined; 'SendActionRecordAudio': undefined; @@ -27,7 +28,6 @@ export interface LangPair { 'PlayingGame': undefined; 'SendActionRecordRound': undefined; 'SendActionUploadRound': undefined; - 'SendActionChooseSticker': undefined; 'SetUrlInUse': undefined; 'UsernameInUse': undefined; 'CreateGroupError': undefined; @@ -2111,7 +2111,44 @@ export interface LangPairWithVariables { 'UserTyping': { 'user': V; }; - 'UserActionWatchingAnimations': { + 'UserTypingSeveral': { + 'users': V; + }; + 'UserActionRecordVideo': { + 'user': V; + }; + 'UserActionUploadVideo': { + 'user': V; + }; + 'UserActionRecordAudio': { + 'user': V; + }; + 'UserActionUploadAudio': { + 'user': V; + }; + 'UserActionUploadPhoto': { + 'user': V; + }; + 'UserActionUploadFile': { + 'user': V; + }; + 'UserPlayingGame': { + 'user': V; + }; + 'UserActionRecordRound': { + 'user': V; + }; + 'UserActionUploadRound': { + 'user': V; + }; + 'SendActionChooseSticker': { + 'eyes': V; + }; + 'UserActionChooseSticker': { + 'user': V; + 'eyes': V; + }; + 'ActionWatchingAnimations': { 'emoji': V; }; 'SetUrlAvailable': { @@ -3703,6 +3740,10 @@ export interface LangPairPlural { } export interface LangPairPluralWithVariables { + 'UserTypingMany': { + 'user': V; + 'count': V; + }; 'Participants': { 'count': V; };