From 8a4547104c4f88c02b607ad7b6849dc22f377c28 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 1 Oct 2026 08:13:45 +0400 Subject: [PATCH] refac --- src/lib/components/channel/Channel.svelte | 22 +- .../components/channel/MessageInput.svelte | 255 ++++++++++++------ src/lib/components/channel/Thread.svelte | 24 +- .../MessageInput/QueuedMessageItem.svelte | 51 ++-- src/lib/stores/index.ts | 13 + src/lib/utils/channelQueue.ts | 51 ++++ src/routes/+layout.svelte | 19 +- 7 files changed, 314 insertions(+), 121 deletions(-) create mode 100644 src/lib/utils/channelQueue.ts diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index a2d2d335ec..f1e3141852 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -225,7 +225,17 @@ } }; - const submitHandler = async ({ content, data }) => { + const submitHandler = async ({ + content, + data, + channel_id, + reply_to_message + }: { + content: string; + data: any; + channel_id: string; + reply_to_message: any; + }) => { if (!content && (data?.files ?? []).length === 0) { return; } @@ -236,7 +246,7 @@ temp_id: tempId, content: content, data: data, - reply_to_id: replyToMessage?.id ?? null + reply_to_id: reply_to_message?.id ?? null }; const ts = Date.now() * 1000000; // nanoseconds @@ -246,23 +256,21 @@ id: tempId, user_id: $user?.id, user: $user, - reply_to_message: replyToMessage ?? null, + reply_to_message: reply_to_message ?? null, created_at: ts, updated_at: ts }, ...messages ]; - const res = await sendMessage(localStorage.token, id, message).catch((error) => { + const res = await sendMessage(localStorage.token, channel_id, message).catch((error) => { toast.error(`${error}`); return null; }); - if (res) { + if (res && id === channel_id && messagesContainerElement) { messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; } - - replyToMessage = null; }; const onChange = async () => { diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index b9cfaef618..edc2af41ca 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -3,10 +3,13 @@ import { v4 as uuidv4 } from 'uuid'; import { tick, getContext, onMount } from 'svelte'; + import { get } from 'svelte/store'; const i18n: any = getContext('i18n'); - import { config, mobile, settings, socket, user } from '$lib/stores'; + import { channelRequestQueues, config, mobile, settings, socket, user } from '$lib/stores'; + import { processingQueueChannels, processNextInQueue } from '$lib/utils/channelQueue'; + import QueuedMessageItem from '../chat/MessageInput/QueuedMessageItem.svelte'; import { convertHeicToJpeg, isHeicImage, @@ -43,10 +46,10 @@ import XMark from '../icons/XMark.svelte'; export let placeholder = $i18n.t('Type here...'); - export let chatInputElement; + export let chatInputElement: any; - export let id = null; - export let channel = null; + export let id: string | null = null; + export let channel: any = null; export let typingUsers = []; export let inputLoading = false; @@ -65,7 +68,7 @@ export let userSuggestions = false; export let channelSuggestions = false; - export let replyToMessage = null; + export let replyToMessage: any = null; export let typingUsersClassName = 'from-white dark:from-gray-900'; @@ -74,7 +77,11 @@ let recording = false; let content = ''; - let files = []; + let files: any[] = []; + let isDestroyed = false; + $: parentId = id === 'root' ? null : id; + $: queueKey = JSON.stringify([$user?.id, channel?.id, parentId]); + $: messageQueue = $channelRequestQueues[queueKey] ?? []; let filesInputElement; let inputFiles; @@ -378,69 +385,66 @@ return; } - if (file['type'].startsWith('image/') || isHeicImage(file)) { - const compressImageHandler = async (imageUrl, settings = {}, config = {}) => { - // Quick shortcut so we don’t do unnecessary work. - const settingsCompression = - (settings?.imageCompression && settings?.imageCompressionInChannels) ?? false; - const configWidth = config?.file?.image_compression?.width ?? null; - const configHeight = config?.file?.image_compression?.height ?? null; - - // If neither settings nor config wants compression, return original URL. - if (!settingsCompression && !configWidth && !configHeight) { - return imageUrl; - } - - // Default to null (no compression unless set) - let width = null; - let height = null; - - // If user/settings want compression, pick their preferred size. - if (settingsCompression) { - width = settings?.imageCompressionSize?.width ?? null; - height = settings?.imageCompressionSize?.height ?? null; - } - - // Apply config limits as an upper bound if any - if (configWidth && (width === null || width > configWidth)) { - width = configWidth; - } - if (configHeight && (height === null || height > configHeight)) { - height = configHeight; - } - - // Do the compression if required - if (width || height) { - return await compressImage(imageUrl, width, height); - } - return imageUrl; - }; - - const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file; - let reader = new FileReader(); - - reader.onload = async (event) => { - let imageUrl = event.target.result; - - // Compress the image if settings or config require it - imageUrl = await compressImageHandler(imageUrl, $settings, $config); - - const blob = await (await fetch(imageUrl)).blob(); - const compressedFile = new File([blob], imageFile.name, { type: imageFile.type }); - - uploadFileHandler(compressedFile, false); - }; - - reader.readAsDataURL(imageFile); - } else { - uploadFileHandler(file); - } + const image = file.type.startsWith('image/') || isHeicImage(file); + uploadFileHandler(file, !image, image); }); }; - const uploadFileHandler = async (file, process = true) => { + const prepareImageFileHandler = async (file: File) => { + const compressImageHandler = async (imageUrl: string, settings: any = {}, config: any = {}) => { + // Quick shortcut so we don’t do unnecessary work. + const settingsCompression = + (settings?.imageCompression && settings?.imageCompressionInChannels) ?? false; + const configWidth = config?.file?.image_compression?.width ?? null; + const configHeight = config?.file?.image_compression?.height ?? null; + + // If neither settings nor config wants compression, return original URL. + if (!settingsCompression && !configWidth && !configHeight) { + return imageUrl; + } + + // Default to null (no compression unless set) + let width = null; + let height = null; + + // If user/settings want compression, pick their preferred size. + if (settingsCompression) { + width = settings?.imageCompressionSize?.width ?? null; + height = settings?.imageCompressionSize?.height ?? null; + } + + // Apply config limits as an upper bound if any + if (configWidth && (width === null || width > configWidth)) { + width = configWidth; + } + if (configHeight && (height === null || height > configHeight)) { + height = configHeight; + } + + // Do the compression if required + if (width || height) { + return await compressImage(imageUrl, width, height); + } + return imageUrl; + }; + const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file; + const imageUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(imageFile); + }); + const compressed = await compressImageHandler(imageUrl, $settings, $config); + const blob = await (await fetch(compressed as string)).blob(); + return new File([blob], imageFile.name, { type: imageFile.type }); + }; + + const uploadFileHandler = async (file: File, process = true, prepareImage = false) => { + const targetChannelId = channel.id; + const targetQueueKey = queueKey; + const senderId = $user?.id; const tempItemId = uuidv4(); - const fileItem = { + const fileItem: any = { type: 'file', file: '', id: null, @@ -461,10 +465,12 @@ files = [...files, fileItem]; try { + if (prepareImage) file = await prepareImageFileHandler(file); + if (get(user)?.id !== senderId) return; // During the file upload, file content is automatically extracted. // If the file is an audio file, provide the language for STT. let metadata = { - channel_id: channel.id, + channel_id: targetChannelId, // If the file is an audio file, provide the language for STT. ...((file.type.startsWith('audio/') || file.type.startsWith('video/')) && $settings?.audio?.stt?.language @@ -498,11 +504,23 @@ files = files; } else { - files = files.filter((item) => item?.itemId !== tempItemId); + throw new Error($i18n.t('Failed to upload file.')); } } catch (e) { + fileItem.status = 'error'; + fileItem.error = String(e); toast.error(`${e}`); files = files.filter((item) => item?.itemId !== tempItemId); + } finally { + channelRequestQueues.update((q) => { + for (const item of q[targetQueueKey] ?? []) { + item.files = item.files.map((file) => + file.itemId === fileItem.itemId ? fileItem : file + ); + } + return q; + }); + void processNextInQueue(targetQueueKey); } }; @@ -541,28 +559,72 @@ draggedOver = false; }; + const editQueuedMessage = (messageId: string) => { + if (content || files.length || replyToMessage) return; + const entry = messageQueue.find((item) => item.id === messageId); + if (!entry || entry.sending) return; + deleteQueuedMessage(messageId); + content = entry.prompt; + files = entry.files; + replyToMessage = entry.reply_to_message; + chatInputElement?.setText(content); + chatInputElement?.focus(); + }; + + const deleteQueuedMessage = (messageId: string) => { + channelRequestQueues.update((q) => ({ + ...q, + [queueKey]: (q[queueKey] ?? []).filter((item) => item.id !== messageId || item.sending) + })); + void processNextInQueue(queueKey); + }; + const submitHandler = async () => { - if (content === '' && files.length === 0) { - return; - } - - onSubmit({ - content, - data: { - files: files - } - }); + if (disabled || !$user?.id || (content === '' && files.length === 0)) return; + const targetQueueKey = queueKey; + const entry = { + id: uuidv4(), + prompt: content, + files, + user_id: $user.id, + channel_id: channel.id, + parent_id: parentId, + reply_to_message: replyToMessage + }; + const queued = + processingQueueChannels.has(targetQueueKey) || + messageQueue.length > 0 || + files.some((file) => ['uploading', 'error'].includes(file.status)); content = ''; files = []; + replyToMessage = null; + chatInputElement?.setText(''); - if (chatInputElement) { - chatInputElement?.setText(''); - - await tick(); - - chatInputElement.focus(); + if (queued) { + channelRequestQueues.update((q) => ({ + ...q, + [targetQueueKey]: [...(q[targetQueueKey] ?? []), entry] + })); + void processNextInQueue(targetQueueKey); + } else { + processingQueueChannels.add(targetQueueKey); + try { + await onSubmit({ + content: entry.prompt, + data: { files: entry.files }, + channel_id: entry.channel_id, + parent_id: entry.parent_id, + reply_to_message: entry.reply_to_message + }); + } finally { + processingQueueChannels.delete(targetQueueKey); + void processNextInQueue(targetQueueKey); + } } + + await tick(); + if (!isDestroyed && targetQueueKey === queueKey) chatInputElement?.focus(); }; $: if (content) { @@ -570,6 +632,9 @@ } onMount(() => { + const unsubscribe = channelRequestQueues.subscribe(() => { + files = [...files]; + }); suggestions = [ { char: '@', @@ -659,7 +724,6 @@ window.addEventListener('keydown', handleKeyDown); - let isDestroyed = false; let dropzoneElement: HTMLElement | null = null; const initialize = async () => { await tick(); @@ -675,6 +739,7 @@ initialize(); return () => { + unsubscribe(); isDestroyed = true; window.removeEventListener('keydown', handleKeyDown); @@ -802,11 +867,31 @@ /> {:else}
{ submitHandler(); }} > + {#if messageQueue.length > 0} +
+ {#each messageQueue as entry (entry.id)} + processNextInQueue(queueKey, messageId)} + onEdit={content || files.length || replyToMessage + ? undefined + : editQueuedMessage} + onDelete={deleteQueuedMessage} + /> + {/each} +
+ {/if}
user.id === event.user.id)) { typingUsers = typingUsers.filter((user) => user.id !== event.user.id); @@ -141,22 +141,32 @@ } }; - const submitHandler = async ({ content, data }) => { + const submitHandler = async ({ + content, + data, + channel_id, + parent_id, + reply_to_message + }: { + content: string; + data: any; + channel_id: string; + parent_id: string | null; + reply_to_message: any; + }) => { if (!content && (data?.files ?? []).length === 0) { return; } - const res = await sendMessage(localStorage.token, channel.id, { - parent_id: threadId, - reply_to_id: replyToMessage?.id ?? null, + const res = await sendMessage(localStorage.token, channel_id, { + parent_id: parent_id ?? undefined, + reply_to_id: reply_to_message?.id ?? null, content: content, data: data }).catch((error) => { toast.error(`${error}`); return null; }); - - replyToMessage = null; }; const onChange = async () => { diff --git a/src/lib/components/chat/MessageInput/QueuedMessageItem.svelte b/src/lib/components/chat/MessageInput/QueuedMessageItem.svelte index 4e3cb92718..1abb3d61f1 100644 --- a/src/lib/components/chat/MessageInput/QueuedMessageItem.svelte +++ b/src/lib/components/chat/MessageInput/QueuedMessageItem.svelte @@ -9,14 +9,25 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; import { isRasterImageContentType } from '$lib/utils'; - const i18n = getContext('i18n'); + const i18n = getContext('i18n'); export let id: string; export let content: string; export let files: any[] = []; export let onSendNow: (id: string) => void; - export let onEdit: (id: string) => void; + export let onEdit: ((id: string) => void) | undefined; export let onDelete: (id: string) => void; + export let sending = false; + export let error = ''; + $: uploadFailed = files.some((file) => file.status === 'error'); + $: sendDisabled = sending || files.some((file) => ['uploading', 'error'].includes(file.status)); + $: sendLabel = sending + ? $i18n.t('Sending...') + : uploadFailed + ? $i18n.t('Upload failed') + : sendDisabled + ? $i18n.t('Waiting for upload') + : $i18n.t('Send now');
@@ -71,37 +82,33 @@

{/if} - {#if files.some((file) => file.status === 'error')} + {#if uploadFailed} {$i18n.t('Upload failed')} + {:else if error} + {$i18n.t('Failed to send message')} + {:else if sending} + {/if}
- ['uploading', 'error'].includes(file.status)) - ? $i18n.t('Waiting for upload') - : $i18n.t('Send now')} - > +