diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index 246a6f4f9..3e1156818 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -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 = { 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(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) => { + if (!hasNextPage || isFetchingNextPage) return; + const el = e.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_EDGE_SLOP) { + fetchNextPage(); + } + }; + return (
@@ -73,10 +92,7 @@ export default function SupportPage() {
- + 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() { ))}
-
+
{isLoading ? ( -
- Loading… -
+
Loading…
) : items.length === 0 ? ( -
- No conversations. -
+
No conversations.
) : ( - items.map((c) => ( - setSelectedId(c.id)} - /> - )) + <> + {items.map((c) => ( + setSelectedId(c.id)} + /> + ))} + {isFetchingNextPage && ( +
Loading more…
+ )} + )}
@@ -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' } + } >
(null); const viewport = useRef(null); + const fileInput = useRef(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) => { + 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 (
@@ -282,21 +399,85 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
-
+
{isLoading ? ( -
- Loading… -
+
Loading…
+ ) : messages.length === 0 ? ( +
No messages yet.
) : ( - (messages ?? []).map((m) => ) + <> + {isFetchingNextPage && ( +
+ Loading earlier messages… +
+ )} + {!hasNextPage && ( +
+ Start of conversation +
+ )} + {messages.map((m) => ( + + ))} + )}
-
+
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + attach.add(Array.from(e.dataTransfer.files)); + }} + > + {error && ( +
+ {error} + +
+ )} +
+ { + attach.add(Array.from(e.currentTarget.files ?? [])); + // Reset so picking the same file twice in a row still fires change. + e.currentTarget.value = ''; + }} + /> +