| <script lang="ts"> |
| import dayjs from 'dayjs'; |
| import relativeTime from 'dayjs/plugin/relativeTime'; |
| import isToday from 'dayjs/plugin/isToday'; |
| import isYesterday from 'dayjs/plugin/isYesterday'; |
| import localizedFormat from 'dayjs/plugin/localizedFormat'; |
| |
| dayjs.extend(relativeTime); |
| dayjs.extend(isToday); |
| dayjs.extend(isYesterday); |
| dayjs.extend(localizedFormat); |
| |
| import { getContext, onMount } from 'svelte'; |
| const i18n = getContext<Writable<i18nType>>('i18n'); |
| |
| import { settings, user, shortCodesToEmojis } from '$lib/stores'; |
| |
| import { WEBUI_BASE_URL } from '$lib/constants'; |
| |
| import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; |
| import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte'; |
| import Name from '$lib/components/chat/Messages/Name.svelte'; |
| import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; |
| import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; |
| import Pencil from '$lib/components/icons/Pencil.svelte'; |
| import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| import Textarea from '$lib/components/common/Textarea.svelte'; |
| import Image from '$lib/components/common/Image.svelte'; |
| import FileItem from '$lib/components/common/FileItem.svelte'; |
| import ProfilePreview from './Message/ProfilePreview.svelte'; |
| import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte'; |
| import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; |
| import ReactionPicker from './Message/ReactionPicker.svelte'; |
| import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; |
| import { formatDate } from '$lib/utils'; |
| |
| export let message; |
| export let showUserProfile = true; |
| export let thread = false; |
| |
| export let onDelete: Function = () => {}; |
| export let onEdit: Function = () => {}; |
| export let onThread: Function = () => {}; |
| export let onReaction: Function = () => {}; |
| |
| let showButtons = false; |
| |
| let edit = false; |
| let editedContent = null; |
| let showDeleteConfirmDialog = false; |
| </script> |
| |
| <ConfirmDialog |
| bind:show={showDeleteConfirmDialog} |
| title={$i18n.t('Delete Message')} |
| message={$i18n.t('Are you sure you want to delete this message?')} |
| onConfirm={async () => { |
| await onDelete(); |
| }} |
| /> |
| |
| {#if message} |
| <div |
| class="flex flex-col justify-between px-5 {showUserProfile |
| ? 'pt-1.5 pb-0.5' |
| : ''} w-full {($settings?.widescreenMode ?? null) |
| ? 'max-w-full' |
| : 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative" |
| > |
| {#if !edit} |
| <div |
| class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10" |
| > |
| <div |
| 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" |
| > |
| <ReactionPicker |
| onClose={() => (showButtons = false)} |
| onSubmit={(name) => { |
| showButtons = false; |
| onReaction(name); |
| }} |
| > |
| <Tooltip content={$i18n.t('Add Reaction')}> |
| <button |
| class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" |
| on:click={() => { |
| showButtons = true; |
| }} |
| > |
| <FaceSmile /> |
| </button> |
| </Tooltip> |
| </ReactionPicker> |
| |
| {#if !thread} |
| <Tooltip content={$i18n.t('Reply in Thread')}> |
| <button |
| class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" |
| on:click={() => { |
| onThread(message.id); |
| }} |
| > |
| <ChatBubbleOvalEllipsis /> |
| </button> |
| </Tooltip> |
| {/if} |
| |
| {#if message.user_id === $user.id || $user.role === 'admin'} |
| <Tooltip content={$i18n.t('Edit')}> |
| <button |
| class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" |
| on:click={() => { |
| edit = true; |
| editedContent = message.content; |
| }} |
| > |
| <Pencil /> |
| </button> |
| </Tooltip> |
| |
| <Tooltip content={$i18n.t('Delete')}> |
| <button |
| class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" |
| on:click={() => (showDeleteConfirmDialog = true)} |
| > |
| <GarbageBin /> |
| </button> |
| </Tooltip> |
| {/if} |
| </div> |
| </div> |
| {/if} |
| |
| <div |
| class=" flex w-full message-{message.id}" |
| id="message-{message.id}" |
| dir={$settings.chatDirection} |
| > |
| <div |
| class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`} |
| > |
| {#if showUserProfile} |
| <ProfilePreview user={message.user}> |
| <ProfileImage |
| src={message.user?.profile_image_url ?? |
| ($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)} |
| className={'size-8 translate-y-1 ml-0.5'} |
| /> |
| </ProfilePreview> |
| {:else} |
| |
| |
| {#if message.created_at} |
| <div |
| 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" |
| > |
| <Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}> |
| {dayjs(message.created_at / 1000000).format('HH:mm')} |
| </Tooltip> |
| </div> |
| {/if} |
| {/if} |
| </div> |
| |
| <div class="flex-auto w-0 pl-1"> |
| {#if showUserProfile} |
| <Name> |
| <div class=" self-end text-base shrink-0 font-medium truncate"> |
| {message?.user?.name} |
| </div> |
| |
| {#if message.created_at} |
| <div |
| class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]" |
| > |
| <Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}> |
| <span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span> |
| </Tooltip> |
| </div> |
| {/if} |
| </Name> |
| {/if} |
| |
| {#if (message?.data?.files ?? []).length > 0} |
| <div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"> |
| {#each message?.data?.files as file} |
| <div> |
| {#if file.type === 'image'} |
| <Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" /> |
| {:else} |
| <FileItem |
| item={file} |
| url={file.url} |
| name={file.name} |
| type={file.type} |
| size={file?.size} |
| colorClassName="bg-white dark:bg-gray-850 " |
| /> |
| {/if} |
| </div> |
| {/each} |
| </div> |
| {/if} |
| |
| {#if edit} |
| <div class="py-2"> |
| <Textarea |
| className=" bg-transparent outline-none w-full resize-none" |
| bind:value={editedContent} |
| onKeydown={(e) => { |
| if (e.key === 'Escape') { |
| document.getElementById('close-edit-message-button')?.click(); |
| } |
| |
| const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey; |
| const isEnterPressed = e.key === 'Enter'; |
| |
| if (isCmdOrCtrlPressed && isEnterPressed) { |
| document.getElementById('confirm-edit-message-button')?.click(); |
| } |
| }} |
| /> |
| <div class=" mt-2 mb-1 flex justify-end text-sm font-medium"> |
| <div class="flex space-x-1.5"> |
| <button |
| id="close-edit-message-button" |
| 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" |
| on:click={() => { |
| edit = false; |
| editedContent = null; |
| }} |
| > |
| {$i18n.t('Cancel')} |
| </button> |
| |
| <button |
| id="confirm-edit-message-button" |
| 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" |
| on:click={async () => { |
| onEdit(editedContent); |
| edit = false; |
| editedContent = null; |
| }} |
| > |
| {$i18n.t('Save')} |
| </button> |
| </div> |
| </div> |
| </div> |
| {:else} |
| <div class=" min-w-full markdown-prose"> |
| <Markdown |
| id={message.id} |
| content={message.content} |
| />{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]" |
| >(edited)</span |
| >{/if} |
| </div> |
| |
| {#if (message?.reactions ?? []).length > 0} |
| <div> |
| <div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2"> |
| {#each message.reactions as reaction} |
| <Tooltip content={`:${reaction.name}:`}> |
| <button |
| class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes( |
| $user.id |
| ) |
| ? ' bg-blue-300/10 outline outline-blue-500/50 outline-1' |
| : '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'}" |
| on:click={() => { |
| onReaction(reaction.name); |
| }} |
| > |
| {#if $shortCodesToEmojis[reaction.name]} |
| <img |
| src="/assets/emojis/{$shortCodesToEmojis[ |
| reaction.name |
| ].toLowerCase()}.svg" |
| alt={reaction.name} |
| class=" size-4" |
| loading="lazy" |
| /> |
| {:else} |
| <div> |
| {reaction.name} |
| </div> |
| {/if} |
| |
| {#if reaction.user_ids.length > 0} |
| <div class="text-xs font-medium text-gray-500 dark:text-gray-400"> |
| {reaction.user_ids?.length} |
| </div> |
| {/if} |
| </button> |
| </Tooltip> |
| {/each} |
| |
| <ReactionPicker |
| onSubmit={(name) => { |
| onReaction(name); |
| }} |
| > |
| <Tooltip content={$i18n.t('Add Reaction')}> |
| <div |
| 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" |
| > |
| <FaceSmile /> |
| </div> |
| </Tooltip> |
| </ReactionPicker> |
| </div> |
| </div> |
| {/if} |
| |
| {#if !thread && message.reply_count > 0} |
| <div class="flex items-center gap-1.5 -mt-0.5 mb-1.5"> |
| <button |
| 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" |
| on:click={() => { |
| onThread(message.id); |
| }} |
| > |
| <span class="font-medium mr-1"> |
| {$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span |
| ><span> |
| {' - '}{$i18n.t('Last reply')} |
| {dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span |
| > |
| |
| <span class="ml-1"> |
| <ChevronRight className="size-2.5" strokeWidth="3" /> |
| </span> |
| {$i18n.t('View Replies')} |
| </button> |
| </div> |
| {/if} |
| {/if} |
| </div> |
| </div> |
| </div> |
| {/if} |
| |