Channel.svelte 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { Pane, PaneGroup, PaneResizer } from 'paneforge';
  4. import { onDestroy, onMount, tick } from 'svelte';
  5. import { goto } from '$app/navigation';
  6. import { chatId, showSidebar, socket, user } from '$lib/stores';
  7. import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
  8. import Messages from './Messages.svelte';
  9. import MessageInput from './MessageInput.svelte';
  10. import Navbar from './Navbar.svelte';
  11. import Drawer from '../common/Drawer.svelte';
  12. import EllipsisVertical from '../icons/EllipsisVertical.svelte';
  13. import Thread from './Thread.svelte';
  14. export let id = '';
  15. let scrollEnd = true;
  16. let messagesContainerElement = null;
  17. let top = false;
  18. let channel = null;
  19. let messages = null;
  20. let threadId = null;
  21. let typingUsers = [];
  22. let typingUsersTimeout = {};
  23. $: if (id) {
  24. initHandler();
  25. }
  26. const scrollToBottom = () => {
  27. if (messagesContainerElement) {
  28. messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
  29. }
  30. };
  31. const initHandler = async () => {
  32. top = false;
  33. messages = null;
  34. channel = null;
  35. threadId = null;
  36. typingUsers = [];
  37. typingUsersTimeout = {};
  38. channel = await getChannelById(localStorage.token, id).catch((error) => {
  39. return null;
  40. });
  41. if (channel) {
  42. messages = await getChannelMessages(localStorage.token, id, 0);
  43. if (messages) {
  44. scrollToBottom();
  45. if (messages.length < 50) {
  46. top = true;
  47. }
  48. }
  49. } else {
  50. goto('/');
  51. }
  52. };
  53. const channelEventHandler = async (event) => {
  54. if (event.channel_id === id) {
  55. const type = event?.data?.type ?? null;
  56. const data = event?.data?.data ?? null;
  57. if (type === 'message') {
  58. if ((data?.parent_id ?? null) === null) {
  59. messages = [data, ...messages];
  60. if (typingUsers.find((user) => user.id === event.user.id)) {
  61. typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
  62. }
  63. await tick();
  64. if (scrollEnd) {
  65. scrollToBottom();
  66. }
  67. }
  68. } else if (type === 'message:update') {
  69. const idx = messages.findIndex((message) => message.id === data.id);
  70. if (idx !== -1) {
  71. messages[idx] = data;
  72. }
  73. } else if (type === 'message:delete') {
  74. messages = messages.filter((message) => message.id !== data.id);
  75. } else if (type === 'message:reply') {
  76. const idx = messages.findIndex((message) => message.id === data.id);
  77. if (idx !== -1) {
  78. messages[idx] = data;
  79. }
  80. } else if (type.includes('message:reaction')) {
  81. const idx = messages.findIndex((message) => message.id === data.id);
  82. if (idx !== -1) {
  83. messages[idx] = data;
  84. }
  85. } else if (type === 'typing' && event.message_id === null) {
  86. if (event.user.id === $user.id) {
  87. return;
  88. }
  89. typingUsers = data.typing
  90. ? [
  91. ...typingUsers,
  92. ...(typingUsers.find((user) => user.id === event.user.id)
  93. ? []
  94. : [
  95. {
  96. id: event.user.id,
  97. name: event.user.name
  98. }
  99. ])
  100. ]
  101. : typingUsers.filter((user) => user.id !== event.user.id);
  102. if (typingUsersTimeout[event.user.id]) {
  103. clearTimeout(typingUsersTimeout[event.user.id]);
  104. }
  105. typingUsersTimeout[event.user.id] = setTimeout(() => {
  106. typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
  107. }, 5000);
  108. }
  109. }
  110. };
  111. const submitHandler = async ({ content, data }) => {
  112. if (!content && (data?.files ?? []).length === 0) {
  113. return;
  114. }
  115. const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
  116. (error) => {
  117. toast.error(`${error}`);
  118. return null;
  119. }
  120. );
  121. if (res) {
  122. messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
  123. }
  124. };
  125. const onChange = async () => {
  126. $socket?.emit('channel-events', {
  127. channel_id: id,
  128. message_id: null,
  129. data: {
  130. type: 'typing',
  131. data: {
  132. typing: true
  133. }
  134. }
  135. });
  136. };
  137. let mediaQuery;
  138. let largeScreen = false;
  139. onMount(() => {
  140. if ($chatId) {
  141. chatId.set('');
  142. }
  143. $socket?.on('channel-events', channelEventHandler);
  144. mediaQuery = window.matchMedia('(min-width: 1024px)');
  145. const handleMediaQuery = async (e) => {
  146. if (e.matches) {
  147. largeScreen = true;
  148. } else {
  149. largeScreen = false;
  150. }
  151. };
  152. mediaQuery.addEventListener('change', handleMediaQuery);
  153. handleMediaQuery(mediaQuery);
  154. });
  155. onDestroy(() => {
  156. $socket?.off('channel-events', channelEventHandler);
  157. });
  158. </script>
  159. <svelte:head>
  160. <title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
  161. </svelte:head>
  162. <div
  163. class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
  164. ? 'md:max-w-[calc(100%-260px)]'
  165. : ''} w-full max-w-full flex flex-col"
  166. id="channel-container"
  167. >
  168. <PaneGroup direction="horizontal" class="w-full h-full">
  169. <Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative">
  170. <Navbar {channel} />
  171. <div class="flex-1 overflow-y-auto">
  172. {#if channel}
  173. <div
  174. 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"
  175. id="messages-container"
  176. bind:this={messagesContainerElement}
  177. on:scroll={(e) => {
  178. scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
  179. }}
  180. >
  181. {#key id}
  182. <Messages
  183. {channel}
  184. {messages}
  185. {top}
  186. onThread={(id) => {
  187. threadId = id;
  188. }}
  189. onLoad={async () => {
  190. const newMessages = await getChannelMessages(
  191. localStorage.token,
  192. id,
  193. messages.length
  194. );
  195. messages = [...messages, ...newMessages];
  196. if (newMessages.length < 50) {
  197. top = true;
  198. return;
  199. }
  200. }}
  201. />
  202. {/key}
  203. </div>
  204. {/if}
  205. </div>
  206. <div class=" pb-[1rem]">
  207. <MessageInput
  208. id="root"
  209. {typingUsers}
  210. {onChange}
  211. onSubmit={submitHandler}
  212. {scrollToBottom}
  213. {scrollEnd}
  214. />
  215. </div>
  216. </Pane>
  217. {#if !largeScreen}
  218. {#if threadId !== null}
  219. <Drawer
  220. show={threadId !== null}
  221. on:close={() => {
  222. threadId = null;
  223. }}
  224. >
  225. <div class=" {threadId !== null ? ' h-screen w-full' : 'px-6 py-4'} h-full">
  226. <Thread
  227. {threadId}
  228. {channel}
  229. onClose={() => {
  230. threadId = null;
  231. }}
  232. />
  233. </div>
  234. </Drawer>
  235. {/if}
  236. {:else if threadId !== null}
  237. <PaneResizer
  238. class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
  239. >
  240. <div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
  241. <EllipsisVertical className="size-4 invisible group-hover:visible" />
  242. </div>
  243. </PaneResizer>
  244. <Pane defaultSize={50} minSize={30} class="h-full w-full">
  245. <div class="h-full w-full shadow-xl">
  246. <Thread
  247. {threadId}
  248. {channel}
  249. onClose={() => {
  250. threadId = null;
  251. }}
  252. />
  253. </div>
  254. </Pane>
  255. {/if}
  256. </PaneGroup>
  257. </div>