1
0

Channel.svelte 7.6 KB

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