|
<script lang="ts"> |
|
import { toast } from 'svelte-sonner'; |
|
import { Pane, PaneGroup, PaneResizer } from 'paneforge'; |
|
|
|
import { onDestroy, onMount, tick } from 'svelte'; |
|
import { goto } from '$app/navigation'; |
|
|
|
import { chatId, showSidebar, socket, user } from '$lib/stores'; |
|
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; |
|
|
|
import Messages from './Messages.svelte'; |
|
import MessageInput from './MessageInput.svelte'; |
|
import Navbar from './Navbar.svelte'; |
|
import Drawer from '../common/Drawer.svelte'; |
|
import EllipsisVertical from '../icons/EllipsisVertical.svelte'; |
|
import Thread from './Thread.svelte'; |
|
|
|
export let id = ''; |
|
|
|
let scrollEnd = true; |
|
let messagesContainerElement = null; |
|
|
|
let top = false; |
|
|
|
let channel = null; |
|
let messages = null; |
|
|
|
let threadId = null; |
|
|
|
let typingUsers = []; |
|
let typingUsersTimeout = {}; |
|
|
|
$: if (id) { |
|
initHandler(); |
|
} |
|
|
|
const scrollToBottom = () => { |
|
if (messagesContainerElement) { |
|
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; |
|
} |
|
}; |
|
|
|
const initHandler = async () => { |
|
top = false; |
|
messages = null; |
|
channel = null; |
|
threadId = null; |
|
|
|
typingUsers = []; |
|
typingUsersTimeout = {}; |
|
|
|
channel = await getChannelById(localStorage.token, id).catch((error) => { |
|
return null; |
|
}); |
|
|
|
if (channel) { |
|
messages = await getChannelMessages(localStorage.token, id, 0); |
|
|
|
if (messages) { |
|
scrollToBottom(); |
|
|
|
if (messages.length < 50) { |
|
top = true; |
|
} |
|
} |
|
} else { |
|
goto('/'); |
|
} |
|
}; |
|
|
|
const channelEventHandler = async (event) => { |
|
if (event.channel_id === id) { |
|
const type = event?.data?.type ?? null; |
|
const data = event?.data?.data ?? null; |
|
|
|
if (type === 'message') { |
|
if ((data?.parent_id ?? null) === null) { |
|
messages = [data, ...messages]; |
|
|
|
if (typingUsers.find((user) => user.id === event.user.id)) { |
|
typingUsers = typingUsers.filter((user) => user.id !== event.user.id); |
|
} |
|
|
|
await tick(); |
|
if (scrollEnd) { |
|
scrollToBottom(); |
|
} |
|
} |
|
} else if (type === 'message:update') { |
|
const idx = messages.findIndex((message) => message.id === data.id); |
|
|
|
if (idx !== -1) { |
|
messages[idx] = data; |
|
} |
|
} else if (type === 'message:delete') { |
|
messages = messages.filter((message) => message.id !== data.id); |
|
} else if (type === 'message:reply') { |
|
const idx = messages.findIndex((message) => message.id === data.id); |
|
|
|
if (idx !== -1) { |
|
messages[idx] = data; |
|
} |
|
} else if (type.includes('message:reaction')) { |
|
const idx = messages.findIndex((message) => message.id === data.id); |
|
if (idx !== -1) { |
|
messages[idx] = data; |
|
} |
|
} else if (type === 'typing' && event.message_id === null) { |
|
if (event.user.id === $user.id) { |
|
return; |
|
} |
|
|
|
typingUsers = data.typing |
|
? [ |
|
...typingUsers, |
|
...(typingUsers.find((user) => user.id === event.user.id) |
|
? [] |
|
: [ |
|
{ |
|
id: event.user.id, |
|
name: event.user.name |
|
} |
|
]) |
|
] |
|
: typingUsers.filter((user) => user.id !== event.user.id); |
|
|
|
if (typingUsersTimeout[event.user.id]) { |
|
clearTimeout(typingUsersTimeout[event.user.id]); |
|
} |
|
|
|
typingUsersTimeout[event.user.id] = setTimeout(() => { |
|
typingUsers = typingUsers.filter((user) => user.id !== event.user.id); |
|
}, 5000); |
|
} |
|
} |
|
}; |
|
|
|
const submitHandler = async ({ content, data }) => { |
|
if (!content && (data?.files ?? []).length === 0) { |
|
return; |
|
} |
|
|
|
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch( |
|
(error) => { |
|
toast.error(`${error}`); |
|
return null; |
|
} |
|
); |
|
|
|
if (res) { |
|
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; |
|
} |
|
}; |
|
|
|
const onChange = async () => { |
|
$socket?.emit('channel-events', { |
|
channel_id: id, |
|
message_id: null, |
|
data: { |
|
type: 'typing', |
|
data: { |
|
typing: true |
|
} |
|
} |
|
}); |
|
}; |
|
|
|
let mediaQuery; |
|
let largeScreen = false; |
|
|
|
onMount(() => { |
|
if ($chatId) { |
|
chatId.set(''); |
|
} |
|
|
|
$socket?.on('channel-events', channelEventHandler); |
|
|
|
mediaQuery = window.matchMedia('(min-width: 1024px)'); |
|
|
|
const handleMediaQuery = async (e) => { |
|
if (e.matches) { |
|
largeScreen = true; |
|
} else { |
|
largeScreen = false; |
|
} |
|
}; |
|
|
|
mediaQuery.addEventListener('change', handleMediaQuery); |
|
handleMediaQuery(mediaQuery); |
|
}); |
|
|
|
onDestroy(() => { |
|
$socket?.off('channel-events', channelEventHandler); |
|
}); |
|
</script> |
|
|
|
<svelte:head> |
|
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title> |
|
</svelte:head> |
|
|
|
<div |
|
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar |
|
? 'md:max-w-[calc(100%-260px)]' |
|
: ''} w-full max-w-full flex flex-col" |
|
id="channel-container" |
|
> |
|
<PaneGroup direction="horizontal" class="w-full h-full"> |
|
<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative"> |
|
<Navbar {channel} /> |
|
|
|
<div class="flex-1 overflow-y-auto"> |
|
{#if channel} |
|
<div |
|
class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto" |
|
id="messages-container" |
|
bind:this={messagesContainerElement} |
|
on:scroll={(e) => { |
|
scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50; |
|
}} |
|
> |
|
{#key id} |
|
<Messages |
|
{channel} |
|
{messages} |
|
{top} |
|
onThread={(id) => { |
|
threadId = id; |
|
}} |
|
onLoad={async () => { |
|
const newMessages = await getChannelMessages( |
|
localStorage.token, |
|
id, |
|
messages.length |
|
); |
|
|
|
messages = [...messages, ...newMessages]; |
|
|
|
if (newMessages.length < 50) { |
|
top = true; |
|
return; |
|
} |
|
}} |
|
/> |
|
{/key} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class=" pb-[1rem]"> |
|
<MessageInput |
|
id="root" |
|
{typingUsers} |
|
{onChange} |
|
onSubmit={submitHandler} |
|
{scrollToBottom} |
|
{scrollEnd} |
|
/> |
|
</div> |
|
</Pane> |
|
|
|
{#if !largeScreen} |
|
{#if threadId !== null} |
|
<Drawer |
|
show={threadId !== null} |
|
on:close={() => { |
|
threadId = null; |
|
}} |
|
> |
|
<div class=" {threadId !== null ? ' h-screen w-full' : 'px-6 py-4'} h-full"> |
|
<Thread |
|
{threadId} |
|
{channel} |
|
onClose={() => { |
|
threadId = null; |
|
}} |
|
/> |
|
</div> |
|
</Drawer> |
|
{/if} |
|
{:else if threadId !== null} |
|
<PaneResizer |
|
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850" |
|
> |
|
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs"> |
|
<EllipsisVertical className="size-4 invisible group-hover:visible" /> |
|
</div> |
|
</PaneResizer> |
|
|
|
<Pane defaultSize={50} minSize={30} class="h-full w-full"> |
|
<div class="h-full w-full shadow-xl"> |
|
<Thread |
|
{threadId} |
|
{channel} |
|
onClose={() => { |
|
threadId = null; |
|
}} |
|
/> |
|
</div> |
|
</Pane> |
|
{/if} |
|
</PaneGroup> |
|
</div> |
|
|