Message.svelte 11 KB

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