Thread.svelte 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. <script lang="ts">
  2. import { goto } from '$app/navigation';
  3. import { socket, user } from '$lib/stores';
  4. import { getChannelThreadMessages, sendMessage } from '$lib/apis/channels';
  5. import XMark from '$lib/components/icons/XMark.svelte';
  6. import MessageInput from './MessageInput.svelte';
  7. import Messages from './Messages.svelte';
  8. import { onDestroy, onMount, tick, getContext } from 'svelte';
  9. import { toast } from 'svelte-sonner';
  10. import Spinner from '../common/Spinner.svelte';
  11. const i18n = getContext('i18n');
  12. export let threadId = null;
  13. export let channel = null;
  14. export let onClose = () => {};
  15. let messages = null;
  16. let top = false;
  17. let messagesContainerElement = null;
  18. let chatInputElement = null;
  19. let replyToMessage = null;
  20. let typingUsers = [];
  21. let typingUsersTimeout = {};
  22. $: if (threadId) {
  23. initHandler();
  24. }
  25. const scrollToBottom = () => {
  26. messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
  27. };
  28. const initHandler = async () => {
  29. messages = null;
  30. top = false;
  31. typingUsers = [];
  32. typingUsersTimeout = {};
  33. if (channel) {
  34. messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
  35. if (messages.length < 50) {
  36. top = true;
  37. }
  38. await tick();
  39. scrollToBottom();
  40. } else {
  41. goto('/');
  42. }
  43. };
  44. const channelEventHandler = async (event) => {
  45. console.debug(event);
  46. if (event.channel_id === channel.id) {
  47. const type = event?.data?.type ?? null;
  48. const data = event?.data?.data ?? null;
  49. if (type === 'message') {
  50. if ((data?.parent_id ?? null) === threadId) {
  51. if (messages) {
  52. messages = [data, ...messages];
  53. if (typingUsers.find((user) => user.id === event.user.id)) {
  54. typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
  55. }
  56. }
  57. }
  58. } else if (type === 'message:update') {
  59. if (messages) {
  60. const idx = messages.findIndex((message) => message.id === data.id);
  61. if (idx !== -1) {
  62. messages[idx] = data;
  63. }
  64. }
  65. } else if (type === 'message:delete') {
  66. if (messages) {
  67. messages = messages.filter((message) => message.id !== data.id);
  68. }
  69. } else if (type.includes('message:reaction')) {
  70. if (messages) {
  71. const idx = messages.findIndex((message) => message.id === data.id);
  72. if (idx !== -1) {
  73. messages[idx] = data;
  74. }
  75. }
  76. } else if (type === 'typing' && event.message_id === threadId) {
  77. if (event.user.id === $user?.id) {
  78. return;
  79. }
  80. typingUsers = data.typing
  81. ? [
  82. ...typingUsers,
  83. ...(typingUsers.find((user) => user.id === event.user.id)
  84. ? []
  85. : [
  86. {
  87. id: event.user.id,
  88. name: event.user.name
  89. }
  90. ])
  91. ]
  92. : typingUsers.filter((user) => user.id !== event.user.id);
  93. if (typingUsersTimeout[event.user.id]) {
  94. clearTimeout(typingUsersTimeout[event.user.id]);
  95. }
  96. typingUsersTimeout[event.user.id] = setTimeout(() => {
  97. typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
  98. }, 5000);
  99. }
  100. }
  101. };
  102. const submitHandler = async ({ content, data }) => {
  103. if (!content && (data?.files ?? []).length === 0) {
  104. return;
  105. }
  106. const res = await sendMessage(localStorage.token, channel.id, {
  107. parent_id: threadId,
  108. reply_to_id: replyToMessage?.id ?? null,
  109. content: content,
  110. data: data
  111. }).catch((error) => {
  112. toast.error(`${error}`);
  113. return null;
  114. });
  115. replyToMessage = null;
  116. };
  117. const onChange = async () => {
  118. $socket?.emit('channel-events', {
  119. channel_id: channel.id,
  120. message_id: threadId,
  121. data: {
  122. type: 'typing',
  123. data: {
  124. typing: true
  125. }
  126. }
  127. });
  128. };
  129. onMount(() => {
  130. $socket?.on('channel-events', channelEventHandler);
  131. });
  132. onDestroy(() => {
  133. $socket?.off('channel-events', channelEventHandler);
  134. });
  135. </script>
  136. {#if channel}
  137. <div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850">
  138. <div class="sticky top-0 flex items-center justify-between px-3.5 py-3">
  139. <div class=" font-medium text-lg">{$i18n.t('Thread')}</div>
  140. <div>
  141. <button
  142. class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-2"
  143. on:click={() => {
  144. onClose();
  145. }}
  146. >
  147. <XMark />
  148. </button>
  149. </div>
  150. </div>
  151. <div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
  152. {#if messages !== null}
  153. <Messages
  154. id={threadId}
  155. {channel}
  156. {top}
  157. {messages}
  158. {replyToMessage}
  159. thread={true}
  160. onReply={async (message) => {
  161. replyToMessage = message;
  162. await tick();
  163. chatInputElement?.focus();
  164. }}
  165. onLoad={async () => {
  166. const newMessages = await getChannelThreadMessages(
  167. localStorage.token,
  168. channel.id,
  169. threadId,
  170. messages.length
  171. );
  172. messages = [...messages, ...newMessages];
  173. if (newMessages.length < 50) {
  174. top = true;
  175. return;
  176. }
  177. }}
  178. />
  179. {:else}
  180. <div class="w-full flex justify-center pt-5 pb-10">
  181. <Spinner />
  182. </div>
  183. {/if}
  184. <div class=" pb-[1rem] px-2.5 w-full">
  185. <MessageInput
  186. bind:replyToMessage
  187. bind:chatInputElement
  188. id={threadId}
  189. disabled={!channel?.write_access}
  190. placeholder={!channel?.write_access
  191. ? $i18n.t('You do not have permission to send messages in this thread.')
  192. : $i18n.t('Reply to thread...')}
  193. typingUsersClassName="from-gray-50 dark:from-gray-850"
  194. {typingUsers}
  195. userSuggestions={true}
  196. channelSuggestions={true}
  197. {onChange}
  198. onSubmit={submitHandler}
  199. />
  200. </div>
  201. </div>
  202. </div>
  203. {/if}