Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/useChatAttachmentUploads.ts
T

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";
}