714 lines
22 KiB
Svelte
714 lines
22 KiB
Svelte
<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 { formatDate } from '$lib/utils';
|
|
|
|
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
|
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
|
import { getMessageData } from '$lib/apis/channels';
|
|
|
|
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/ChatBubble.svelte';
|
|
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
|
|
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
|
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
|
import Emoji from '$lib/components/common/Emoji.svelte';
|
|
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
|
|
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
|
|
import PinSlash from '$lib/components/icons/PinSlash.svelte';
|
|
import Pin from '$lib/components/icons/Pin.svelte';
|
|
|
|
export let className = '';
|
|
|
|
export let message;
|
|
export let channel;
|
|
|
|
export let showUserProfile = true;
|
|
export let thread = false;
|
|
export let id = null;
|
|
|
|
export let replyToMessage = false;
|
|
export let disabled = false;
|
|
export let pending = false;
|
|
|
|
export let onDelete: Function = () => {};
|
|
export let onEdit: Function = () => {};
|
|
export let onReply: Function = () => {};
|
|
export let onPin: Function = () => {};
|
|
export let onThread: Function = () => {};
|
|
export let onReaction: Function = () => {};
|
|
|
|
let showButtons = false;
|
|
|
|
let edit = false;
|
|
let editedContent = null;
|
|
let showDeleteConfirmDialog = false;
|
|
$: renderedMessageId = message ? (id ? `${id}-${message.id}` : message.id) : null;
|
|
$: replyToMessageId = message?.reply_to_message
|
|
? id
|
|
? `${id}-${message.reply_to_message.id}`
|
|
: message.reply_to_message.id
|
|
: null;
|
|
|
|
// Swipe-to-reply state
|
|
let swipeStartX = 0;
|
|
let swipeStartY = 0;
|
|
let swipeOffsetX = 0;
|
|
let isSwiping = false;
|
|
let swipeLocked = false; // locked to horizontal once determined
|
|
let swipeMessageEl: HTMLElement | null = null;
|
|
|
|
const SWIPE_THRESHOLD = 60;
|
|
const SWIPE_MAX = 100;
|
|
const SWIPE_DEAD_ZONE = 10;
|
|
|
|
const handleTouchStart = (e: TouchEvent) => {
|
|
if (disabled || edit || !onReply) return;
|
|
const touch = e.touches[0];
|
|
swipeStartX = touch.clientX;
|
|
swipeStartY = touch.clientY;
|
|
swipeOffsetX = 0;
|
|
isSwiping = false;
|
|
swipeLocked = false;
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
if (disabled || edit || !onReply) return;
|
|
const touch = e.touches[0];
|
|
const deltaX = touch.clientX - swipeStartX;
|
|
const deltaY = touch.clientY - swipeStartY;
|
|
|
|
// Determine swipe direction from dead zone
|
|
if (
|
|
!swipeLocked &&
|
|
(Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)
|
|
) {
|
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
|
// Vertical scroll — abort swipe tracking
|
|
isSwiping = false;
|
|
swipeLocked = true;
|
|
return;
|
|
}
|
|
// Horizontal swipe — lock in
|
|
swipeLocked = true;
|
|
isSwiping = true;
|
|
}
|
|
|
|
if (!isSwiping) return;
|
|
|
|
// Only allow right swipe
|
|
const clampedX = Math.max(0, deltaX);
|
|
// Dampen the motion beyond threshold for a rubber-band feel
|
|
swipeOffsetX =
|
|
clampedX <= SWIPE_THRESHOLD ? clampedX : SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
|
|
swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX);
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) {
|
|
onReply(message);
|
|
}
|
|
swipeOffsetX = 0;
|
|
isSwiping = false;
|
|
swipeLocked = false;
|
|
};
|
|
|
|
const loadMessageData = async () => {
|
|
if (message && message?.data === true) {
|
|
const res = await getMessageData(localStorage.token, channel?.id, message.id);
|
|
if (res) {
|
|
message.data = res;
|
|
}
|
|
}
|
|
};
|
|
|
|
onMount(async () => {
|
|
if (message && message?.data === true) {
|
|
await loadMessageData();
|
|
}
|
|
});
|
|
</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="swipe-reply-wrapper relative"
|
|
on:touchstart={handleTouchStart}
|
|
on:touchmove={handleTouchMove}
|
|
on:touchend={handleTouchEnd}
|
|
>
|
|
<!-- Swipe reply indicator -->
|
|
{#if swipeOffsetX > 0}
|
|
<div
|
|
class="swipe-reply-indicator"
|
|
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 +
|
|
Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
|
|
>
|
|
<div
|
|
class="swipe-reply-icon"
|
|
class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}
|
|
>
|
|
<ArrowUpLeftAlt className="size-5" />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
id="message-{renderedMessageId}"
|
|
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
|
|
? className
|
|
: `px-5 ${
|
|
replyToMessage
|
|
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
|
|
: ''
|
|
} ${
|
|
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
|
|
$user?.id
|
|
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
|
|
: ''
|
|
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
|
|
? 'pt-1.5 pb-0.5'
|
|
: ''}"
|
|
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0
|
|
? ''
|
|
: 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
|
|
>
|
|
{#if !edit && !disabled}
|
|
<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/30 dark:border-gray-850/30"
|
|
>
|
|
{#if onReaction}
|
|
<EmojiPicker
|
|
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>
|
|
</EmojiPicker>
|
|
{/if}
|
|
|
|
{#if onReply}
|
|
<Tooltip content={$i18n.t('Reply')}>
|
|
<button
|
|
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
|
|
on:click={() => {
|
|
onReply(message);
|
|
}}
|
|
>
|
|
<ArrowUpLeftAlt className="size-5" />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
|
|
<button
|
|
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
|
|
on:click={() => {
|
|
onPin(message);
|
|
}}
|
|
>
|
|
{#if message?.is_pinned}
|
|
<PinSlash className="size-4" />
|
|
{:else}
|
|
<Pin className="size-4" />
|
|
{/if}
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{#if !thread && onThread}
|
|
<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'}
|
|
{#if onEdit}
|
|
<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>
|
|
{/if}
|
|
|
|
{#if onDelete}
|
|
<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}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if message?.is_pinned}
|
|
<div class="flex {showUserProfile ? 'mb-0.5' : 'mt-0.5'}">
|
|
<div class="ml-8.5 flex items-center gap-1 px-1 rounded-full text-xs">
|
|
<Pin className="size-3 text-yellow-500 dark:text-yellow-300" />
|
|
<span class="text-gray-500">{$i18n.t('Pinned')}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if message?.reply_to_message?.user}
|
|
<div class="relative text-xs mb-1">
|
|
<div
|
|
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-[1.5px] border-l-[1.5px] border-gray-200 dark:border-gray-700 z-0"
|
|
></div>
|
|
|
|
<button
|
|
class="ml-12 flex items-center space-x-2 relative z-0"
|
|
on:click={() => {
|
|
const messageElement = document.getElementById(`message-${replyToMessageId}`);
|
|
if (messageElement) {
|
|
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
messageElement.classList.add('highlight');
|
|
setTimeout(() => {
|
|
messageElement.classList.remove('highlight');
|
|
}, 2000);
|
|
return;
|
|
}
|
|
}}
|
|
>
|
|
{#if message?.reply_to_message?.meta?.model_id}
|
|
<img
|
|
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
|
|
alt={message.reply_to_message.meta.model_name ??
|
|
message.reply_to_message.meta.model_id}
|
|
class="size-4 ml-0.5 rounded-full object-cover"
|
|
on:error={(e) => {
|
|
e.currentTarget.src = '/favicon.png';
|
|
}}
|
|
/>
|
|
{:else}
|
|
<img
|
|
src={message.reply_to_message.user?.role === 'webhook'
|
|
? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image`
|
|
: `${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
|
|
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
|
|
class="size-4 ml-0.5 rounded-full object-cover"
|
|
/>
|
|
{/if}
|
|
|
|
<div class="shrink-0">
|
|
{message?.reply_to_message.meta?.model_name ??
|
|
message?.reply_to_message.user?.name ??
|
|
$i18n.t('Unknown User')}
|
|
</div>
|
|
|
|
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
|
|
<Markdown
|
|
id={`${renderedMessageId}-reply-to`}
|
|
content={message?.reply_to_message?.content}
|
|
allowEmbeds={false}
|
|
/>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class=" flex w-full message-{message.id} "
|
|
id="message-{renderedMessageId}"
|
|
dir={$settings.chatDirection}
|
|
>
|
|
<div class={`shrink-0 mr-1 w-9`}>
|
|
{#if showUserProfile}
|
|
{#if message?.meta?.model_id}
|
|
<img
|
|
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
|
|
alt={message.meta.model_name ?? message.meta.model_id}
|
|
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
|
|
on:error={(e) => {
|
|
e.currentTarget.src = '/favicon.png';
|
|
}}
|
|
/>
|
|
{:else if message.user?.role === 'webhook'}
|
|
<ProfileImage
|
|
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
|
|
className={'size-8 ml-0.5'}
|
|
/>
|
|
{:else}
|
|
<ProfilePreview user={message.user}>
|
|
<ProfileImage
|
|
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
|
|
className={'size-8 ml-0.5'}
|
|
/>
|
|
</ProfilePreview>
|
|
{/if}
|
|
{:else}
|
|
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
|
|
|
|
{#if message.created_at}
|
|
<div
|
|
class="mt-1.5 flex shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-normal 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-2">
|
|
{#if showUserProfile}
|
|
<Name>
|
|
<div class=" self-end text-base shrink-0 font-normal truncate">
|
|
{#if message?.meta?.model_id}
|
|
{message?.meta?.model_name ?? message?.meta?.model_id}
|
|
{:else}
|
|
{message?.user?.name}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if message.created_at}
|
|
<div
|
|
class=" self-center text-xs text-gray-400 font-normal first-letter:capitalize ml-0.5 translate-y-[1px]"
|
|
>
|
|
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
|
|
<span class="line-clamp-1">
|
|
{#if dayjs(message.created_at / 1000000).isToday()}
|
|
{dayjs(message.created_at / 1000000).format('LT')}
|
|
{:else}
|
|
{$i18n.t(formatDate(message.created_at / 1000000), {
|
|
LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
|
|
LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
|
|
})}
|
|
{/if}
|
|
</span>
|
|
</Tooltip>
|
|
</div>
|
|
{/if}
|
|
</Name>
|
|
{/if}
|
|
|
|
{#if message?.data === true}
|
|
<!-- loading indicator -->
|
|
<div class=" my-2">
|
|
<Skeleton />
|
|
</div>
|
|
{:else if (message?.data?.files ?? []).length > 0}
|
|
<div
|
|
class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"
|
|
dir={$settings?.chatDirection ?? 'auto'}
|
|
>
|
|
{#each message?.data?.files as file}
|
|
{@const fileUrl =
|
|
file.url.startsWith('data') || file.url.startsWith('http')
|
|
? file.url
|
|
: `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
|
|
<div>
|
|
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
|
|
<Image src={fileUrl} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
|
|
{:else if file.type === 'video' || (file?.content_type ?? '').startsWith('video/')}
|
|
<video src={fileUrl} controls class=" max-h-96 rounded-lg"></video>
|
|
{:else}
|
|
<FileItem
|
|
item={file}
|
|
url={file.url}
|
|
name={file.name}
|
|
type={file.type}
|
|
size={file?.size}
|
|
small={true}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if edit}
|
|
<div class="py-2">
|
|
<Textarea
|
|
className=" bg-transparent outline-hidden 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-normal">
|
|
<div class="flex space-x-1.5">
|
|
<button
|
|
id="close-edit-message-button"
|
|
class="px-3.5 py-1.5 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-3.5 py-1.5 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 {pending ? 'opacity-50' : ''}">
|
|
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
|
|
<Skeleton />
|
|
{:else}
|
|
<span class="markdown-prose">
|
|
<Markdown
|
|
id={renderedMessageId}
|
|
content={message.content}
|
|
paragraphTag="span"
|
|
allowEmbeds={!!message?.meta?.model_id}
|
|
/>
|
|
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
|
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
|
|
>{/if}
|
|
{/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={$i18n.t('{{NAMES}} reacted with {{REACTION}}', {
|
|
NAMES: reaction.users
|
|
.reduce((acc, u, idx) => {
|
|
const name = u.id === $user?.id ? $i18n.t('You') : u.name;
|
|
const total = reaction.users.length;
|
|
|
|
// First three names always added normally
|
|
if (idx < 3) {
|
|
const separator =
|
|
idx === 0
|
|
? ''
|
|
: idx === Math.min(2, total - 1)
|
|
? ` ${$i18n.t('and')} `
|
|
: ', ';
|
|
return `${acc}${separator}${name}`;
|
|
}
|
|
|
|
// More than 4 → "and X others"
|
|
if (idx === 3 && total > 4) {
|
|
return (
|
|
acc +
|
|
` ${$i18n.t('and {{COUNT}} others', {
|
|
COUNT: total - 3
|
|
})}`
|
|
);
|
|
}
|
|
|
|
return acc;
|
|
}, '')
|
|
.trim(),
|
|
REACTION: `:${reaction.name}:`
|
|
})}
|
|
>
|
|
<button
|
|
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.users
|
|
.map((u) => u.id)
|
|
.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={() => {
|
|
if (onReaction) {
|
|
onReaction(reaction.name);
|
|
}
|
|
}}
|
|
>
|
|
<Emoji shortCode={reaction.name} />
|
|
|
|
{#if reaction.users.length > 0}
|
|
<div class="text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
{reaction.users?.length}
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</Tooltip>
|
|
{/each}
|
|
|
|
{#if onReaction}
|
|
<EmojiPicker
|
|
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>
|
|
</EmojiPicker>
|
|
{/if}
|
|
</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-normal 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>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.highlight {
|
|
animation: highlightAnimation 2s ease-in-out;
|
|
}
|
|
|
|
@keyframes highlightAnimation {
|
|
0% {
|
|
background-color: rgba(0, 60, 255, 0.1);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
}
|
|
}
|
|
|
|
/* Swipe-to-reply styles */
|
|
.swipe-reply-wrapper {
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.swipe-reply-indicator {
|
|
position: absolute;
|
|
left: 8px;
|
|
top: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.swipe-reply-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background-color: rgba(128, 128, 128, 0.15);
|
|
color: rgba(128, 128, 128, 0.8);
|
|
transition:
|
|
background-color 0.15s,
|
|
color 0.15s;
|
|
}
|
|
|
|
.swipe-reply-icon--active {
|
|
background-color: rgba(59, 130, 246, 0.2);
|
|
color: rgb(59, 130, 246);
|
|
}
|
|
|
|
:global(.dark) .swipe-reply-icon {
|
|
background-color: rgba(200, 200, 200, 0.1);
|
|
color: rgba(200, 200, 200, 0.6);
|
|
}
|
|
|
|
:global(.dark) .swipe-reply-icon--active {
|
|
background-color: rgba(96, 165, 250, 0.2);
|
|
color: rgb(96, 165, 250);
|
|
}
|
|
</style>
|