ai / frontend /src /lib /components /channel /Messages /Message.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
23.8 kB
<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 { REXPRO_API_BASE_URL, REXPRO_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 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;
// 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-{message.id}"
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-${message.reply_to_message.id}`,
);
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={`${REXPRO_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"
? `${REXPRO_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image`
: `${REXPRO_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={`${message.id}-reply-to`}
content={message?.reply_to_message?.content}
allowEmbeds={false}
/>
</div>
</button>
</div>
{/if}
<div
class=" flex w-full message-{message.id} "
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${REXPRO_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={`${REXPRO_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={"size-8 ml-0.5"}
/>
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={`${REXPRO_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-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-2">
{#if showUserProfile}
<Name>
<div
class=" self-end text-base shrink-0 font-medium 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-medium 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
: `${REXPRO_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-medium"
>
<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 markdown-prose {pending
? 'opacity-50'
: ''}"
>
{#if (message?.content ?? "").trim() === "" && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown
id={message.id}
content={message.content}
paragraphTag="span"
allowEmbeds={!!message?.meta?.model_id}
/>{#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-medium 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-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>
</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>