332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { FILE_UPLOAD_MAX_FILE_SIZE_BYTES } from "@hivegrid/view-models";
|
|
|
|
import { uploadFileInChunks, waitForChatAttachmentScan } from "~/utils/files/uploadFileClient";
|
|
|
|
export type ChatPendingAttachment = {
|
|
readonly clientId: string;
|
|
readonly fileId: string | null;
|
|
readonly fileName: string;
|
|
readonly mimeType: string;
|
|
readonly sizeBytes: number;
|
|
readonly kind: "file" | "image" | "voice";
|
|
readonly status: "uploading" | "processing" | "ready" | "error";
|
|
readonly progress: number;
|
|
readonly errorKey: string | null;
|
|
readonly durationSeconds: number | null;
|
|
};
|
|
|
|
export const MAX_CHAT_ATTACHMENTS = 20;
|
|
export const MAX_VOICE_RECORDING_SECONDS = 10 * 60;
|
|
|
|
export function useChatAttachmentUploads(input: { readonly tenantSlug: string; readonly enabled: boolean }): {
|
|
readonly attachments: readonly ChatPendingAttachment[];
|
|
readonly isStartingRecording: boolean;
|
|
readonly isRecording: boolean;
|
|
readonly recordingDurationSeconds: number;
|
|
readonly errorKey: string | null;
|
|
readonly addFiles: (files: FileList | readonly File[], kindHint?: "voice") => void;
|
|
readonly retry: (clientId: string) => void;
|
|
readonly remove: (clientId: string) => void;
|
|
readonly clearReady: (fileIds: readonly string[]) => void;
|
|
readonly startRecording: () => Promise<void>;
|
|
readonly stopRecording: () => void;
|
|
readonly cancelRecording: () => void;
|
|
} {
|
|
const [attachments, setAttachments] = useState<readonly ChatPendingAttachment[]>([]);
|
|
const [isStartingRecording, setIsStartingRecording] = useState(false);
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [recordingDurationSeconds, setRecordingDurationSeconds] = useState(0);
|
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
const filesRef = useRef(new Map<string, File>());
|
|
const attachmentCountRef = useRef(0);
|
|
const abortRef = useRef(new Map<string, AbortController>());
|
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
const recordingStreamRef = useRef<MediaStream | null>(null);
|
|
const recordingChunksRef = useRef<Blob[]>([]);
|
|
const recordingStartedAtRef = useRef<number | null>(null);
|
|
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const recordingCancelledRef = useRef(false);
|
|
const recordingStartingRef = useRef(false);
|
|
const recordingBytesRef = useRef(0);
|
|
const disposedRef = useRef(false);
|
|
const enabledRef = useRef(input.enabled);
|
|
enabledRef.current = input.enabled;
|
|
|
|
const clearRecordingTimer = useCallback(() => {
|
|
if (recordingTimerRef.current !== null) clearInterval(recordingTimerRef.current);
|
|
recordingTimerRef.current = null;
|
|
recordingStartedAtRef.current = null;
|
|
}, []);
|
|
|
|
const upload = useCallback(
|
|
async (clientId: string, file: File): Promise<void> => {
|
|
const controller = new AbortController();
|
|
abortRef.current.set(clientId, controller);
|
|
setAttachments((current) => current.map((item) => (item.clientId === clientId ? { ...item, status: "uploading", progress: 0, errorKey: null } : item)));
|
|
try {
|
|
const result = await uploadFileInChunks({
|
|
tenantSlug: input.tenantSlug,
|
|
file,
|
|
signal: controller.signal,
|
|
onProgress: (progress) => setAttachments((current) => current.map((item) => (item.clientId === clientId ? { ...item, progress } : item))),
|
|
onProcessing: () =>
|
|
setAttachments((current) => current.map((item) => (item.clientId === clientId ? { ...item, status: "processing", progress: 100 } : item))),
|
|
});
|
|
setAttachments((current) =>
|
|
current.map((item) =>
|
|
item.clientId === clientId ? { ...item, fileId: result.id, fileName: result.displayName, status: "processing", progress: 100 } : item
|
|
)
|
|
);
|
|
await waitForChatAttachmentScan({
|
|
tenantSlug: input.tenantSlug,
|
|
fileId: result.id,
|
|
signal: controller.signal,
|
|
});
|
|
setAttachments((current) =>
|
|
current.map((item) =>
|
|
item.clientId === clientId ? { ...item, fileId: result.id, fileName: result.displayName, status: "ready", progress: 100 } : item
|
|
)
|
|
);
|
|
} catch (error) {
|
|
if (controller.signal.aborted) return;
|
|
const errorKey = error instanceof Error && "errorKey" in error && typeof error.errorKey === "string" ? error.errorKey : "chat.attachments.uploadFailed";
|
|
setAttachments((current) => current.map((item) => (item.clientId === clientId ? { ...item, status: "error", errorKey } : item)));
|
|
} finally {
|
|
abortRef.current.delete(clientId);
|
|
}
|
|
},
|
|
[input.tenantSlug]
|
|
);
|
|
|
|
const addFiles = useCallback(
|
|
(files: FileList | readonly File[], kindHint?: "voice", durationSeconds: number | null = null) => {
|
|
if (!input.enabled) return;
|
|
const selected = Array.from(files);
|
|
if (selected.length === 0) return;
|
|
if (selected.length > Math.max(0, MAX_CHAT_ATTACHMENTS - attachmentCountRef.current)) {
|
|
setErrorKey("chat.attachments.tooMany");
|
|
return;
|
|
}
|
|
attachmentCountRef.current += selected.length;
|
|
setErrorKey(null);
|
|
const entries = selected.map((file) => {
|
|
const clientId = createClientId();
|
|
filesRef.current.set(clientId, file);
|
|
return {
|
|
clientId,
|
|
fileId: null,
|
|
fileName: file.name,
|
|
mimeType: file.type || "application/octet-stream",
|
|
sizeBytes: file.size,
|
|
kind:
|
|
kindHint === "voice" || file.type.startsWith("audio/")
|
|
? ("voice" as const)
|
|
: file.type.startsWith("image/")
|
|
? ("image" as const)
|
|
: ("file" as const),
|
|
status: "uploading" as const,
|
|
progress: 0,
|
|
errorKey: null,
|
|
durationSeconds: kindHint === "voice" ? durationSeconds : null,
|
|
};
|
|
});
|
|
setAttachments((current) => [...current, ...entries]);
|
|
for (const entry of entries) void upload(entry.clientId, filesRef.current.get(entry.clientId)!);
|
|
},
|
|
[input.enabled, upload]
|
|
);
|
|
|
|
const remove = useCallback((clientId: string) => {
|
|
abortRef.current.get(clientId)?.abort();
|
|
abortRef.current.delete(clientId);
|
|
filesRef.current.delete(clientId);
|
|
setAttachments((current) => {
|
|
const removed = current.some((item) => item.clientId === clientId);
|
|
if (removed) attachmentCountRef.current = Math.max(0, attachmentCountRef.current - 1);
|
|
return current.filter((item) => item.clientId !== clientId);
|
|
});
|
|
setErrorKey(null);
|
|
}, []);
|
|
|
|
const retry = useCallback(
|
|
(clientId: string) => {
|
|
const file = filesRef.current.get(clientId);
|
|
if (file) void upload(clientId, file);
|
|
},
|
|
[upload]
|
|
);
|
|
|
|
const clearReady = useCallback((fileIds: readonly string[]) => {
|
|
const sent = new Set(fileIds);
|
|
setAttachments((current) => {
|
|
const removedClientIds = current.filter((item) => item.fileId && sent.has(item.fileId)).map((item) => item.clientId);
|
|
for (const clientId of removedClientIds) filesRef.current.delete(clientId);
|
|
attachmentCountRef.current = Math.max(0, attachmentCountRef.current - removedClientIds.length);
|
|
return current.filter((item) => !item.fileId || !sent.has(item.fileId));
|
|
});
|
|
}, []);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
recordingCancelledRef.current = false;
|
|
if (recorderRef.current?.state === "recording") recorderRef.current.stop();
|
|
}, []);
|
|
|
|
const cancelRecording = useCallback(() => {
|
|
recordingCancelledRef.current = true;
|
|
if (recorderRef.current?.state === "recording") recorderRef.current.stop();
|
|
}, []);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
if (!input.enabled || isRecording || recordingStartingRef.current || recorderRef.current) {
|
|
return;
|
|
}
|
|
if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
|
setErrorKey("chat.attachments.recordingUnsupported");
|
|
return;
|
|
}
|
|
let stream: MediaStream | null = null;
|
|
recordingStartingRef.current = true;
|
|
setIsStartingRecording(true);
|
|
try {
|
|
setErrorKey(null);
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
if (disposedRef.current || !enabledRef.current) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
recordingStreamRef.current = stream;
|
|
recordingChunksRef.current = [];
|
|
recordingBytesRef.current = 0;
|
|
recordingCancelledRef.current = false;
|
|
setRecordingDurationSeconds(0);
|
|
const preferredMimeType = preferredRecordingMimeType();
|
|
const recorder = new MediaRecorder(stream, preferredMimeType ? { mimeType: preferredMimeType } : undefined);
|
|
recorderRef.current = recorder;
|
|
recorder.ondataavailable = (event) => {
|
|
if (event.data.size <= 0 || recordingCancelledRef.current) return;
|
|
if (recordingBytesRef.current + event.data.size > FILE_UPLOAD_MAX_FILE_SIZE_BYTES) {
|
|
recordingCancelledRef.current = true;
|
|
setErrorKey("chat.attachments.recordingTooLarge");
|
|
if (recorder.state === "recording") recorder.stop();
|
|
return;
|
|
}
|
|
recordingBytesRef.current += event.data.size;
|
|
recordingChunksRef.current.push(event.data);
|
|
};
|
|
recorder.onstop = () => {
|
|
const cancelled = recordingCancelledRef.current;
|
|
const durationSeconds = recordingStartedAtRef.current
|
|
? Math.min(MAX_VOICE_RECORDING_SECONDS, Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 1_000)))
|
|
: null;
|
|
const mimeType = canonicalRecordingMimeType(recorder.mimeType);
|
|
const blob = new Blob(recordingChunksRef.current, { type: mimeType });
|
|
const extension = recordingFileExtension(mimeType);
|
|
if (!cancelled && blob.size > 0 && isSupportedRecordingMimeType(mimeType)) {
|
|
addFiles([new File([blob], `voice-note-${Date.now()}.${extension}`, { type: mimeType })], "voice", durationSeconds);
|
|
} else if (!cancelled && blob.size > 0) {
|
|
setErrorKey("chat.attachments.recordingFailed");
|
|
}
|
|
recordingChunksRef.current = [];
|
|
recordingBytesRef.current = 0;
|
|
recordingCancelledRef.current = false;
|
|
clearRecordingTimer();
|
|
recordingStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
recordingStreamRef.current = null;
|
|
recorderRef.current = null;
|
|
setIsRecording(false);
|
|
setRecordingDurationSeconds(0);
|
|
};
|
|
recorder.onerror = () => {
|
|
recordingCancelledRef.current = true;
|
|
recordingChunksRef.current = [];
|
|
clearRecordingTimer();
|
|
recordingStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
recordingStreamRef.current = null;
|
|
recorderRef.current = null;
|
|
setIsRecording(false);
|
|
setRecordingDurationSeconds(0);
|
|
setErrorKey("chat.attachments.recordingFailed");
|
|
};
|
|
recorder.start(1_000);
|
|
recordingStartedAtRef.current = Date.now();
|
|
recordingTimerRef.current = setInterval(() => {
|
|
const startedAt = recordingStartedAtRef.current;
|
|
if (startedAt === null) return;
|
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1_000));
|
|
setRecordingDurationSeconds(Math.min(MAX_VOICE_RECORDING_SECONDS, elapsedSeconds));
|
|
if (elapsedSeconds >= MAX_VOICE_RECORDING_SECONDS && recorder.state === "recording") {
|
|
recordingCancelledRef.current = false;
|
|
recorder.stop();
|
|
}
|
|
}, 250);
|
|
setIsRecording(true);
|
|
} catch {
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
recordingStreamRef.current = null;
|
|
setIsRecording(false);
|
|
setErrorKey("chat.attachments.microphoneDenied");
|
|
} finally {
|
|
recordingStartingRef.current = false;
|
|
if (!disposedRef.current) setIsStartingRecording(false);
|
|
}
|
|
}, [addFiles, clearRecordingTimer, input.enabled, isRecording]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
disposedRef.current = true;
|
|
for (const controller of abortRef.current.values()) controller.abort();
|
|
recordingCancelledRef.current = true;
|
|
clearRecordingTimer();
|
|
if (recorderRef.current?.state === "recording") recorderRef.current.stop();
|
|
recordingStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
},
|
|
[clearRecordingTimer]
|
|
);
|
|
|
|
return {
|
|
attachments,
|
|
isStartingRecording,
|
|
isRecording,
|
|
recordingDurationSeconds,
|
|
errorKey,
|
|
addFiles,
|
|
retry,
|
|
remove,
|
|
clearReady,
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
};
|
|
}
|
|
|
|
function createClientId(): string {
|
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
const RECORDING_MIME_TYPES = new Set(["audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg", "audio/wav"]);
|
|
|
|
function preferredRecordingMimeType(): string | undefined {
|
|
if (typeof MediaRecorder.isTypeSupported !== "function") return undefined;
|
|
return ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/mp4"].find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
|
|
}
|
|
|
|
function canonicalRecordingMimeType(value: string): string {
|
|
const base = (value || "audio/webm").split(";", 1)[0]?.trim().toLowerCase() || "audio/webm";
|
|
return base === "audio/x-m4a" ? "audio/mp4" : base === "audio/x-wav" ? "audio/wav" : base;
|
|
}
|
|
|
|
function isSupportedRecordingMimeType(value: string): boolean {
|
|
return RECORDING_MIME_TYPES.has(value);
|
|
}
|
|
|
|
function recordingFileExtension(mimeType: string): string {
|
|
if (mimeType === "audio/ogg") return "ogg";
|
|
if (mimeType === "audio/mp4") return "m4a";
|
|
if (mimeType === "audio/mpeg") return "mp3";
|
|
if (mimeType === "audio/wav") return "wav";
|
|
return "webm";
|
|
}
|