diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx index dd7c0b11..8f9182c5 100644 --- a/web/src/features/messages/AttachmentPreview.tsx +++ b/web/src/features/messages/AttachmentPreview.tsx @@ -100,3 +100,50 @@ export function FileAttachment({ data }: { data: FileData }) { ); } + +interface AudioData { + val?: string; + ref?: string; + mime?: string; + duration?: number; +} + +function formatAudioDuration(ms: number | undefined): string { + const totalSeconds = Math.round((ms || 0) / 1000); + return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`; +} + +export function AudioAttachment({ data }: { data: AudioData }) { + const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null; + const remote = useAuthedBlobUrl(inline ? undefined : data.ref); + const src = inline || remote.url; + const [playing, setPlaying] = useState(false); + const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null)); + + useEffect(() => { + if (!audioEl) return undefined; + const onEnded = () => setPlaying(false); + audioEl.addEventListener('ended', onEnded); + return () => audioEl.removeEventListener('ended', onEnded); + }, [audioEl]); + + const toggle = () => { + if (!audioEl || !src) return; + if (playing) { + audioEl.pause(); + setPlaying(false); + return; + } + if (audioEl.src !== src) audioEl.src = src; + audioEl.play().catch(() => {}); + setPlaying(true); + }; + + return ( + + ); +} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 481ddf1d..d3534dc9 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -9,13 +9,14 @@ import { EmojiPanel } from '@/features/messages/EmojiPanel'; import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal'; import { MessageContextMenu } from '@/features/messages/MessageContextMenu'; import { MessageRow } from '@/features/messages/MessageBubble'; +import { VoiceRecorder } from '@/features/messages/VoiceRecorder'; import { useBootstrap } from '@/lib/api/hooks'; import { favoriteMessages, recallMessage } from '@/lib/api/messages'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { availableActions, type MessageAction } from '@/lib/messages/actions'; import { renderDrafty } from '@/lib/messages/drafty'; -import { sendFile, sendImage } from '@/lib/tinode/attachments'; +import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send'; import { closeConversation, openConversation } from '@/lib/tinode/topics'; @@ -45,6 +46,7 @@ export function ConversationView() { const [selectMode, setSelectMode] = useState(false); const [selectedSeqs, setSelectedSeqs] = useState>(new Set()); const [loadingOlder, setLoadingOlder] = useState(false); + const [recording, setRecording] = useState(false); const [replyTo, setReplyTo] = useState(null); const [forwardMsg, setForwardMsg] = useState(null); const [emojiOpen, setEmojiOpen] = useState(false); @@ -317,26 +319,44 @@ export function ConversationView() { - - - -