diff --git a/web/src/components/friends/DirectMessageView.tsx b/web/src/components/friends/DirectMessageView.tsx index e7be389..1bbbc89 100644 --- a/web/src/components/friends/DirectMessageView.tsx +++ b/web/src/components/friends/DirectMessageView.tsx @@ -9,6 +9,9 @@ import { ChatView } from '@/components/chat/ChatView'; import { PinsPanel } from '@/components/chat/PinsPanel'; import { SearchPanel } from '@/components/chat/SearchPanel'; import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { DmCallHistoryPanel } from '@/components/friends/DmCallHistoryPanel'; +import { DmCallMenu } from '@/components/friends/DmCallMenu'; +import { DmCallPanel } from '@/components/friends/DmCallPanel'; import { Avatar } from '@/components/ui/Avatar'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Menu, MenuItem } from '@/components/ui/Menu'; @@ -16,6 +19,7 @@ import { Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { useCurrentUser } from '@/lib/hooks'; import { presenceDotClass } from '@/lib/presence'; +import { useDmCallStore } from '@/stores/dmCall'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; @@ -76,8 +80,15 @@ export function DirectMessageView({ channelId }: { channelId: string }) { const [searchOpen, setSearchOpen] = useState(false); const [pinsOpen, setPinsOpen] = useState(false); + const [callHistoryOpen, setCallHistoryOpen] = useState(false); const [jump, setJump] = useState<{ id: string; token: number } | null>(null); + // В звонке этой беседы лента уступает место панели звонка: плитки участников + // и управление своей сессией (AGENT.md 7.8). Выход возвращает переписку. + const callChannelId = useDmCallStore((state) => state.channelId); + const callStatus = useDmCallStore((state) => state.status); + const inThisCall = callChannelId === channelId && callStatus !== 'idle'; + const messages = useMessagesStore((state) => state.channels[channelId]?.messages); const authorIds = useMemo( () => @@ -152,7 +163,18 @@ export function DirectMessageView({ channelId }: { channelId: string }) { ) : ( {t(`user.status.${status}`)} )} + {channel.active_call === undefined ? null : ( + + {t('friends.call.inProgress')} + + )}
{t('friends.call.historyEmpty')}
++ {describeError(error)} +
+ )} + > + ); +} diff --git a/web/src/components/friends/DmCallPanel.tsx b/web/src/components/friends/DmCallPanel.tsx new file mode 100644 index 0000000..59f8a3a --- /dev/null +++ b/web/src/components/friends/DmCallPanel.tsx @@ -0,0 +1,270 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { DmCall } from '@/api/calls'; +import type { Channel } from '@/api/types'; +import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { Avatar } from '@/components/ui/Avatar'; +import { Button } from '@/components/ui/primitives'; +import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio'; +import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators'; +import { speakingRingClass } from '@/components/voice/voiceStyles'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import type { VoiceParticipant } from '@/lib/livekit'; +import { useDmCallStore } from '@/stores/dmCall'; +import { useSessionStore } from '@/stores/session'; +import { useVoiceStore } from '@/stores/voice'; + +/** + * Панель звонка в беседе (AGENT.md 7.8, 7.14): плитки участников и те же + * кнопки, что в серверной голосовой комнате, — микрофон, звук, камера, + * демонстрация экрана и выход. Серверных ролей в беседе нет, поэтому + * модерации здесь тоже нет: только своя сессия. + */ +export function DmCallPanel({ channel }: { channel: Channel }) { + const { t } = useTranslation(); + const describeError = useApiErrorMessage(); + const status = useDmCallStore((state) => state.status); + const error = useDmCallStore((state) => state.error); + const participants = useDmCallStore((state) => state.participants); + const latencies = useDmCallStore((state) => state.latencies); + const micEnabled = useDmCallStore((state) => state.micEnabled); + const deafened = useDmCallStore((state) => state.deafened); + const cameraEnabled = useDmCallStore((state) => state.cameraEnabled); + const screenEnabled = useDmCallStore((state) => state.screenEnabled); + const refreshing = useDmCallStore((state) => state.refreshing); + const setMicEnabled = useDmCallStore((state) => state.setMicEnabled); + const setDeafened = useDmCallStore((state) => state.setDeafened); + const setCameraEnabled = useDmCallStore((state) => state.setCameraEnabled); + const setScreenShareEnabled = useDmCallStore((state) => state.setScreenShareEnabled); + const leave = useDmCallStore((state) => state.leave); + + const outputVolume = useVoiceStore((state) => state.outputVolume); + const volumes = useVoiceStore((state) => state.participantVolumes); + + const userIds = useMemo(() => participants.map((item) => item.userId), [participants]); + const authors = useAuthorDirectory(null, userIds); + const currentUserId = useSessionStore((state) => state.user?.id ?? null); + + // Пока комната только подключается, состав пуст: показываем тех, кого позвали. + const live = channel.active_call; + const pending = useMemo( + () => pendingNames(channel, live, currentUserId), + [channel, live, currentUserId], + ); + + const onMic = useCallback(() => void setMicEnabled(!micEnabled), [micEnabled, setMicEnabled]); + const onSound = useCallback(() => setDeafened(!deafened), [deafened, setDeafened]); + const onCamera = useCallback( + () => void setCameraEnabled(!cameraEnabled), + [cameraEnabled, setCameraEnabled], + ); + const onScreen = useCallback( + () => void setScreenShareEnabled(!screenEnabled), + [screenEnabled, setScreenShareEnabled], + ); + + const connecting = status === 'connecting'; + + return ( ++ {t(refreshing ? 'voice.refreshing' : 'friends.call.connected')} +
++ {describeError(error)} +
+ ) : null} + + {participants.length === 0 ? ( ++ {connecting ? t('voice.joining') : t('friends.call.empty')} +
+ ) : ( ++ {t('friends.call.pending', { names: pending.join(', ') })} +
+ ) : null} + +{t('voice.joining')}
: null} +{t('voice.speaking')}
: null} +{name}
++ {t(call.media === 'video' ? 'friends.call.incomingVideo' : 'friends.call.incoming')} +
++ {describeError(error)} +
+ )} +