| <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> |
|
|