mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat: setup attachment to the freight chat
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
|
||||
import { FileText, X } from "lucide-react";
|
||||
|
||||
import { formatBytes } from "./MessageAttachments";
|
||||
import type { PendingAttachment } from "./useAttachmentDraft";
|
||||
|
||||
/**
|
||||
* The staged-files strip above the composer. Shows what will be sent and lets
|
||||
* the agent drop any of it before hitting send.
|
||||
*/
|
||||
export function AttachmentDraftBar({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs" mb="xs" wrap="wrap">
|
||||
{attachments.map((a) => (
|
||||
<Paper
|
||||
key={a.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
p={4}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" pr={16}>
|
||||
{a.previewUrl ? (
|
||||
<Image
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
w={36}
|
||||
h={36}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
w={36}
|
||||
h={36}
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ minWidth: 0, maxWidth: 120 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.file.name}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{formatBytes(a.file.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
radius="xl"
|
||||
color="gray"
|
||||
variant="filled"
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
onClick={() => onRemove(a.id)}
|
||||
style={{ position: "absolute", top: -6, right: -6 }}
|
||||
>
|
||||
<X size={10} />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
isSupportAttachmentImage,
|
||||
type SupportAttachmentDto,
|
||||
} from "@edr/types";
|
||||
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, ImageOff } from "lucide-react";
|
||||
|
||||
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
|
||||
|
||||
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Cap the bubble: a tall screenshot would push the conversation off-screen. */
|
||||
const THUMB = { maxHeight: 220, maxWidth: 260 } as const;
|
||||
|
||||
/**
|
||||
* One image attachment. Its own component because the bytes are fetched through
|
||||
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
||||
* be called from inside a `.map()`.
|
||||
*/
|
||||
function ImageAttachment({
|
||||
a,
|
||||
onView,
|
||||
}: {
|
||||
a: SupportAttachmentDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Group gap={6} c="dimmed">
|
||||
<ImageOff size={14} />
|
||||
<Text size="xs">Couldn't load {a.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: THUMB.maxWidth,
|
||||
height: 140,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onView(a, src)}
|
||||
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
|
||||
>
|
||||
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments inside a message bubble: images as thumbnails, everything else as
|
||||
* a labelled file row. Non-images are not fetched until opened — pulling every
|
||||
* document in a thread just to draw a filename would be wasteful.
|
||||
*/
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
mine,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: SupportAttachmentDto[];
|
||||
mine: boolean;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={6} mt={6}>
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onView} />
|
||||
) : (
|
||||
<Paper
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
px="sm"
|
||||
py={6}
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: mine ? "rgba(255,255,255,0.16)" : "white",
|
||||
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<FileText size={16} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text size="10px" opacity={0.75}>
|
||||
{formatBytes(a.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
SendSupportMessageDto,
|
||||
SupportConversationDto,
|
||||
SupportConversationListResult,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
@@ -14,6 +14,33 @@ export interface ListConversationsParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ListMessagesParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
body?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server can persist them against
|
||||
* the message it creates in the same request; text-only stays JSON. Letting
|
||||
* axios set the multipart boundary itself is deliberate — setting
|
||||
* `Content-Type` by hand omits the boundary and the request fails to parse.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
|
||||
if (!input.attachments?.length) return { body: input.body };
|
||||
|
||||
const form = new FormData();
|
||||
if (input.body) form.append("body", input.body);
|
||||
for (const file of input.attachments) form.append("attachments", file);
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
|
||||
* response interceptor already unwraps the `{ success, data }` envelope, so
|
||||
@@ -29,19 +56,39 @@ export const supportApi = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
|
||||
const { data } = await api.get<SupportMessageDto[]>(
|
||||
listMessages: async (
|
||||
id: string,
|
||||
params: ListMessagesParams = {},
|
||||
): Promise<SupportMessageListResult> => {
|
||||
const { data } = await api.get<SupportMessageListResult>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
{ params },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through the authenticated client.
|
||||
*
|
||||
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
|
||||
* token from the Authorization header only — there is no cookie fallback — and
|
||||
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
|
||||
* Same reason `filesService.download` exists for booking documents. The caller
|
||||
* wraps this blob in an object URL.
|
||||
*/
|
||||
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
|
||||
// The DTO path is absolute from the API root (`/api/...`), but this client's
|
||||
// baseURL already ends in `/api` — drop the duplicate prefix.
|
||||
const path = relativeUrl.replace(/^\/api/, "");
|
||||
const { data } = await api.get(path, { responseType: "blob" });
|
||||
return data as unknown as Blob;
|
||||
},
|
||||
sendMessage: async (
|
||||
id: string,
|
||||
body: SendSupportMessageDto,
|
||||
input: SendMessageInput,
|
||||
): Promise<SupportMessageDto> => {
|
||||
const { data } = await api.post<SupportMessageDto>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
body,
|
||||
toRequestBody(input),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
isSupportAttachmentAllowed,
|
||||
} from "@edr/types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** A file staged in the composer, not yet sent. */
|
||||
export interface PendingAttachment {
|
||||
/** Local-only id; the server id doesn't exist until the message is sent. */
|
||||
id: string;
|
||||
file: File;
|
||||
/** Object URL, images only. Revoked when the entry goes away. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* Staging area for files being attached to a message.
|
||||
*
|
||||
* Files are held client-side until send, then posted alongside the text in one
|
||||
* multipart request — there's no upload-then-reference step, so nothing to
|
||||
* garbage-collect if the agent changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
|
||||
* every image for the life of the tab.
|
||||
*/
|
||||
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const rejectRef = useRef(onReject);
|
||||
rejectRef.current = onReject;
|
||||
|
||||
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
||||
// still-live URLs) on every change to the list.
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const a of attachmentsRef.current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const add = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setAttachments((current) => {
|
||||
const accepted: PendingAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (
|
||||
current.length + accepted.length >=
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
|
||||
) {
|
||||
rejectRef.current?.(
|
||||
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!isSupportAttachmentAllowed(file.type)) {
|
||||
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
rejectRef.current?.(
|
||||
`${file.name} is over the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB limit.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: `pending-${nextId++}`,
|
||||
file,
|
||||
previewUrl: isSupportAttachmentImage(file.type)
|
||||
? URL.createObjectURL(file)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return accepted.length ? [...current, ...accepted] : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachments((current) => {
|
||||
const target = current.find((a) => a.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return current.filter((a) => a.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachments((current) => {
|
||||
for (const a of current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pull files off a paste. Returns true if anything was taken, so the caller
|
||||
* can suppress the default paste — otherwise pasting a screenshot also drops
|
||||
* its filename (or nothing) into the textarea.
|
||||
*
|
||||
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
|
||||
* clipboard, so check for files first and only then let the text through.
|
||||
*/
|
||||
const addFromPaste = useCallback(
|
||||
(clipboard: DataTransfer | null): boolean => {
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
add(files);
|
||||
return true;
|
||||
},
|
||||
[add],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
files: attachments.map((a) => a.file),
|
||||
add,
|
||||
addFromPaste,
|
||||
remove,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { supportApi } from "./supportApi";
|
||||
|
||||
/**
|
||||
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
|
||||
*
|
||||
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
|
||||
* guard takes the bearer token from the `Authorization` header and has no cookie
|
||||
* fallback, and an `<img>` request cannot carry that header — a direct src is an
|
||||
* unavoidable 401. So the bytes are fetched through the authenticated client and
|
||||
* handed to the browser as an object URL, the same way booking documents are
|
||||
* downloaded.
|
||||
*
|
||||
* The URL is revoked on unmount and whenever the attachment changes, so a thread
|
||||
* scrolled through hundreds of images doesn't pin all of them in memory.
|
||||
*/
|
||||
export function useAttachmentObjectUrl(relativeUrl: string): {
|
||||
src?: string;
|
||||
failed: boolean;
|
||||
} {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let created: string | undefined;
|
||||
|
||||
setSrc(undefined);
|
||||
setFailed(false);
|
||||
|
||||
supportApi
|
||||
.fetchAttachment(relativeUrl)
|
||||
.then((blob) => {
|
||||
// The component may have unmounted mid-flight; creating a URL then would
|
||||
// leak it, since the cleanup below has already run.
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setSrc(created);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [relativeUrl]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand variant for files that aren't previewed inline (documents): fetch
|
||||
* only when the user actually opens one, rather than pulling every attachment in
|
||||
* the thread down just to render a filename row.
|
||||
*
|
||||
* Holds a single slot — opening another file revokes the previous URL, as does
|
||||
* unmounting.
|
||||
*/
|
||||
export function useLazyAttachmentObjectUrl(): (
|
||||
relativeUrl: string,
|
||||
) => Promise<string> {
|
||||
const current = useRef<string>();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useCallback(async (relativeUrl: string) => {
|
||||
const blob = await supportApi.fetchAttachment(relativeUrl);
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
current.current = URL.createObjectURL(blob);
|
||||
return current.current;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { supportApi, type ListConversationsParams } from "./supportApi";
|
||||
import {
|
||||
supportApi,
|
||||
type ListConversationsParams,
|
||||
type SendMessageInput,
|
||||
} from "./supportApi";
|
||||
|
||||
export const SUPPORT_KEY = ["support"] as const;
|
||||
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
|
||||
@@ -8,20 +25,145 @@ export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
|
||||
export const supportMessagesKey = (id: string) =>
|
||||
["support", "messages", id] as const;
|
||||
|
||||
/** Shared inbox: every thread, filterable by unread + company-name search. */
|
||||
/** Threads per page in the inbox. */
|
||||
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||
/** Messages per page in a thread. */
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Shared inbox: every thread, filterable by unread + company-name search.
|
||||
*
|
||||
* Pages on scroll. This previously asked for `limit: 100` and rendered whatever
|
||||
* came back — which silently truncated the inbox at the server's own max of 100
|
||||
* with no way to reach the rest.
|
||||
*/
|
||||
export function useConversations(params: ListConversationsParams = {}) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
||||
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listConversations({
|
||||
...params,
|
||||
page: pageParam,
|
||||
limit: CONVERSATIONS_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const loaded = allPages.reduce((n, page) => n + page.items.length, 0);
|
||||
return loaded < lastPage.count ? allPages.length + 1 : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Flatten for rendering, keeping the page-level fields (count/unreadCount)
|
||||
* from the newest fetch so badges don't go stale as more pages load.
|
||||
*
|
||||
* De-duplicated by id because this list pages by OFFSET over a sort key that
|
||||
* moves: a thread jumps to rank 1 the moment it gets a message, shifting
|
||||
* everything down, so a row already shown on page 1 can be served again on
|
||||
* page 2 — and React would then see two children with the same key. The
|
||||
* conversations invalidate that rides along with every such event heals the
|
||||
* ordering a beat later; this just stops the intervening render from breaking.
|
||||
*
|
||||
* Keyset wouldn't help here, unlike the message list: the sort key itself
|
||||
* mutates, so no cursor over it is stable either.
|
||||
*/
|
||||
const items = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const flat: SupportConversationDto[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const conversation of page.items) {
|
||||
if (seen.has(conversation.id)) continue;
|
||||
seen.add(conversation.id);
|
||||
flat.push(conversation);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
items,
|
||||
count: query.data?.pages[0]?.count ?? 0,
|
||||
unreadCount: query.data?.pages[0]?.unreadCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's messages, paged backwards from newest.
|
||||
*
|
||||
* react-query's "next page" is *older* history here, so `pages` runs
|
||||
* newest-block-first and has to be reversed to render top-to-bottom in time
|
||||
* order. Cursor-based rather than offset so a message arriving mid-scroll
|
||||
* doesn't shift the pages already loaded.
|
||||
*/
|
||||
export function useMessages(conversationId: string | null) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: supportMessagesKey(conversationId ?? ""),
|
||||
queryFn: () => supportApi.listMessages(conversationId as string),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listMessages(conversationId as string, {
|
||||
before: pageParam,
|
||||
limit: MESSAGES_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// Exposed so the view can tell a prepended history page from a message
|
||||
// appended at the bottom — `messages` changing says nothing about which.
|
||||
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into a cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: that refetches *every* page the agent
|
||||
* has scrolled back through, so the cost of each inbound message would grow with
|
||||
* how far they've read. Page 0 is the newest block and its items are oldest-first
|
||||
* within the block, so the new message belongs on its end.
|
||||
*
|
||||
* No-ops when the thread isn't cached — nothing is rendering it, and seeding a
|
||||
* partial cache here would leave a thread whose "first page" is one message and
|
||||
* whose `nextCursor` is missing.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than a boolean: the two ways it can decline
|
||||
* to append need opposite handling. A duplicate is the sender's own echo and must
|
||||
* be ignored — refetching there would undo the whole point. "Uncached" means the
|
||||
* thread's first page is still in flight and may have been read on the server
|
||||
* *before* this message existed, so dropping it silently would lose it until
|
||||
* something else happened to refetch; the caller refetches instead. That's cheap
|
||||
* precisely because nothing is loaded yet.
|
||||
*/
|
||||
export type AppendOutcome = "appended" | "duplicate" | "uncached";
|
||||
|
||||
export function appendMessageToCache(
|
||||
qc: QueryClient,
|
||||
message: SupportMessageDto,
|
||||
): AppendOutcome {
|
||||
let outcome: AppendOutcome = "uncached";
|
||||
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
|
||||
supportMessagesKey(message.conversationId),
|
||||
(current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
if (newest.items.some((m) => m.id === message.id)) {
|
||||
outcome = "duplicate";
|
||||
return current;
|
||||
}
|
||||
outcome = "appended";
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
|
||||
};
|
||||
},
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function useSupportUnreadCount(enabled = true) {
|
||||
@@ -36,10 +178,13 @@ export function useSupportUnreadCount(enabled = true) {
|
||||
export function useSendMessage(conversationId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: string) =>
|
||||
supportApi.sendMessage(conversationId, { body }),
|
||||
mutationFn: (input: SendMessageInput) =>
|
||||
supportApi.sendMessage(conversationId, input),
|
||||
// The gateway echoes our own message back over the socket, which appends it
|
||||
// to the cache — so don't invalidate the thread here or every send would
|
||||
// refetch every page the agent has scrolled through. The conversation list
|
||||
// still needs a refresh for its last-message preview and ordering.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import {
|
||||
appendMessageToCache,
|
||||
SUPPORT_CONVERSATIONS_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
supportMessagesKey,
|
||||
@@ -45,14 +46,25 @@ export function useSupportSocket(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: supportMessagesKey(event.message.conversationId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
onMessageRef.current?.(event);
|
||||
});
|
||||
socket.on(
|
||||
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: SupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated, and invalidating
|
||||
// it would refetch every page the agent has scrolled back through on every
|
||||
// single inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === "uncached") {
|
||||
// The thread's first page is still loading and may have been read before
|
||||
// this message existed — without this it would go missing until some
|
||||
// unrelated refetch. Cheap: there are no pages to re-fetch yet.
|
||||
qc.invalidateQueries({
|
||||
queryKey: supportMessagesKey(event.message.conversationId),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
onMessageRef.current?.(event);
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_ACCEPT,
|
||||
SupportAuthorRole,
|
||||
type SupportAttachmentDto,
|
||||
type SupportConversationDto,
|
||||
type SupportMessageDto,
|
||||
} from "@edr/types";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -23,10 +26,22 @@ import {
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
Headset,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLazyAttachmentObjectUrl } from "@/features/support/useAttachmentObjectUrl";
|
||||
import { AttachmentDraftBar } from "@/features/support/AttachmentDraftBar";
|
||||
import { MessageAttachments } from "@/features/support/MessageAttachments";
|
||||
import { useAttachmentDraft } from "@/features/support/useAttachmentDraft";
|
||||
import {
|
||||
useConversations,
|
||||
useMarkConversationRead,
|
||||
@@ -39,6 +54,9 @@ import { customersService } from "@/services/customers.service";
|
||||
|
||||
type ReadFilter = "ALL" | "UNREAD";
|
||||
|
||||
/** Distance from an edge (px) that counts as "at" it. */
|
||||
const SCROLL_EDGE_SLOP = 120;
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
@@ -54,12 +72,24 @@ export default function SupportInboxPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const inboxViewport = useRef<HTMLDivElement>(null);
|
||||
/**
|
||||
* The thread just opened from the company picker.
|
||||
*
|
||||
* Selection resolves against the *loaded* pages, and a company picked from the
|
||||
* modal may well have a thread that sits far enough down the list to not be
|
||||
* loaded yet — in which case the lookup below would find nothing and the pane
|
||||
* would sit blank. Hold onto the conversation the server handed back so the
|
||||
* pane can open immediately, regardless of where it falls in the inbox.
|
||||
*/
|
||||
const [startedConversation, setStartedConversation] =
|
||||
useState<SupportConversationDto | null>(null);
|
||||
|
||||
const { data, isLoading } = useConversations({
|
||||
search,
|
||||
unreadOnly: readFilter === "UNREAD",
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useConversations({
|
||||
search,
|
||||
unreadOnly: readFilter === "UNREAD",
|
||||
});
|
||||
|
||||
useSupportSocket(true, (event) => {
|
||||
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
|
||||
@@ -70,10 +100,13 @@ export default function SupportInboxPage() {
|
||||
}
|
||||
});
|
||||
|
||||
const selected = useMemo(
|
||||
() => items.find((c) => c.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
// Prefer the live row from the list (its unread count and last message stay
|
||||
// current); fall back to the picker's copy while its page is still unloaded.
|
||||
const selected = useMemo(() => {
|
||||
const fromList = items.find((c) => c.id === selectedId);
|
||||
if (fromList) return fromList;
|
||||
return startedConversation?.id === selectedId ? startedConversation : null;
|
||||
}, [items, selectedId, startedConversation]);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
@@ -109,7 +142,10 @@ export default function SupportInboxPage() {
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
@@ -140,24 +176,47 @@ export default function SupportInboxPage() {
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1 }} type="hover">
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
type="hover"
|
||||
// Pull the next page in as the agent nears the end of the list.
|
||||
// Previously the hook asked for 100 rows and stopped there, so any
|
||||
// company past the hundredth was simply unreachable.
|
||||
onScrollPositionChange={({ y }) => {
|
||||
const el = inboxViewport.current;
|
||||
if (!el || !hasNextPage || isFetchingNextPage) return;
|
||||
if (el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
viewportRef={inboxViewport}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
|
||||
{readFilter === "UNREAD"
|
||||
? "Nothing unread."
|
||||
: "No conversations."}
|
||||
</Text>
|
||||
) : (
|
||||
items.map((c) => (
|
||||
<InboxRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
active={c.id === selectedId}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{items.map((c) => (
|
||||
<InboxRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
active={c.id === selectedId}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<Group justify="center" p="sm">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
@@ -168,7 +227,12 @@ export default function SupportInboxPage() {
|
||||
<ConversationThread conversation={selected} />
|
||||
) : (
|
||||
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size={56}
|
||||
>
|
||||
<Headset size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">Select a conversation, or start a new chat.</Text>
|
||||
@@ -180,8 +244,9 @@ export default function SupportInboxPage() {
|
||||
<CompanyPicker
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStarted={(id) => {
|
||||
setSelectedId(id);
|
||||
onStarted={(conversation) => {
|
||||
setStartedConversation(conversation);
|
||||
setSelectedId(conversation.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
@@ -200,7 +265,7 @@ function CompanyPicker({
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onStarted: (conversationId: string) => void;
|
||||
onStarted: (conversation: SupportConversationDto) => void;
|
||||
}) {
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
const start = useStartConversation();
|
||||
@@ -224,15 +289,23 @@ function CompanyPicker({
|
||||
if (!companyId) return;
|
||||
const conversation = await start.mutateAsync(companyId);
|
||||
setCompanyId(null);
|
||||
onStarted(conversation.id);
|
||||
onStarted(conversation);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Start a chat"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Customer"
|
||||
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
|
||||
placeholder={
|
||||
isLoading ? "Loading companies…" : "Search for a company"
|
||||
}
|
||||
data={options}
|
||||
value={companyId}
|
||||
onChange={setCompanyId}
|
||||
@@ -319,26 +392,133 @@ function ConversationThread({
|
||||
}: {
|
||||
conversation: SupportConversationDto;
|
||||
}) {
|
||||
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||
const {
|
||||
messages,
|
||||
pageCount,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useMessages(conversation.id);
|
||||
const send = useSendMessage(conversation.id);
|
||||
const markRead = useMarkConversationRead();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
const attach = useAttachmentDraft((reason) => toast.error(reason));
|
||||
|
||||
/**
|
||||
* Scroll height captured just before an older page was requested, tagged with
|
||||
* the page count at that moment.
|
||||
*
|
||||
* The page count is what makes this safe. Keyed on presence alone, a message
|
||||
* arriving over the socket while history was still in flight would consume the
|
||||
* snapshot on a one-bubble append, and the real 30-message prepend would then
|
||||
* land with nothing to correct against — throwing the reader exactly as far as
|
||||
* this exists to prevent. Comparing counts means only an actual new page can
|
||||
* claim it.
|
||||
*/
|
||||
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
|
||||
null,
|
||||
);
|
||||
/** Whether the agent is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
/** Which thread the refs above describe; a switch resets them. */
|
||||
const anchoredThread = useRef(conversation.id);
|
||||
|
||||
const messageCount = messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
markRead.mutate(conversation.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation.id, messages?.length]);
|
||||
}, [conversation.id, messageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length, conversation.id]);
|
||||
/**
|
||||
* Keep the viewport sensible as the list changes underneath it.
|
||||
*
|
||||
* Two different things change `messages`, and they want opposite behaviour: a
|
||||
* new message at the bottom should follow (if the agent is already there),
|
||||
* while an older page prepended at the top must NOT move what they're reading.
|
||||
* Layout effect, not effect — this must run before paint or the prepend
|
||||
* visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
|
||||
// Thread switch: start a fresh read at the bottom and drop the previous
|
||||
// thread's anchoring state.
|
||||
if (anchoredThread.current !== conversation.id) {
|
||||
anchoredThread.current = conversation.id;
|
||||
pendingRestore.current = null;
|
||||
stick.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = pendingRestore.current;
|
||||
if (restore && pageCount > restore.atPageCount) {
|
||||
// An older page went in above: push the scroll down by exactly the height
|
||||
// that was added, so the same message stays under the cursor.
|
||||
el.scrollTop += el.scrollHeight - restore.height;
|
||||
pendingRestore.current = null;
|
||||
return;
|
||||
}
|
||||
if (stick.current) el.scrollTo({ top: el.scrollHeight });
|
||||
}, [messages, pageCount, conversation.id]);
|
||||
|
||||
const onScroll = ({ y }: { y: number }) => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
|
||||
// A failed fetch leaves this set, which is harmless: the list didn't
|
||||
// change, so the height is still accurate for the retry, and the count
|
||||
// tag stops it being mistaken for a landed page in the meantime.
|
||||
pendingRestore.current = {
|
||||
height: el.scrollHeight,
|
||||
atPageCount: pageCount,
|
||||
};
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
if (!body && attach.attachments.length === 0) return;
|
||||
const files = attach.files;
|
||||
// Clear optimistically so the composer feels instant; on failure the text is
|
||||
// restored below rather than silently lost.
|
||||
setDraft("");
|
||||
await send.mutateAsync(body);
|
||||
attach.clear();
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ body: body || undefined, attachments: files });
|
||||
} catch (error) {
|
||||
setDraft(body);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Couldn't send that message.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const openAttachment = (a: SupportAttachmentDto, src: string) =>
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
|
||||
// Documents aren't fetched until opened.
|
||||
const openFile = async (a: SupportAttachmentDto) => {
|
||||
try {
|
||||
const src = await loadAttachment(a.url);
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
} catch {
|
||||
toast.error(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -361,36 +541,108 @@ function ConversationThread({
|
||||
</Group>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
viewportRef={viewport}
|
||||
type="hover"
|
||||
onScrollPositionChange={onScroll}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : (messages ?? []).length === 0 ? (
|
||||
) : messages.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||
No messages yet — say hello.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm" p="md">
|
||||
{(messages ?? []).map((m) => (
|
||||
<AgentBubble key={m.id} m={m} />
|
||||
{isFetchingNextPage && (
|
||||
<Group justify="center" py="xs">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Group>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
Start of conversation
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<AgentBubble
|
||||
key={m.id}
|
||||
m={m}
|
||||
onView={openAttachment}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Composer */}
|
||||
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||
outline: dragging
|
||||
? "2px dashed var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
outlineOffset: -4,
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<AttachmentDraftBar
|
||||
attachments={attach.attachments}
|
||||
onRemove={attach.remove}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
attach.add(Array.from(e.currentTarget.files ?? []));
|
||||
// Reset so picking the same file twice in a row still fires change.
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Attach files"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</ActionIcon>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder="Type your message… (Enter to send, Shift+Enter for newline)"
|
||||
placeholder="Type a message, or paste an image… (Enter to send, Shift+Enter for newline)"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
// Screenshots land on the clipboard as files. Take them and suppress
|
||||
// the default, which would otherwise also paste the image's name (or
|
||||
// nothing) as text.
|
||||
onPaste={(e) => {
|
||||
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -404,18 +656,27 @@ function ConversationThread({
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
loading={send.isPending}
|
||||
disabled={!draft.trim()}
|
||||
disabled={!draft.trim() && attach.attachments.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
function AgentBubble({
|
||||
m,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: SupportMessageDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.authorRole === SupportAuthorRole.AGENT;
|
||||
return (
|
||||
<Group
|
||||
@@ -430,7 +691,13 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
</Avatar>
|
||||
)}
|
||||
<Box style={{ maxWidth: "70%" }}>
|
||||
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
mb={2}
|
||||
ml={mine ? 0 : 4}
|
||||
ta={mine ? "right" : "left"}
|
||||
>
|
||||
{mine ? m.authorName || "You" : m.authorName || "Customer"}
|
||||
</Text>
|
||||
<Paper
|
||||
@@ -446,9 +713,20 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body}
|
||||
</Text>
|
||||
{m.body && (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{m.body}
|
||||
</Text>
|
||||
)}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onView={onView}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Paper>
|
||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||
{formatTime(m.createdAt)}
|
||||
|
||||
Reference in New Issue
Block a user