feat: add waveform voice messages end-to-end
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
This commit is contained in:
@@ -41,9 +41,11 @@ export function MessageComposer() {
|
||||
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const [showFormatMenu, setShowFormatMenu] = useState(false);
|
||||
const [captionDraft, setCaptionDraft] = useState("");
|
||||
const mediaInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||
@@ -169,7 +171,11 @@ export function MessageComposer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
|
||||
async function handleUpload(
|
||||
file: File,
|
||||
messageType: "file" | "image" | "video" | "audio" | "voice" = "file",
|
||||
waveformPoints?: number[] | null
|
||||
) {
|
||||
if (!activeChatId || !me) {
|
||||
return;
|
||||
}
|
||||
@@ -186,12 +192,19 @@ export function MessageComposer() {
|
||||
senderId: me.id,
|
||||
type: messageType,
|
||||
text: upload.file_url,
|
||||
clientMessageId
|
||||
clientMessageId,
|
||||
});
|
||||
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
|
||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
||||
const messageWithWaveform = waveformPoints?.length ? { ...message, attachment_waveform: waveformPoints } : message;
|
||||
confirmMessageByClientId(activeChatId, clientMessageId, messageWithWaveform);
|
||||
try {
|
||||
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
||||
await attachFile(
|
||||
message.id,
|
||||
upload.file_url,
|
||||
file.type || "application/octet-stream",
|
||||
file.size,
|
||||
waveformPoints ?? null
|
||||
);
|
||||
} catch {
|
||||
setUploadError("File sent, but metadata save failed. Please refresh chat.");
|
||||
}
|
||||
@@ -297,7 +310,8 @@ export function MessageComposer() {
|
||||
}
|
||||
const blob = new Blob(data, { type: "audio/webm" });
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
|
||||
await handleUpload(file, "voice");
|
||||
const waveform = await buildWaveformPoints(blob, 64);
|
||||
await handleUpload(file, "voice", waveform);
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
@@ -453,6 +467,58 @@ export function MessageComposer() {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function insertFormatting(startTag: string, endTag = startTag, placeholder = "text") {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? text.length;
|
||||
const end = textarea.selectionEnd ?? text.length;
|
||||
const selected = text.slice(start, end);
|
||||
const middle = selected || placeholder;
|
||||
const nextValue = `${text.slice(0, start)}${startTag}${middle}${endTag}${text.slice(end)}`;
|
||||
setText(nextValue);
|
||||
if (activeChatId) {
|
||||
setDraft(activeChatId, nextValue);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
if (selected) {
|
||||
const pos = start + startTag.length + middle.length + endTag.length;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
} else {
|
||||
const selStart = start + startTag.length;
|
||||
const selEnd = selStart + middle.length;
|
||||
textarea.setSelectionRange(selStart, selEnd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? text.length;
|
||||
const end = textarea.selectionEnd ?? text.length;
|
||||
const selected = text.slice(start, end).trim() || "text";
|
||||
const href = window.prompt("Enter URL (https://...)");
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
const link = `[${selected}](${href.trim()})`;
|
||||
const nextValue = `${text.slice(0, start)}${link}${text.slice(end)}`;
|
||||
setText(nextValue);
|
||||
if (activeChatId) {
|
||||
setDraft(activeChatId, nextValue);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const pos = start + link.length;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
|
||||
{activeChatId && replyToByChat[activeChatId] ? (
|
||||
@@ -492,6 +558,32 @@ export function MessageComposer() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showFormatMenu ? (
|
||||
<div className="mb-2 flex items-center gap-1 rounded-2xl border border-slate-700/80 bg-slate-900/95 px-2 py-1.5">
|
||||
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("||", "||")} type="button" title="Spoiler">
|
||||
👁
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs font-semibold hover:bg-slate-800" onClick={() => insertFormatting("**", "**")} type="button" title="Bold">
|
||||
B
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs italic hover:bg-slate-800" onClick={() => insertFormatting("*", "*")} type="button" title="Italic">
|
||||
I
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs underline hover:bg-slate-800" onClick={() => insertFormatting("__", "__")} type="button" title="Underline">
|
||||
U
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs line-through hover:bg-slate-800" onClick={() => insertFormatting("~~", "~~")} type="button" title="Strikethrough">
|
||||
S
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("`", "`")} type="button" title="Monospace">
|
||||
M
|
||||
</button>
|
||||
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={insertLink} type="button" title="Link">
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-2 flex items-end gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -549,7 +641,17 @@ export function MessageComposer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-2 text-xs font-semibold text-slate-200 hover:bg-slate-700"
|
||||
onClick={() => setShowFormatMenu((v) => !v)}
|
||||
type="button"
|
||||
title="Text formatting"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||
placeholder="Write a message..."
|
||||
rows={1}
|
||||
@@ -657,6 +759,38 @@ export function MessageComposer() {
|
||||
);
|
||||
}
|
||||
|
||||
async function buildWaveformPoints(blob: Blob, bars = 64): Promise<number[] | null> {
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
try {
|
||||
const audioBuffer = await audioContext.decodeAudioData(buffer.slice(0));
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
if (!channel.length || bars < 8) {
|
||||
return null;
|
||||
}
|
||||
const blockSize = Math.max(1, Math.floor(channel.length / bars));
|
||||
const points: number[] = [];
|
||||
for (let i = 0; i < bars; i += 1) {
|
||||
const start = i * blockSize;
|
||||
const end = Math.min(channel.length, start + blockSize);
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j += 1) {
|
||||
const sample = channel[j];
|
||||
sum += sample * sample;
|
||||
}
|
||||
const rms = Math.sqrt(sum / Math.max(1, end - start));
|
||||
points.push(Math.max(1, Math.min(31, Math.round(rms * 42))));
|
||||
}
|
||||
return points;
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
Reference in New Issue
Block a user