Middle Header: Update typing status lang strings (#6854)

This commit is contained in:
zubiden 2026-04-27 14:29:26 +02:00 committed by Alexander Zinchuk
parent 881c09acdf
commit 230e9797d4
27 changed files with 471 additions and 176 deletions

View File

@ -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['type'], 'watchingAnimations'>,
): 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 {

View File

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

View File

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

View File

@ -145,6 +145,7 @@ export type ApiUpdateChatLeave = {
export type ApiUpdateChatTypingStatus = {
'@type': 'updateChatTypingStatus';
id: string;
peerId: string;
threadId?: ThreadId;
typingStatus: ApiTypingStatus | undefined;
};

View File

@ -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.";

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -45,7 +45,7 @@ type OwnProps = {
threadId?: ThreadId;
className?: string;
statusIcon?: IconName;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
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 <TypingStatus typingStatus={typingStatus} />;
if (typingStatusByPeerId) {
return <TypingStatus typingStatusByPeerId={typingStatusByPeerId} />;
}
if (isTopic) {

View File

@ -41,7 +41,7 @@ import TypingStatus from './TypingStatus';
const TOPIC_ICON_SIZE = 2.5 * REM;
type BaseOwnProps = {
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
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 <TypingStatus typingStatus={typingStatus} />;
if (typingStatusByPeerId) {
return <TypingStatus typingStatusByPeerId={typingStatusByPeerId} isPrivate />;
}
if (isTopic) {

View File

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

View File

@ -1,11 +0,0 @@
.typing-status {
display: flex;
align-items: baseline;
.sender-name {
&::after {
content: '\00a0';
color: var(--color-text-secondary);
}
}
}

View File

@ -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<string, ApiTypingStatus>;
isPrivate?: boolean;
};
type StateProps = {
typingUser?: ApiUser;
};
const ICON_SIZE = 1.125 * REM;
const EYES_ICON_SIZE = 1.25 * REM;
const TypingStatus: FC<OwnProps & StateProps> = ({ 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 (
<p className="typing-status" dir={lang.isRtl ? 'rtl' : 'auto'}>
{typingUserName && (
<span className="sender-name" dir="auto">{renderText(typingUserName)}</span>
<span className={buildClassName(styles.typingStatus, 'typing-status')} dir={lang.isRtl ? 'rtl' : 'auto'}>
{shouldRenderTypingStatusIcon && (
<AnimatedIconWithPreview
className={styles.typingIcon}
tgsUrl={LOCAL_TGS_URLS.Typing}
size={ICON_SIZE}
play
noLoop={false}
shouldUseTextColor
/>
)}
<DotAnimation content={content} />
</p>
<span className={styles.content} dir="auto">{content}</span>
</span>
);
};
export default memo(withGlobal<OwnProps>(
(global, { typingStatus }): Complete<StateProps> => {
if (!typingStatus.userId) {
return { typingUser: undefined };
}
function renderEyesIcon() {
return (
<AnimatedIconWithPreview
className={styles.eyesIcon}
tgsUrl={LOCAL_TGS_URLS.Eyes}
size={EYES_ICON_SIZE}
play
noLoop={false}
shouldUseTextColor
/>
);
}
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<typeof selectPeer>, 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);

View File

@ -209,7 +209,7 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp
{renderText(truncatedText)}
<span key="typing-placeholder" className={styles.placeholder}>
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Typing}
tgsUrl={LOCAL_TGS_URLS.Writing}
size={PLACEHOLDER_SIZE}
play
noLoop={false}

View File

@ -20,7 +20,9 @@ import PartyPopper from '../../../assets/tgs/general/PartyPopper.tgs';
import Invite from '../../../assets/tgs/invites/Invite.tgs';
import JoinRequest from '../../../assets/tgs/invites/Requests.tgs';
import LastSeen from '../../../assets/tgs/LastSeen.tgs';
import Eyes from '../../../assets/tgs/message/Eyes.tgs';
import Typing from '../../../assets/tgs/message/Typing.tgs';
import Writing from '../../../assets/tgs/message/Writing.tgs';
import MonkeyClose from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs';
import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
@ -98,5 +100,7 @@ export const LOCAL_TGS_URLS = {
Passkeys,
DuckCake,
HandStop,
Writing,
Typing,
Eyes,
};

View File

@ -119,7 +119,7 @@ type StateProps = {
canScrollDown?: boolean;
canChangeFolder?: boolean;
lastMessageTopic?: ApiTopic;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
withInterfaceAnimations?: boolean;
lastMessageId?: number;
lastMessage?: ApiMessage;
@ -158,7 +158,7 @@ const Chat: FC<OwnProps & StateProps> = ({
canScrollDown,
canChangeFolder,
lastMessageTopic,
typingStatus,
typingStatusByPeerId,
lastMessageId,
lastMessage,
isSavedDialog,
@ -231,7 +231,7 @@ const Chat: FC<OwnProps & StateProps> = ({
chat,
chatId,
lastMessage,
typingStatus,
typingStatusByPeerId,
draft,
statefulMediaContent: groupStatefulContent({ story: lastMessageStory }),
lastMessageTopic,
@ -567,7 +567,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
user,
userStatus,
lastMessageTopic,
typingStatus,
typingStatusByPeerId,
withInterfaceAnimations: selectCanAnimateInterface(global),
lastMessage,
lastMessageId,

View File

@ -71,7 +71,7 @@ type StateProps = {
lastMessageStory?: ApiTypeStory;
lastMessageOutgoingStatus?: ApiMessageOutgoingStatus;
lastMessageSender?: ApiPeer;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
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<OwnProps>(
? 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<OwnProps>(
chat,
lastMessage,
lastMessageSender,
typingStatus,
typingStatusByPeerId,
isChatMuted,
canDelete: selectCanDeleteTopic(global, chatId, topic.id),
withInterfaceAnimations: selectCanAnimateInterface(global),

View File

@ -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<string, ApiTypingStatus>) {
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<string, ApiTypingStatus>;
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 <TypingStatus typingStatus={typingStatus} />;
&& typingStatusByPeerId && lastMessage
&& latestTypingStatusTimestamp && latestTypingStatusTimestamp > lastMessage.date) {
return (
<TypingStatus typingStatusByPeerId={typingStatusByPeerId} isPrivate={isUserId(chatId)} />
);
}
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() {

View File

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

View File

@ -75,7 +75,7 @@ type OwnProps = {
type StateProps = {
chat?: ApiChat;
isSavedDialog?: boolean;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
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<OwnProps>(
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),

View File

@ -43,10 +43,6 @@
content: "@";
}
}
.user-status {
display: none !important;
}
}
@media (max-width: 600px) {

View File

@ -36,7 +36,7 @@ type StateProps = {
connectionState?: ApiUpdateConnectionStateType;
isSyncing?: boolean;
isFetchingDifference?: boolean;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
isSavedDialog?: boolean;
messagesCount?: number;
unreadCount?: number;
@ -52,7 +52,7 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
connectionState,
isSyncing,
isFetchingDifference,
typingStatus,
typingStatusByPeerId,
isSavedDialog,
messagesCount,
unreadCount,
@ -104,7 +104,7 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
<PrivateChatInfo
key={displayChatId}
userId={displayChatId}
typingStatus={typingStatus}
typingStatusByPeerId={typingStatusByPeerId}
status={connectionStatusText || savedMessagesStatus}
withDots={Boolean(connectionStatusText)}
withFullInfo={false}
@ -120,7 +120,7 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): Complete<StateProps> => {
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<OwnProps>(
connectionState: global.connectionState,
isSyncing: global.isSyncing,
isFetchingDifference: global.isFetchingDifference,
typingStatus,
typingStatusByPeerId,
isSavedDialog,
messagesCount,
unreadCount,

View File

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

View File

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

View File

@ -720,7 +720,7 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
localState: {
...thread.localState,
listedIds: thread.localState?.lastViewportIds,
typingStatus: undefined,
typingStatusByPeerId: undefined,
},
};
return acc;

View File

@ -653,7 +653,7 @@ export interface ThreadLocalState {
noWebPage?: boolean;
typingStatus?: ApiTypingStatus;
typingStatusByPeerId?: Record<string, ApiTypingStatus>;
typingDraftIdByRandomId?: Record<string, number>;
}

View File

@ -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<V = LangVariable> {
'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<V = LangVariable> {
'UserTypingMany': {
'user': V;
'count': V;
};
'Participants': {
'count': V;
};