Message.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. <script lang="ts">
  2. import dayjs from 'dayjs';
  3. import relativeTime from 'dayjs/plugin/relativeTime';
  4. import isToday from 'dayjs/plugin/isToday';
  5. import isYesterday from 'dayjs/plugin/isYesterday';
  6. dayjs.extend(relativeTime);
  7. dayjs.extend(isToday);
  8. dayjs.extend(isYesterday);
  9. import { getContext, onMount } from 'svelte';
  10. const i18n = getContext<Writable<i18nType>>('i18n');
  11. import { settings, user, shortCodesToEmojis } from '$lib/stores';
  12. import { WEBUI_BASE_URL } from '$lib/constants';
  13. import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
  14. import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
  15. import Name from '$lib/components/chat/Messages/Name.svelte';
  16. import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  17. import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
  18. import Pencil from '$lib/components/icons/Pencil.svelte';
  19. import Tooltip from '$lib/components/common/Tooltip.svelte';
  20. import Textarea from '$lib/components/common/Textarea.svelte';
  21. import Image from '$lib/components/common/Image.svelte';
  22. import FileItem from '$lib/components/common/FileItem.svelte';
  23. import ProfilePreview from './Message/ProfilePreview.svelte';
  24. import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte';
  25. import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
  26. import ReactionPicker from './Message/ReactionPicker.svelte';
  27. import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
  28. export let message;
  29. export let showUserProfile = true;
  30. export let thread = false;
  31. export let onDelete: Function = () => {};
  32. export let onEdit: Function = () => {};
  33. export let onThread: Function = () => {};
  34. export let onReaction: Function = () => {};
  35. let showButtons = false;
  36. let edit = false;
  37. let editedContent = null;
  38. let showDeleteConfirmDialog = false;
  39. const formatDate = (inputDate) => {
  40. const date = dayjs(inputDate);
  41. const now = dayjs();
  42. if (date.isToday()) {
  43. return `Today at ${date.format('HH:mm')}`;
  44. } else if (date.isYesterday()) {
  45. return `Yesterday at ${date.format('HH:mm')}`;
  46. } else {
  47. return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
  48. }
  49. };
  50. </script>
  51. <ConfirmDialog
  52. bind:show={showDeleteConfirmDialog}
  53. title={$i18n.t('Delete Message')}
  54. message={$i18n.t('Are you sure you want to delete this message?')}
  55. onConfirm={async () => {
  56. await onDelete();
  57. }}
  58. />
  59. {#if message}
  60. <div
  61. class="flex flex-col justify-between px-5 {showUserProfile
  62. ? 'pt-1.5 pb-0.5'
  63. : ''} w-full {($settings?.widescreenMode ?? null)
  64. ? 'max-w-full'
  65. : 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
  66. >
  67. {#if (message.user_id === $user.id || $user.role === 'admin') && !edit}
  68. <div
  69. class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-30"
  70. >
  71. <div
  72. class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
  73. >
  74. <ReactionPicker
  75. onClose={() => (showButtons = false)}
  76. onSubmit={(name) => {
  77. showButtons = false;
  78. onReaction(name);
  79. }}
  80. >
  81. <Tooltip content={$i18n.t('Add Reaction')}>
  82. <button
  83. class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
  84. on:click={() => {
  85. showButtons = true;
  86. }}
  87. >
  88. <FaceSmile />
  89. </button>
  90. </Tooltip>
  91. </ReactionPicker>
  92. {#if !thread}
  93. <Tooltip content={$i18n.t('Reply in Thread')}>
  94. <button
  95. class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
  96. on:click={() => {
  97. onThread(message.id);
  98. }}
  99. >
  100. <ChatBubbleOvalEllipsis />
  101. </button>
  102. </Tooltip>
  103. {/if}
  104. <Tooltip content={$i18n.t('Edit')}>
  105. <button
  106. class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
  107. on:click={() => {
  108. edit = true;
  109. editedContent = message.content;
  110. }}
  111. >
  112. <Pencil />
  113. </button>
  114. </Tooltip>
  115. <Tooltip content={$i18n.t('Delete')}>
  116. <button
  117. class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
  118. on:click={() => (showDeleteConfirmDialog = true)}
  119. >
  120. <GarbageBin />
  121. </button>
  122. </Tooltip>
  123. </div>
  124. </div>
  125. {/if}
  126. <div
  127. class=" flex w-full message-{message.id}"
  128. id="message-{message.id}"
  129. dir={$settings.chatDirection}
  130. >
  131. <div
  132. class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
  133. >
  134. {#if showUserProfile}
  135. <ProfilePreview user={message.user}>
  136. <ProfileImage
  137. src={message.user?.profile_image_url ??
  138. ($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
  139. className={'size-8 translate-y-1 ml-0.5'}
  140. />
  141. </ProfilePreview>
  142. {:else}
  143. <!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
  144. {#if message.created_at}
  145. <div
  146. class="mt-1.5 flex flex-shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
  147. >
  148. <Tooltip
  149. content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
  150. >
  151. {dayjs(message.created_at / 1000000).format('HH:mm')}
  152. </Tooltip>
  153. </div>
  154. {/if}
  155. {/if}
  156. </div>
  157. <div class="flex-auto w-0 pl-1">
  158. {#if showUserProfile}
  159. <Name>
  160. <div class=" self-end text-base font-medium">
  161. {message?.user?.name}
  162. </div>
  163. {#if message.created_at}
  164. <div
  165. class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
  166. >
  167. <Tooltip
  168. content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
  169. >
  170. {formatDate(message.created_at / 1000000)}
  171. </Tooltip>
  172. </div>
  173. {/if}
  174. </Name>
  175. {/if}
  176. {#if (message?.data?.files ?? []).length > 0}
  177. <div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
  178. {#each message?.data?.files as file}
  179. <div>
  180. {#if file.type === 'image'}
  181. <Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
  182. {:else}
  183. <FileItem
  184. item={file}
  185. url={file.url}
  186. name={file.name}
  187. type={file.type}
  188. size={file?.size}
  189. colorClassName="bg-white dark:bg-gray-850 "
  190. />
  191. {/if}
  192. </div>
  193. {/each}
  194. </div>
  195. {/if}
  196. {#if edit}
  197. <div class="py-2">
  198. <Textarea
  199. className=" bg-transparent outline-none w-full resize-none"
  200. bind:value={editedContent}
  201. onKeydown={(e) => {
  202. if (e.key === 'Escape') {
  203. document.getElementById('close-edit-message-button')?.click();
  204. }
  205. const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
  206. const isEnterPressed = e.key === 'Enter';
  207. if (isCmdOrCtrlPressed && isEnterPressed) {
  208. document.getElementById('confirm-edit-message-button')?.click();
  209. }
  210. }}
  211. />
  212. <div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
  213. <div class="flex space-x-1.5">
  214. <button
  215. id="close-edit-message-button"
  216. class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
  217. on:click={() => {
  218. edit = false;
  219. editedContent = null;
  220. }}
  221. >
  222. {$i18n.t('Cancel')}
  223. </button>
  224. <button
  225. id="confirm-edit-message-button"
  226. class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
  227. on:click={async () => {
  228. onEdit(editedContent);
  229. edit = false;
  230. editedContent = null;
  231. }}
  232. >
  233. {$i18n.t('Save')}
  234. </button>
  235. </div>
  236. </div>
  237. </div>
  238. {:else}
  239. <div class=" min-w-full markdown-prose">
  240. <Markdown
  241. id={message.id}
  242. content={message.content}
  243. />{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
  244. >(edited)</span
  245. >{/if}
  246. </div>
  247. {#if (message?.reactions ?? []).length > 0}
  248. <div>
  249. <div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
  250. {#each message.reactions as reaction}
  251. <Tooltip content={`:${reaction.name}:`}>
  252. <button
  253. class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
  254. $user.id
  255. )
  256. ? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
  257. : 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
  258. on:click={() => {
  259. onReaction(reaction.name);
  260. }}
  261. >
  262. {#if $shortCodesToEmojis[reaction.name]}
  263. <img
  264. src="/assets/emojis/{$shortCodesToEmojis[
  265. reaction.name
  266. ].toLowerCase()}.svg"
  267. alt={reaction.name}
  268. class=" size-4"
  269. loading="lazy"
  270. />
  271. {:else}
  272. <div>
  273. {reaction.name}
  274. </div>
  275. {/if}
  276. {#if reaction.user_ids.length > 0}
  277. <div class="text-xs font-medium text-gray-500 dark:text-gray-400">
  278. {reaction.user_ids?.length}
  279. </div>
  280. {/if}
  281. </button>
  282. </Tooltip>
  283. {/each}
  284. <ReactionPicker
  285. onSubmit={(name) => {
  286. onReaction(name);
  287. }}
  288. >
  289. <Tooltip content={$i18n.t('Add Reaction')}>
  290. <div
  291. class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
  292. >
  293. <FaceSmile />
  294. </div>
  295. </Tooltip>
  296. </ReactionPicker>
  297. </div>
  298. </div>
  299. {/if}
  300. {#if message.reply_count > 0}
  301. <div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
  302. <button
  303. class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
  304. on:click={() => {
  305. onThread(message.id);
  306. }}
  307. >
  308. <span class="font-medium mr-1">
  309. {$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
  310. ><span>
  311. {' - '}{$i18n.t('Last reply')}
  312. {dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
  313. >
  314. <span class="ml-1">
  315. <ChevronRight className="size-2.5" strokeWidth="3" />
  316. </span>
  317. <!-- {$i18n.t('View Replies')} -->
  318. </button>
  319. </div>
  320. {/if}
  321. {/if}
  322. </div>
  323. </div>
  324. </div>
  325. {/if}