diff --git a/docs/plans/dm_calls_techdebt.md b/docs/plans/dm_calls_techdebt.md index f5b691c0..e2c79180 100644 --- a/docs/plans/dm_calls_techdebt.md +++ b/docs/plans/dm_calls_techdebt.md @@ -577,3 +577,16 @@ App foreground, incoming DM call. JS strip + audio начинают отраба - **Capacitor sync risk:** confirmed safe — `cap sync` регенерит только `capacitor.build.gradle`, не трогает `build.gradle`. - **Связь:** follow-up §5.27 (landed 2026-04-24). +### 5.50. 🟡 LeaveRoomPrompt / LeaveSpacePrompt prematurely report success + +- **Что:** [LeaveRoomPrompt.tsx](../../src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx) и [LeaveSpacePrompt.tsx](../../src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx) вызывают `mx.leave(roomId)` внутри `async` callback без `await`/`return`. `useAsyncCallback` ждёт callback, а не внутренний network promise, поэтому состояние становится `Success` сразу после запуска запроса. +- **Симптом:** prompt закрывается через `onDone()` до ответа homeserver. Если `POST /rooms/{roomId}/leave` падает, ошибка не попадает в `leaveState`, `Failed to leave room/space` не показывается, возможен unhandled promise rejection, user остаётся в комнате после refresh/sync. +- **Доказательство:** `matrix-js-sdk` `leave(roomId)` возвращает `Promise` и внутри делает `authedRequest(POST /rooms/$room_id/leave)`. Текущий callback возвращает `Promise`, который resolve'ится сразу. +- **Live reproduce:** + 1. `npm run dev`, залогиниться и открыть joined room. + 2. DevTools → Network throttling → `Offline` (или block request URL `*rooms/*/leave*`). + 3. Room menu → `Leave Room` → confirm. + 4. Фактический pre-fix результат: prompt закрывается как success, error text не появляется; в Network `POST .../rooms/{roomId}/leave` failed/blocked; после возврата online + refresh/sync комната остаётся. + 5. Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error. +- **Фикс-направление:** `await mx.leave(roomId);` в обоих prompt'ах. Не `return mx.leave(roomId)`, если сохраняем `useAsyncCallback` contract. +- **Scope:** не связано с i18n-диффом; pre-existing bug, найден при ревью локализации leave prompt'а. diff --git a/public/locales/en.json b/public/locales/en.json index c42a4ed8..78183faf 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -390,7 +390,18 @@ "incoming": "Incoming call…", "answer": "Answer", "decline": "Decline", - "unknown_caller": "Unknown caller" + "unknown_caller": "Unknown caller", + "mic_off": "Turn Off Microphone", + "mic_on": "Turn On Microphone", + "sound_off": "Turn Off Sound", + "sound_on": "Turn On Sound", + "camera_off": "Stop Camera", + "camera_on": "Start Camera", + "screenshare_off": "Stop Screenshare", + "screenshare_on": "Start Screenshare", + "chat_close": "Close Chat", + "chat_open": "Open Chat", + "end_call": "End" }, "Room": { "new_messages": "New Messages", @@ -476,7 +487,40 @@ "not_decrypted_yet": "This message is not decrypted yet", "broken_message": "Broken message", "empty_message": "Empty message", - "edited": " (edited)" + "edited": " (edited)", + + "conversation_beginning": "This is the beginning of conversation.", + "created_by": "Created by @{{creator}} on {{date}} {{time}}", + "invite_member": "Invite Member", + "open_old_room": "Open Old Room", + "join_old_room": "Join Old Room", + "leave_room_title": "Leave Room", + "leave_room_confirm": "Are you sure you want to leave this room?", + "leave_room_error": "Failed to leave room! {{error}}", + "leaving": "Leaving...", + "leave": "Leave", + + "member_broken": "Broken membership event", + "member_accepted_knock": "{{sender}} accepted {{user}}'s join request", + "member_invited": "{{sender}} invited {{user}}", + "member_knock": "{{user}} requested to join room", + "member_joined": "{{user}} joined the room", + "member_rejected_invite": "{{user}} rejected the invitation", + "member_rejected_knock": "{{sender}} rejected {{user}}'s join request", + "member_revoked_knock": "{{user}} revoked join request", + "member_revoked_invite": "{{sender}} revoked {{user}}'s invite", + "member_unbanned": "{{sender}} unbanned {{user}}", + "member_left": "{{user}} left the room", + "member_kicked": "{{sender}} kicked {{user}}", + "member_banned": "{{sender}} banned {{user}}", + "member_name_changed": "{{oldName}} changed display name to {{newName}}", + "member_name_removed": "{{user}} removed their display name", + "member_avatar_changed": "{{user}} changed their avatar", + "member_avatar_removed": "{{user}} removed their avatar", + "member_no_change": "Membership event with no changes", + + "member_ended_call": "{{user}} ended the call", + "member_joined_call": "{{user}} joined the call" }, "Inbox": { "inbox": "Inbox", diff --git a/public/locales/ru.json b/public/locales/ru.json index b2f4924c..759dcd33 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -390,7 +390,18 @@ "incoming": "Входящий звонок…", "answer": "Ответить", "decline": "Отклонить", - "unknown_caller": "Неизвестный абонент" + "unknown_caller": "Неизвестный абонент", + "mic_off": "Выключить микрофон", + "mic_on": "Включить микрофон", + "sound_off": "Выключить звук", + "sound_on": "Включить звук", + "camera_off": "Выключить камеру", + "camera_on": "Включить камеру", + "screenshare_off": "Остановить показ экрана", + "screenshare_on": "Начать показ экрана", + "chat_close": "Закрыть чат", + "chat_open": "Открыть чат", + "end_call": "Завершить" }, "Room": { "new_messages": "Новые сообщения", @@ -476,7 +487,40 @@ "not_decrypted_yet": "Сообщение ещё не расшифровано", "broken_message": "Повреждённое сообщение", "empty_message": "Пустое сообщение", - "edited": " (изменено)" + "edited": " (изменено)", + + "conversation_beginning": "Начало переписки.", + "created_by": "Комната создана @{{creator}} {{date}} {{time}}", + "invite_member": "Пригласить", + "open_old_room": "Открыть старую комнату", + "join_old_room": "Войти в старую комнату", + "leave_room_title": "Покинуть комнату", + "leave_room_confirm": "Покинуть эту комнату?", + "leave_room_error": "Не удалось покинуть комнату! {{error}}", + "leaving": "Выход...", + "leave": "Покинуть", + + "member_broken": "Некорректное событие участия", + "member_accepted_knock": "{{sender}} одобряет вступление {{user}}", + "member_invited": "{{sender}} приглашает {{user}}", + "member_knock": "{{user}} просит вступить в комнату", + "member_joined": "{{user}} теперь в комнате", + "member_rejected_invite": "{{user}} отклоняет приглашение", + "member_rejected_knock": "{{sender}} отклоняет запрос {{user}}", + "member_revoked_knock": "{{user}} отзывает запрос на вступление", + "member_revoked_invite": "{{sender}} отзывает приглашение {{user}}", + "member_unbanned": "{{sender}} разблокирует {{user}}", + "member_left": "{{user}} больше не в комнате", + "member_kicked": "{{sender}} исключает {{user}}", + "member_banned": "{{sender}} блокирует {{user}}", + "member_name_changed": "{{oldName}} теперь {{newName}}", + "member_name_removed": "{{user}} убирает отображаемое имя", + "member_avatar_changed": "{{user}} меняет аватар", + "member_avatar_removed": "{{user}} убирает аватар", + "member_no_change": "Событие участия без изменений", + + "member_ended_call": "{{user}} больше не в звонке", + "member_joined_call": "{{user}} теперь в звонке" }, "Inbox": { "inbox": "Входящие", diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..aaa383c7 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -17,6 +17,7 @@ import { Spinner, } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; @@ -27,6 +28,7 @@ type LeaveRoomPromptProps = { onCancel: () => void; }; export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const [leaveState, leaveRoom] = useAsyncCallback( @@ -66,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro size="500" > - Leave Room + {t('Room.leave_room_title')} @@ -74,10 +76,10 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro - Are you sure you want to leave this room? + {t('Room.leave_room_confirm')} {leaveState.status === AsyncStatus.Error && ( - Failed to leave room! {leaveState.error.message} + {t('Room.leave_room_error', { error: leaveState.error.message })} )} @@ -96,7 +98,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro } > - {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'} + {leaveState.status === AsyncStatus.Loading ? t('Room.leaving') : t('Room.leave')} diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..4a1d319f 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; +import { Trans, useTranslation } from 'react-i18next'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -23,6 +24,7 @@ export type RoomIntroProps = { }; export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { + const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -66,19 +68,21 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {name} - {typeof topic === 'string' ? topic : 'This is the beginning of conversation.'} + {typeof topic === 'string' ? topic : t('Room.conversation_beginning')} {creatorName && ts && ( - {'Created by '} - @{creatorName} - {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`} + }} + /> )} {invitePrompt && ( @@ -93,7 +97,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => fill="Soft" radii="300" > - Open Old Room + {t('Room.open_old_room')} ) : ( ))} diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index dc8cb6c4..9d1502fd 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -1,6 +1,7 @@ import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; import React, { useCallback } from 'react'; import { useSetAtom, useStore } from 'jotai'; +import { useTranslation } from 'react-i18next'; import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; @@ -20,12 +21,13 @@ type MicrophoneButtonProps = { disabled?: boolean; }; function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + {enabled ? t('Call.mic_off') : t('Call.mic_on')} } > @@ -53,12 +55,13 @@ type SoundButtonProps = { disabled?: boolean; }; function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + {enabled ? t('Call.sound_off') : t('Call.sound_on')} } > @@ -90,12 +93,13 @@ type VideoButtonProps = { disabled?: boolean; }; function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Stop Camera' : 'Start Camera'} + {enabled ? t('Call.camera_off') : t('Call.camera_on')} } > @@ -127,12 +131,13 @@ type ScreenShareButtonProps = { disabled?: boolean; }; function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + {enabled ? t('Call.screenshare_off') : t('Call.screenshare_on')} } > @@ -163,6 +168,7 @@ export function CallControl({ compact: boolean; callJoined: boolean; }) { + const { t } = useTranslation(); const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const setCallEmbed = useSetAtom(callEmbedAtom); const store = useStore(); @@ -250,7 +256,7 @@ export function CallControl({ > {!compact && ( - End + {t('Call.end_call')} )} diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 143a8022..9a7639b8 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds'; import { useAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; import * as css from './styles.css'; import { callChatAtom } from '../../state/callEmbed'; @@ -15,13 +16,14 @@ type MicrophoneButtonProps = { onToggle: () => void; }; export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + {enabled ? t('Call.mic_off') : t('Call.mic_on')} } > @@ -47,13 +49,14 @@ type SoundButtonProps = { onToggle: () => void; }; export function SoundButton({ enabled, onToggle }: SoundButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + {enabled ? t('Call.sound_off') : t('Call.sound_on')} } > @@ -83,13 +86,14 @@ type VideoButtonProps = { onToggle: () => void; }; export function VideoButton({ enabled, onToggle }: VideoButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Stop Camera' : 'Start Camera'} + {enabled ? t('Call.camera_off') : t('Call.camera_on')} } > @@ -119,13 +123,14 @@ type ScreenShareButtonProps = { onToggle: () => void; }; export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { + const { t } = useTranslation(); return ( - {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + {enabled ? t('Call.screenshare_off') : t('Call.screenshare_on')} } > @@ -147,6 +152,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) } export function ChatButton() { + const { t } = useTranslation(); const [chat, setChat] = useAtom(callChatAtom); return ( @@ -155,7 +161,7 @@ export function ChatButton() { delay={500} tooltip={ - {chat ? 'Close Chat' : 'Open Chat'} + {chat ? t('Call.chat_close') : t('Call.chat_open')} } > diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index faee8340..e44fba6a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -47,7 +47,7 @@ import { } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; @@ -1534,8 +1534,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli content={ - {senderName} - {callJoined ? ' joined the call' : ' ended the call'} + }} + /> } diff --git a/src/app/hooks/useMemberEventParser.tsx b/src/app/hooks/useMemberEventParser.tsx index 8a59aaad..a2280296 100644 --- a/src/app/hooks/useMemberEventParser.tsx +++ b/src/app/hooks/useMemberEventParser.tsx @@ -1,6 +1,7 @@ import React, { ReactNode } from 'react'; import { IconSrc, Icons } from 'folds'; import { MatrixEvent } from 'matrix-js-sdk'; +import { Trans } from 'react-i18next'; import { IMemberContent, Membership } from '../../types/matrix/room'; import { getMxIdLocalPart } from '../utils/matrix'; import { isMembershipChanged } from '../utils/room'; @@ -12,6 +13,8 @@ export type ParsedResult = { export type MemberEventParser = (mEvent: MatrixEvent) => ParsedResult; +const B = ; + export const useMemberEventParser = (): MemberEventParser => { const parseMemberEvent: MemberEventParser = (mEvent) => { const content = mEvent.getContent(); @@ -23,7 +26,7 @@ export const useMemberEventParser = (): MemberEventParser => { if (!senderId || !userId) return { icon: Icons.User, - body: 'Broken membership event', + body: , }; const senderName = getMxIdLocalPart(senderId); @@ -39,11 +42,12 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.ArrowGoRightPlus, body: ( <> - {senderName} - {' accepted '} - {userName} - {`'s join request `} - {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -53,9 +57,12 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.ArrowGoRightPlus, body: ( <> - {senderName} - {' invited '} - {userName} {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -66,9 +73,12 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.ArrowGoRightPlus, body: ( <> - {userName} - {' request to join room '} - {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -78,10 +88,11 @@ export const useMemberEventParser = (): MemberEventParser => { return { icon: Icons.ArrowGoRight, body: ( - <> - {userName} - {' joined the room'} - + ), }; } @@ -93,17 +104,21 @@ export const useMemberEventParser = (): MemberEventParser => { body: senderId === userId ? ( <> - {userName} - {' rejected the invitation '} - {reason} + + {reason ? ` ${reason}` : ''} ) : ( <> - {senderName} - {' rejected '} - {userName} - {`'s join request `} - {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -115,17 +130,21 @@ export const useMemberEventParser = (): MemberEventParser => { body: senderId === userId ? ( <> - {userName} - {' revoked joined request '} - {reason} + + {reason ? ` ${reason}` : ''} ) : ( <> - {senderName} - {' revoked '} - {userName} - {`'s invite `} - {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -136,9 +155,12 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.ArrowGoLeft, body: ( <> - {senderName} - {' unbanned '} - {userName} {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -149,15 +171,21 @@ export const useMemberEventParser = (): MemberEventParser => { body: senderId === userId ? ( <> - {userName} - {' left the room '} - {reason} + + {reason ? ` ${reason}` : ''} ) : ( <> - {senderName} - {' kicked '} - {userName} {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -168,9 +196,12 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.ArrowGoLeft, body: ( <> - {senderName} - {' banned '} - {userName} {reason} + + {reason ? ` ${reason}` : ''} ), }; @@ -187,16 +218,17 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.Mention, body: typeof content.displayname === 'string' ? ( - <> - {prevUserName} - {' changed display name to '} - {userName} - + ) : ( - <> - {prevUserName} - {' removed their display name '} - + ), }; } @@ -205,22 +237,24 @@ export const useMemberEventParser = (): MemberEventParser => { icon: Icons.User, body: content.avatar_url && typeof content.avatar_url === 'string' ? ( - <> - {userName} - {' changed their avatar'} - + ) : ( - <> - {userName} - {' removed their avatar '} - + ), }; } return { icon: Icons.User, - body: 'Membership event with no changes', + body: , }; };