mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 23:13:40 +00:00
feat: setup the attachment to the passenger clients
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 ${
|
||||
|
||||
Reference in New Issue
Block a user