Merge pull request #788 from Tria-plc/freight/feat/chat-app

Freight/feat/chat app added attachment
This commit is contained in:
Nathnael Wondisha
2026-07-20 11:23:16 +03:00
committed by GitHub
69 changed files with 5390 additions and 497 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
},

View File

@@ -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,
};
}

View File

@@ -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;
}, []);
}

View File

@@ -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 });
},
});

View File

@@ -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,