mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #788 from Tria-plc/freight/feat/chat-app
Freight/feat/chat app added attachment
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Passenger } from '@edr/types';
|
||||
import { Headset, Search, Send, User } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Passenger, SUPPORT_ATTACHMENT_ACCEPT } from '@edr/types';
|
||||
import { Headset, Paperclip, Search, Send, User, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AttachmentDraftBar } from '@/features/support/AttachmentDraftBar';
|
||||
import { MessageAttachments } from '@/features/support/MessageAttachments';
|
||||
import { useAttachmentDraft } from '@/features/support/useAttachmentDraft';
|
||||
import { useLazyAttachmentObjectUrl } from '@/features/support/useAttachmentObjectUrl';
|
||||
import { useFilePreview } from '@/features/support/useFilePreview';
|
||||
import {
|
||||
useConversations,
|
||||
useMarkRead,
|
||||
@@ -16,6 +21,10 @@ import { useSupportSocket } from '@/features/support/useSupportSocket';
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** Distance from an edge (px) that counts as "at" it. */
|
||||
const SCROLL_EDGE_SLOP = 120;
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
@@ -39,10 +48,9 @@ export default function SupportPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useConversations(
|
||||
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useConversations(
|
||||
status === 'ALL' ? { search } : { status, search },
|
||||
);
|
||||
const items = useMemo(() => data?.items ?? [], [data?.items]);
|
||||
|
||||
useSupportSocket(true);
|
||||
|
||||
@@ -51,6 +59,17 @@ export default function SupportPage() {
|
||||
[items, selectedId],
|
||||
);
|
||||
|
||||
// Pull the next page in as the agent nears the end of the list. The list
|
||||
// previously rendered a single fetch, so any thread past the server's default
|
||||
// page was simply unreachable.
|
||||
const onInboxScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_EDGE_SLOP) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -73,10 +92,7 @@ export default function SupportPage() {
|
||||
<div className="flex w-[340px] shrink-0 flex-col border-r border-border">
|
||||
<div className="space-y-2 border-b border-border p-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-2.5 text-muted-foreground"
|
||||
/>
|
||||
<Search size={16} className="absolute left-3 top-2.5 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -90,9 +106,7 @@ export default function SupportPage() {
|
||||
key={f}
|
||||
onClick={() => setStatus(f)}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium capitalize transition ${
|
||||
status === f
|
||||
? 'text-white'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
status === f ? 'text-white' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
style={status === f ? { background: GREEN } : undefined}
|
||||
>
|
||||
@@ -101,24 +115,25 @@ export default function SupportPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" onScroll={onInboxScroll}>
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No conversations.
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No conversations.</div>
|
||||
) : (
|
||||
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 && (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">Loading more…</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +175,9 @@ function InboxRow({
|
||||
className={`block w-full border-b border-border px-4 py-3 text-left transition hover:bg-muted/50 ${
|
||||
active ? 'bg-muted/70' : ''
|
||||
}`}
|
||||
style={active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }}
|
||||
style={
|
||||
active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
@@ -204,31 +221,131 @@ function InboxRow({
|
||||
}
|
||||
|
||||
function ConversationThread({ conversation }: { conversation: ConversationDto }) {
|
||||
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||
const { messages, pageCount, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useMessages(conversation.id);
|
||||
const send = useSendMessage(conversation.id);
|
||||
const setStatus = useSetStatus();
|
||||
const markRead = useMarkRead();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const { view, viewer } = useFilePreview();
|
||||
const attach = useAttachmentDraft(setError);
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
// A send failure belongs to the thread it was typed in, not the next one.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length, conversation.id]);
|
||||
setError(null);
|
||||
}, [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 the content the
|
||||
* agent is reading. Layout effect, not effect — this has to 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 = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (el.scrollTop < 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 text = draft.trim();
|
||||
if (!text) return;
|
||||
if (!text && 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(text);
|
||||
attach.clear();
|
||||
setError(null);
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ text: text || undefined, attachments: files });
|
||||
} catch (err) {
|
||||
setDraft(text);
|
||||
setError(err instanceof Error ? err.message : "Couldn't send that message.");
|
||||
}
|
||||
};
|
||||
|
||||
const changeStatus = (status: string) =>
|
||||
setStatus.mutate({ id: conversation.id, status });
|
||||
const changeStatus = (status: string) => setStatus.mutate({ id: conversation.id, status });
|
||||
|
||||
// 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: AttachmentDto, src: string) =>
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
|
||||
// Documents aren't fetched until opened.
|
||||
const openFile = async (a: AttachmentDto) => {
|
||||
try {
|
||||
view({ name: a.name, url: await loadAttachment(a.url), mimeType: a.mimeType });
|
||||
} catch {
|
||||
setError(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -282,21 +399,85 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
<div ref={viewport} onScroll={onScroll} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No messages yet.</div>
|
||||
) : (
|
||||
(messages ?? []).map((m) => <AgentBubble key={m.id} m={m} />)
|
||||
<>
|
||||
{isFetchingNextPage && (
|
||||
<div className="py-2 text-center text-xs text-muted-foreground">
|
||||
Loading earlier messages…
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<div className="text-center text-[10px] text-muted-foreground">
|
||||
Start of conversation
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<AgentBubble key={m.id} m={m} onView={openAttachment} onOpenFile={openFile} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div
|
||||
className={`border-t border-border p-3 ${
|
||||
dragging
|
||||
? 'bg-emerald-50 outline-dashed outline-2 -outline-offset-4 outline-emerald-600 dark:bg-emerald-900/20'
|
||||
: ''
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg bg-red-100 px-3 py-1.5 text-xs text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentDraftBar attachments={attach.attachments} onRemove={attach.remove} />
|
||||
<div className="flex items-end gap-2">
|
||||
<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 = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label="Attach files"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// 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();
|
||||
@@ -304,12 +485,12 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
|
||||
placeholder="Type your reply, or paste an image… (Enter to send, Shift+Enter for newline)"
|
||||
className="max-h-28 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
disabled={(!draft.trim() && attach.attachments.length === 0) || send.isPending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: GREEN }}
|
||||
aria-label="Send"
|
||||
@@ -318,11 +499,20 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ m }: { m: MessageDto }) {
|
||||
function AgentBubble({
|
||||
m,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: MessageDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.sender === 'AGENT';
|
||||
return (
|
||||
<div className={`flex ${mine ? 'justify-end' : 'justify-start'} items-end gap-2`}>
|
||||
@@ -332,22 +522,22 @@ function AgentBubble({ m }: { m: MessageDto }) {
|
||||
</span>
|
||||
)}
|
||||
<div className="max-w-[70%]">
|
||||
<p
|
||||
className={`mb-0.5 text-xs text-muted-foreground ${
|
||||
mine ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
<p className={`mb-0.5 text-xs text-muted-foreground ${mine ? 'text-right' : 'text-left'}`}>
|
||||
{mine ? m.authorName || 'You' : m.authorName || 'Passenger'}
|
||||
</p>
|
||||
<div
|
||||
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
|
||||
mine
|
||||
? 'rounded-br-sm text-white'
|
||||
: 'rounded-bl-sm bg-muted text-foreground'
|
||||
className={`rounded-2xl px-3 py-2 text-sm ${
|
||||
mine ? 'rounded-br-sm text-white' : 'rounded-bl-sm bg-muted text-foreground'
|
||||
}`}
|
||||
style={mine ? { background: GREEN } : undefined}
|
||||
>
|
||||
{m.text}
|
||||
{m.text && <p className="whitespace-pre-wrap break-words">{m.text}</p>}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onView={onView}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-0.5 text-[10px] text-muted-foreground ${
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
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 (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="relative flex items-center gap-1.5 rounded-lg border border-border bg-background p-1 pr-4"
|
||||
>
|
||||
{a.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
className="h-9 w-9 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 max-w-[120px]">
|
||||
<span className="block truncate text-xs font-semibold text-foreground">
|
||||
{a.file.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{formatBytes(a.file.size)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(a.id)}
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-gray-600 text-white transition hover:bg-gray-700"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage, type Passenger } from '@edr/types';
|
||||
import { FileText, ImageOff } from 'lucide-react';
|
||||
|
||||
import { useAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: AttachmentDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs opacity-75">
|
||||
<ImageOff size={14} className="shrink-0" />
|
||||
Couldn't load {a.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="h-[120px] w-[200px] animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(a, src)}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg"
|
||||
aria-label={`View ${a.name}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={a.name}
|
||||
// Cap the bubble: a tall screenshot would otherwise push the
|
||||
// whole conversation off-screen.
|
||||
className="max-h-[220px] max-w-[260px] rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: AttachmentDto[];
|
||||
mine: boolean;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-col gap-1.5">
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onView} />
|
||||
) : (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition hover:opacity-90 ${
|
||||
mine ? 'bg-white/20' : 'border border-border bg-background'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">{a.name}</span>
|
||||
<span className="block text-[10px] opacity-75">{formatBytes(a.size)}</span>
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,32 +5,78 @@ import { apiClient } from '@/lib/api-client';
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type ListResult = Passenger.PassengerSupportConversationListResult;
|
||||
type MessageListResult = Passenger.PassengerSupportMessageListResult;
|
||||
|
||||
export interface ListParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
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 {
|
||||
text?: 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.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): FormData | { text?: string } {
|
||||
if (!input.attachments?.length) return { text: input.text };
|
||||
|
||||
const form = new FormData();
|
||||
if (input.text) form.append('text', input.text);
|
||||
for (const file of input.attachments) form.append('attachments', file);
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* `apiClient` pins `Content-Type: application/json` on every request. Axios
|
||||
* reads that header in `transformRequest` and, seeing JSON, runs FormData
|
||||
* through `formDataToJSON` — the files would be silently dropped and the server
|
||||
* would store an empty message. Clearing the header (rather than setting
|
||||
* `multipart/form-data` by hand, which omits the boundary) is what lets the
|
||||
* browser generate a proper boundary of its own.
|
||||
*/
|
||||
const multipartConfig = { headers: { 'Content-Type': undefined } };
|
||||
|
||||
/** Passenger backoffice (agent) support-chat REST calls (client unwraps envelope). */
|
||||
export const supportApi = {
|
||||
listConversations: (params: ListParams = {}) =>
|
||||
apiClient.get<ListResult>('/support/agent/conversations', { params }),
|
||||
listMessages: (id: string) =>
|
||||
apiClient.get<MessageDto[]>(`/support/agent/conversations/${id}/messages`),
|
||||
sendMessage: (id: string, text: string) =>
|
||||
apiClient.post<MessageDto>(
|
||||
listMessages: (id: string, params: ListMessagesParams = {}) =>
|
||||
apiClient.get<MessageListResult>(`/support/agent/conversations/${id}/messages`, { params }),
|
||||
sendMessage: (id: string, input: SendMessageInput) => {
|
||||
const body = toRequestBody(input);
|
||||
return apiClient.post<MessageDto>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
{ text },
|
||||
),
|
||||
body,
|
||||
body instanceof FormData ? multipartConfig : undefined,
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through this client so the bearer token rides along
|
||||
* — the guard reads `Authorization` only, so a direct `<img src>` would 401.
|
||||
*
|
||||
* `relativeUrl` is the DTO's `url` (`/support/attachments/:id`); the passenger
|
||||
* API has no global prefix, so it appends to the client's baseURL as-is.
|
||||
*/
|
||||
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
|
||||
const data = await apiClient.getRaw<Blob>(relativeUrl, { responseType: 'blob' });
|
||||
return data;
|
||||
},
|
||||
setStatus: (id: string, status: string) =>
|
||||
apiClient.patch<ConversationDto>(
|
||||
`/support/agent/conversations/${id}/status`,
|
||||
{ status },
|
||||
),
|
||||
apiClient.patch<ConversationDto>(`/support/agent/conversations/${id}/status`, { status }),
|
||||
markRead: (id: string) =>
|
||||
apiClient.post<{ unreadCount: number }>(
|
||||
`/support/agent/conversations/${id}/read`,
|
||||
),
|
||||
unreadCount: () =>
|
||||
apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
|
||||
apiClient.post<{ unreadCount: number }>(`/support/agent/conversations/${id}/read`),
|
||||
unreadCount: () => apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} 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,79 @@
|
||||
'use client';
|
||||
|
||||
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 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;
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage } from '@edr/types';
|
||||
import { Download, ExternalLink, FileText } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
/** The minimal file shape the preview needs. */
|
||||
export interface PreviewableFile {
|
||||
name: string;
|
||||
/**
|
||||
* A blob object URL, not the attachment's API path — the API guard reads the
|
||||
* bearer token from the `Authorization` header, which `<img>` and `<a>` can't
|
||||
* send. Callers fetch the bytes first (see `useAttachmentObjectUrl`).
|
||||
*/
|
||||
url: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a single shared preview modal for the page: call `view(file)` from any
|
||||
* attachment, render `viewer` once near the page root.
|
||||
*
|
||||
* Mirrors the shape of `useFileViewer` from `@edr/ui-common`, which the freight
|
||||
* backoffice uses. That hook can't be used here: it renders `@mantine/core`
|
||||
* components, and this app is Tailwind-only with no `MantineProvider` mounted —
|
||||
* its Modal would throw at runtime. Kept deliberately narrow (images inline,
|
||||
* everything else handed to the browser) rather than reimplementing the shared
|
||||
* viewer's pdf/office/video handling; swap this for the shared hook if this app
|
||||
* ever adopts Mantine.
|
||||
*/
|
||||
export function useFilePreview() {
|
||||
const [file, setFile] = useState<PreviewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: PreviewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<Modal isOpen={file !== null} onClose={close} title={file?.name ?? ''} size="xl">
|
||||
{file && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{isSupportAttachmentImage(file.mimeType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="max-h-[70vh] w-auto max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-10 text-muted-foreground">
|
||||
<FileText size={48} />
|
||||
<p className="text-sm">No inline preview for this file type.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in new tab
|
||||
</a>
|
||||
<a
|
||||
href={file.url}
|
||||
download={file.name}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
style={{ background: 'rgb(20 113 76)' }}
|
||||
>
|
||||
<Download size={14} />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return { view, close, viewer };
|
||||
}
|
||||
@@ -1,26 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Passenger } from '@edr/types';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { supportApi, type ListParams } from './supportApi';
|
||||
import { supportApi, type ListParams, type SendMessageInput } from './supportApi';
|
||||
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type MessageListResult = Passenger.PassengerSupportMessageListResult;
|
||||
|
||||
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
|
||||
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
||||
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
|
||||
|
||||
/** 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 status + subject/passenger search.
|
||||
*
|
||||
* Pages on scroll. This previously fetched one unbounded page and rendered
|
||||
* whatever came back, which silently truncated the inbox at the server's own
|
||||
* default of 100 with no way to reach the rest.
|
||||
*/
|
||||
export function useConversations(params: ListParams = {}) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
||||
queryFn: () => supportApi.listConversations(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 first 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: Passenger.PassengerSupportConversationDto[] = [];
|
||||
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: MessageDto): AppendOutcome {
|
||||
let outcome: AppendOutcome = 'uncached';
|
||||
qc.setQueryData<InfiniteData<MessageListResult>>(
|
||||
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 useUnreadCount(enabled = true) {
|
||||
@@ -35,9 +170,12 @@ export function useUnreadCount(enabled = true) {
|
||||
export function useSendMessage(conversationId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
|
||||
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 });
|
||||
},
|
||||
});
|
||||
@@ -48,8 +186,7 @@ export function useSetStatus() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
supportApi.setStatus(id, status),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ import { useEffect, useRef } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import {
|
||||
appendMessageToCache,
|
||||
SUPPORT_CONVERSATIONS_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
supportMessagesKey,
|
||||
} from './useSupport';
|
||||
|
||||
const SOCKET_ORIGIN = String(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
).replace(/\/api\/?$/, '');
|
||||
const SOCKET_ORIGIN = String(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000').replace(
|
||||
/\/api\/?$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribes the signed-in agent to live support pushes for the whole shared
|
||||
@@ -33,37 +35,39 @@ export function useSupportSocket(
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(
|
||||
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
||||
{
|
||||
auth: { token },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
const socket = io(`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Temporary diagnostics — remove once live delivery is confirmed.
|
||||
socket.on('connect', () =>
|
||||
console.warn('[support] agent socket connected', socket.id),
|
||||
);
|
||||
socket.on('connect', () => console.warn('[support] agent socket connected', socket.id));
|
||||
socket.on('connect_error', (err) =>
|
||||
console.warn('[support] agent socket connect_error:', err.message),
|
||||
);
|
||||
socket.on('disconnect', (reason) =>
|
||||
console.warn('[support] agent socket disconnected:', reason),
|
||||
);
|
||||
socket.on('support:hello', (info) =>
|
||||
console.warn('[support] server assigned:', info),
|
||||
);
|
||||
socket.on('support:hello', (info) => console.warn('[support] server assigned:', info));
|
||||
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: supportMessagesKey(event.message.conversationId),
|
||||
});
|
||||
// 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);
|
||||
|
||||
@@ -31,7 +31,7 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,15 @@ class ApiClient {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET without the `{ success, data }` unwrap. Binary endpoints (file streams)
|
||||
* have no envelope to unwrap, so `get` would hand back `undefined`.
|
||||
*/
|
||||
async getRaw<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<{ success: boolean; data: T }>(url, data, config);
|
||||
return response.data.data;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
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 visitor 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 (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="relative flex items-center gap-1.5 rounded-lg border border-gray-200 p-1 pr-4 dark:border-slate-600"
|
||||
>
|
||||
{a.previewUrl ? (
|
||||
// next/image can't take a `blob:` object URL — there's nothing at a
|
||||
// routable origin for the optimizer to fetch.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
className="h-9 w-9 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-300">
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 max-w-[120px]">
|
||||
<span className="block truncate text-xs font-semibold text-gray-700 dark:text-slate-200">
|
||||
{a.file.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-gray-400">{formatBytes(a.file.size)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(a.id)}
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
className="absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full bg-gray-600 text-white transition hover:bg-gray-800"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/**
|
||||
* Full-bleed preview for an image attachment.
|
||||
*
|
||||
* Deliberately local and hand-rolled rather than `@edr/ui-common`'s FileViewer:
|
||||
* that component is Mantine-based and this app mounts no MantineProvider, so
|
||||
* importing it would throw at runtime. All we need here is a backdrop and an
|
||||
* `<img>`.
|
||||
*
|
||||
* Portalled to `document.body` because its call site is nested inside the chat
|
||||
* panel, which is `overflow-hidden` and lives in the launcher's `z-30` stacking
|
||||
* context. Rendered in place, the z-[120] below would be trapped in that context
|
||||
* and mean nothing; portalled, it genuinely reaches the modal tier and paints
|
||||
* over the chat that opened it.
|
||||
*
|
||||
* Only ever rendered for image mime types: `<img>` cannot execute its source,
|
||||
* whereas navigating to or framing an attachment could. SVG is excluded from the
|
||||
* allowed upload types upstream for exactly this reason.
|
||||
*/
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 p-6"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/* Stop the backdrop's close handler firing when the image itself is
|
||||
clicked — panning a zoomed screenshot shouldn't dismiss it. */}
|
||||
{/* Plain <img>: `src` is a blob: object URL fetched through the
|
||||
authenticated client, which next/image cannot optimize. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default ImagePreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage, type Passenger } from '@edr/types';
|
||||
import { FileText, ImageOff } from 'lucide-react';
|
||||
|
||||
import { useAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: AttachmentDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs opacity-75">
|
||||
<ImageOff size={14} className="shrink-0" />
|
||||
Couldn't load {a.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="h-[120px] w-[200px] animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(a, src)}
|
||||
className="block overflow-hidden rounded-lg"
|
||||
aria-label={`View ${a.name}`}
|
||||
>
|
||||
{/* Capped: a tall screenshot would otherwise push the whole conversation
|
||||
off-screen. Plain <img>: the source is a blob: object URL, which
|
||||
next/image cannot optimize. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={a.name}
|
||||
className="max-h-[220px] max-w-[240px] cursor-zoom-in rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onViewImage,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: AttachmentDto[];
|
||||
mine: boolean;
|
||||
onViewImage: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onViewImage} />
|
||||
) : (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition ${
|
||||
mine
|
||||
? 'bg-white/20 hover:bg-white/30'
|
||||
: 'border border-gray-200 bg-white hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">{a.name}</span>
|
||||
<span className="block text-[10px] opacity-75">{formatBytes(a.size)}</span>
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Passenger } from '@edr/types';
|
||||
import { Headset, Send, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Passenger, SUPPORT_ATTACHMENT_ACCEPT } from '@edr/types';
|
||||
import { Headset, Paperclip, Send, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AttachmentDraftBar } from './AttachmentDraftBar';
|
||||
import { ImagePreview } from './ImagePreview';
|
||||
import { MessageAttachments } from './MessageAttachments';
|
||||
import { useAttachmentDraft } from './useAttachmentDraft';
|
||||
import { useLazyAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
import { useMarkRead, useSendMessage, useThread } from './useSupport';
|
||||
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** How close to an edge counts as "at" it, in px. */
|
||||
const SCROLL_EDGE_SLOP = 40;
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
@@ -19,29 +28,122 @@ function formatTime(iso?: string | null): string {
|
||||
}
|
||||
|
||||
export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
const { data, isLoading } = useThread();
|
||||
const { messages, pageCount, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useThread();
|
||||
const send = useSendMessage();
|
||||
const markRead = useMarkRead();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Holds the blob object URL the thumbnail already fetched, not the attachment
|
||||
// itself — the preview can't load from `a.url` on its own (see MessageAttachments).
|
||||
const [preview, setPreview] = useState<{ name: string; src: string } | null>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const attach = useAttachmentDraft(setError);
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
const messages = data?.messages ?? [];
|
||||
/**
|
||||
* 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 visitor 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 visitor is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
|
||||
const messageCount = messages.length;
|
||||
|
||||
// Mark read on open + whenever new messages arrive.
|
||||
useEffect(() => {
|
||||
markRead.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
}, [messageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages.length]);
|
||||
/**
|
||||
* 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 visitor is already there),
|
||||
* while an older page prepended at the top must NOT move the content they're
|
||||
* reading. Layout effect, not effect — this has to run before paint or the
|
||||
* prepend visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) 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]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
const y = el.scrollTop;
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const viewImage = (a: AttachmentDto, src: string) => setPreview({ name: a.name, src });
|
||||
|
||||
/**
|
||||
* Documents aren't fetched until opened. There's no inline viewer in this
|
||||
* widget, so the bytes are handed to the browser as a download: a
|
||||
* `window.open` after the await has lost its user-gesture and gets caught by
|
||||
* popup blockers, whereas a synthetic anchor click does not.
|
||||
*/
|
||||
const openFile = async (a: AttachmentDto) => {
|
||||
try {
|
||||
const src = await loadAttachment(a.url);
|
||||
const link = document.createElement('a');
|
||||
link.href = src;
|
||||
link.download = a.name;
|
||||
link.click();
|
||||
} catch {
|
||||
setError(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
if (!text && 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(text);
|
||||
attach.clear();
|
||||
setError(null);
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ text: text || undefined, attachments: files });
|
||||
} catch (e) {
|
||||
setDraft(text);
|
||||
setError(e instanceof Error ? e.message : "Couldn't send that message.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,7 +151,9 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
|
||||
style={{ background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))` }}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
@@ -60,17 +164,13 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-xs text-white/80">We usually reply in a few minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<button onClick={onClose} className="rounded-full p-1 hover:bg-white/20" aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
<div ref={viewport} onScroll={onScroll} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||
) : messages.length === 0 ? (
|
||||
@@ -81,20 +181,76 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<Headset size={24} />
|
||||
</span>
|
||||
Hi! 👋 How can we help you today? Send us a message and our team will
|
||||
get back to you.
|
||||
Hi! 👋 How can we help you today? Send us a message and our team will get back to you.
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => <MessageBubble key={m.id} m={m} />)
|
||||
<>
|
||||
{isFetchingNextPage && (
|
||||
<p className="py-1 text-center text-xs text-gray-400">Loading earlier messages…</p>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<p className="text-center text-[10px] text-gray-400">Start of conversation</p>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} m={m} onViewImage={viewImage} onOpenFile={openFile} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
|
||||
<div
|
||||
className={`border-t p-3 ${
|
||||
dragging
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
|
||||
: 'border-gray-100 dark:border-slate-700'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<p className="mb-2 text-xs text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<AttachmentDraftBar attachments={attach.attachments} onRemove={attach.remove} />
|
||||
<div className="flex items-end gap-2">
|
||||
<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 = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label="Attach files"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// 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();
|
||||
@@ -102,12 +258,12 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type a message…"
|
||||
placeholder="Type a message, or paste an image…"
|
||||
className="max-h-24 flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
disabled={(!draft.trim() && attach.attachments.length === 0) || send.isPending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: GREEN }}
|
||||
aria-label="Send"
|
||||
@@ -116,19 +272,29 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<ImagePreview src={preview.src} alt={preview.name} onClose={() => setPreview(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: MessageDto }) {
|
||||
function MessageBubble({
|
||||
m,
|
||||
onViewImage,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: MessageDto;
|
||||
onViewImage: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.sender === 'USER';
|
||||
return (
|
||||
<div className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="max-w-[78%]">
|
||||
{!mine && (
|
||||
<p className="mb-0.5 ml-1 text-xs text-gray-400">
|
||||
{m.authorName || 'Support agent'}
|
||||
</p>
|
||||
<p className="mb-0.5 ml-1 text-xs text-gray-400">{m.authorName || 'Support agent'}</p>
|
||||
)}
|
||||
<div
|
||||
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
|
||||
@@ -138,13 +304,17 @@ function MessageBubble({ m }: { m: MessageDto }) {
|
||||
}`}
|
||||
style={mine ? { background: GREEN } : undefined}
|
||||
>
|
||||
{m.text}
|
||||
{/* Attachment-only messages carry text: "" — rendering it anyway would
|
||||
leave an empty line above the thumbnail. */}
|
||||
{m.text && <p>{m.text}</p>}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onViewImage={onViewImage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-0.5 text-[10px] text-gray-400 ${
|
||||
mine ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
<p className={`mt-0.5 text-[10px] text-gray-400 ${mine ? 'text-right' : 'text-left'}`}>
|
||||
{formatTime(m.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Headset } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Headset } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SupportPanel } from './SupportPanel';
|
||||
import { useUnreadCount } from './useSupport';
|
||||
import { useSupportSocket } from './useSupportSocket';
|
||||
import { SupportPanel } from "./SupportPanel";
|
||||
import { useUnreadCount } from "./useSupport";
|
||||
import { useSupportSocket } from "./useSupportSocket";
|
||||
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
const GREEN = "rgb(20 113 76)";
|
||||
const GRADIENT = `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`;
|
||||
|
||||
/**
|
||||
@@ -27,14 +27,17 @@ export function SupportWidget() {
|
||||
// bottom action bars (z-40) used on booking flow pages, so the floating launcher never
|
||||
// paints over dialog content or a page's "Continue" bar — it only sits above ordinary
|
||||
// in-page content.
|
||||
<div className="fixed bottom-6 right-6 z-30 flex flex-col items-end gap-3">
|
||||
<div className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-3">
|
||||
{open && <SupportPanel onClose={() => setOpen(false)} />}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open support chat"
|
||||
className="group relative flex items-center gap-2.5 rounded-full p-2 pr-2 text-white transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||
style={{ background: GRADIENT, boxShadow: '0 10px 28px rgba(20,113,76,0.4)' }}
|
||||
style={{
|
||||
background: GRADIENT,
|
||||
boxShadow: "0 10px 28px rgba(20,113,76,0.4)",
|
||||
}}
|
||||
>
|
||||
{/* Faint breathing ring; the unread badge is loud enough on its own. */}
|
||||
{unread === 0 && (
|
||||
@@ -56,7 +59,7 @@ export function SupportWidget() {
|
||||
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full border-2 border-white bg-red-500 px-1 text-xs font-bold text-white">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -6,20 +6,81 @@ import { getDeviceId } from './deviceIdentity';
|
||||
type ThreadDto = Passenger.PassengerSupportThreadDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
|
||||
/** Messages per page when walking the thread backwards. */
|
||||
export const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
export interface GetThreadParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`; omit for the newest page. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
text?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server persists them against the
|
||||
* message it creates in the same request; text-only stays JSON.
|
||||
*
|
||||
* The `Content-Type: undefined` override is load-bearing, not cargo cult.
|
||||
* `apiClient` pins `application/json` as an instance default, and axios's
|
||||
* request transform reads that header to decide what to do with a FormData body:
|
||||
* when it sees JSON it runs the form through `formDataToJSON` and posts *that*,
|
||||
* so the files would be silently dropped and the server would see an empty
|
||||
* message. Clearing the header lets axios's XHR adapter set `multipart/form-data`
|
||||
* itself, with the boundary — which is also why we never write that value by
|
||||
* hand: a hand-set Content-Type has no boundary and fails to parse.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): {
|
||||
data: FormData | { deviceId: string; text?: string };
|
||||
config?: { headers: Record<string, undefined> };
|
||||
} {
|
||||
const deviceId = getDeviceId();
|
||||
if (!input.attachments?.length) {
|
||||
return { data: { deviceId, text: input.text } };
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('deviceId', deviceId);
|
||||
// Attachment-only messages send an empty string rather than omitting the
|
||||
// field — the DTO models text as "" for those, not as absent.
|
||||
form.append('text', input.text ?? '');
|
||||
for (const file of input.attachments) form.append('attachments', file);
|
||||
return { data: form, config: { headers: { 'Content-Type': undefined } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Passenger portal support-chat calls — a single device-scoped thread. No auth,
|
||||
* no forms; everything is keyed by a localStorage device id.
|
||||
*/
|
||||
export const supportApi = {
|
||||
getThread: (): Promise<ThreadDto> =>
|
||||
/**
|
||||
* The device's thread. Returns the *newest page* of messages plus a cursor —
|
||||
* not the whole conversation; page backwards with `before`.
|
||||
*/
|
||||
getThread: (params: GetThreadParams = {}): Promise<ThreadDto> =>
|
||||
apiClient.get('/support/device/thread', {
|
||||
params: { deviceId: getDeviceId() },
|
||||
}),
|
||||
sendMessage: (text: string): Promise<MessageDto> =>
|
||||
apiClient.post('/support/device/messages', {
|
||||
deviceId: getDeviceId(),
|
||||
text,
|
||||
params: { deviceId: getDeviceId(), ...params },
|
||||
}),
|
||||
sendMessage: (input: SendMessageInput): Promise<MessageDto> => {
|
||||
const { data, config } = toRequestBody(input);
|
||||
return apiClient.post('/support/device/messages', data, config);
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through this client rather than linked directly:
|
||||
* the API guard reads the bearer token from the `Authorization` header with no
|
||||
* cookie fallback, and an `<img src>` can't carry that header — it would 401.
|
||||
*
|
||||
* `relativeUrl` is the DTO's `url` (`/support/attachments/:id`); the passenger
|
||||
* API has no global prefix, so it appends to the client's baseURL as-is.
|
||||
* `apiClient.get` unwraps a `{ data }` envelope when it finds one; a Blob has
|
||||
* no such property, so the body comes back untouched.
|
||||
*/
|
||||
fetchAttachment: (relativeUrl: string): Promise<Blob> =>
|
||||
apiClient.get<Blob>(relativeUrl, { responseType: 'blob' }),
|
||||
markRead: (): Promise<{ unreadCount: number }> =>
|
||||
apiClient.post('/support/device/read', { deviceId: getDeviceId() }),
|
||||
unreadCount: (): Promise<{ unreadCount: number }> =>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} 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 visitor changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, pasting screenshots into a long-lived 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,79 @@
|
||||
'use client';
|
||||
|
||||
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 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,19 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Passenger } from '@edr/types';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
type UseInfiniteQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { supportApi } from './supportApi';
|
||||
import { MESSAGES_PAGE_SIZE, supportApi, type SendMessageInput } from './supportApi';
|
||||
|
||||
type ThreadDto = Passenger.PassengerSupportThreadDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
|
||||
export const SUPPORT_THREAD_KEY = ['support', 'thread'];
|
||||
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
||||
|
||||
/** The device's single support thread (conversation + messages). */
|
||||
export function useThread(enabled = true) {
|
||||
return useQuery({
|
||||
/**
|
||||
* The device's single support thread, 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 can't
|
||||
* shift or duplicate the pages already loaded.
|
||||
*/
|
||||
// The return type is spelled out rather than inferred: spreading the query
|
||||
// result produces an anonymous type that names symbols from `query-core`, which
|
||||
// pnpm's non-flat node_modules makes unnameable from here (TS2742).
|
||||
export function useThread(enabled = true): UseInfiniteQueryResult<
|
||||
InfiniteData<ThreadDto>,
|
||||
Error
|
||||
> & {
|
||||
conversation: ConversationDto | null;
|
||||
messages: MessageDto[];
|
||||
pageCount: number;
|
||||
} {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: SUPPORT_THREAD_KEY,
|
||||
queryFn: () => supportApi.getThread(),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.getThread({ before: pageParam, limit: MESSAGES_PAGE_SIZE }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Page 0 is the newest fetch, so its conversation carries the freshest status
|
||||
// and unread count even after older pages load.
|
||||
const conversation = query.data?.pages[0]?.conversation ?? null;
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.messages),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// `pageCount` is 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,
|
||||
conversation,
|
||||
messages,
|
||||
pageCount: query.data?.pages.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into the cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: the thread is paginated, so refetching
|
||||
* would re-request *every* page the visitor has scrolled back through on every
|
||||
* single inbound message — the cost of a message would grow with how far they've
|
||||
* read. Page 0 is the newest block and its messages are oldest-first within the
|
||||
* block, so a new message belongs on its end.
|
||||
*
|
||||
* Doesn't seed the cache when the thread isn't loaded: a partial cache here would
|
||||
* leave a thread whose "first page" is one message and whose `nextCursor` is
|
||||
* missing — which would render as a complete conversation with no way to page
|
||||
* back. The caller is told instead, via the outcome below.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than nothing: the two ways it can decline to
|
||||
* append need opposite handling. A duplicate is our 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: MessageDto): AppendOutcome {
|
||||
let outcome: AppendOutcome = 'uncached';
|
||||
qc.setQueryData<InfiniteData<ThreadDto>>(SUPPORT_THREAD_KEY, (current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
// Our own message arrives twice — once as the POST response, once as the
|
||||
// socket echo. Ignore the duplicate rather than render it twice.
|
||||
if (newest.messages.some((m) => m.id === message.id)) {
|
||||
outcome = 'duplicate';
|
||||
return current;
|
||||
}
|
||||
outcome = 'appended';
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest],
|
||||
};
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cached conversation with a freshly-pushed copy.
|
||||
*
|
||||
* Page 0 is where `useThread` reads the conversation from, so patching it there
|
||||
* is enough; `messages` and `nextCursor` are left exactly as they were, which is
|
||||
* the point — this must not disturb the paged history. No-op when the thread
|
||||
* isn't cached: the in-flight fetch will bring the current conversation anyway.
|
||||
*/
|
||||
export function patchConversationInCache(qc: QueryClient, conversation: ConversationDto): void {
|
||||
qc.setQueryData<InfiniteData<ThreadDto>>(SUPPORT_THREAD_KEY, (current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
return { ...current, pages: [{ ...newest, conversation }, ...rest] };
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
@@ -28,9 +141,11 @@ export function useUnreadCount(enabled = true) {
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (text: string) => supportApi.sendMessage(text),
|
||||
mutationFn: (input: SendMessageInput) => supportApi.sendMessage(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 visitor has scrolled through.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
@@ -40,8 +155,9 @@ export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => supportApi.markRead(),
|
||||
// Same reasoning as above: reading a thread changes only the badge, and the
|
||||
// messages already on screen are the ones being marked.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,11 +6,17 @@ import { useEffect } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import { getDeviceId } from './deviceIdentity';
|
||||
import { SUPPORT_THREAD_KEY, SUPPORT_UNREAD_KEY } from './useSupport';
|
||||
import {
|
||||
appendMessageToCache,
|
||||
patchConversationInCache,
|
||||
SUPPORT_THREAD_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
} from './useSupport';
|
||||
|
||||
const SOCKET_ORIGIN = String(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
).replace(/\/api\/?$/, '');
|
||||
const SOCKET_ORIGIN = String(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000').replace(
|
||||
/\/api\/?$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribes the device to live support pushes. The gateway joins a
|
||||
@@ -22,23 +28,42 @@ export function useSupportSocket(enabled: boolean) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') return;
|
||||
const socket = io(
|
||||
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
||||
{
|
||||
auth: { guestId: getDeviceId() },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
const socket = io(`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`, {
|
||||
auth: { guestId: getDeviceId() },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated now, and
|
||||
// invalidating would refetch every loaded page on every inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === 'uncached') {
|
||||
// The thread's first page is still loading and may have been read on
|
||||
// the server before this message existed — without this it would go
|
||||
// missing until some unrelated refetch. Cheap: no pages loaded yet.
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
);
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
};
|
||||
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, refresh);
|
||||
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, refresh);
|
||||
// Patch the conversation in place rather than invalidating the thread key —
|
||||
// the same key the paginated history uses. The server emits this alongside
|
||||
// MESSAGE_NEW for *every* message, so invalidating here would refetch every
|
||||
// page the visitor has scrolled back through on each one, cancelling out the
|
||||
// append above entirely. The payload is the updated conversation, so there's
|
||||
// nothing to go back to the server for.
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
(conversation: Passenger.PassengerSupportConversationDto) => {
|
||||
patchConversationInCache(qc, conversation);
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
|
||||
Reference in New Issue
Block a user