feat: setup attachment to the freight chat

This commit is contained in:
Nathnael
2026-07-18 08:52:06 +00:00
committed by Hagernesh
parent c1c362ab9d
commit 9fbe1c5236
32 changed files with 2582 additions and 195 deletions

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Let a support message carry files instead of text.
*
* No new table: chat attachments reuse the polymorphic `freight.files` record
* with `resource = 'support_message'` and `resource_id = <message id>`, the same
* way bookings/contracts/companies already store theirs.
*
* The only schema change is dropping NOT NULL from `support_messages.body`, so
* an attachment-only message can say "there is no text" rather than smuggling
* that fact through an empty string. DROP NOT NULL is a catalog-only change in
* Postgres — no table rewrite, no long lock — so this is safe on a live table.
*
* The partial index on (resource, resource_id) is what makes hydrating a page of
* messages one indexed lookup instead of a scan of every file row in the system.
*/
export class SupportChatAttachments2320000000000 implements MigrationInterface {
name = "SupportChatAttachments2320000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.support_messages
ALTER COLUMN body DROP NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP"
ON freight.files (resource, resource_id)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP"
`);
// Re-imposing NOT NULL would fail on any attachment-only message written
// while this migration was applied. Backfill those to '' first so the
// rollback is deterministic rather than dependent on production data.
await queryRunner.query(`
UPDATE freight.support_messages SET body = '' WHERE body IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.support_messages
ALTER COLUMN body SET NOT NULL
`);
}
}

View File

@@ -1,12 +1,19 @@
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
import {
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { Response } from "express";
import { FilesService } from "./files.service";
@@ -23,13 +30,16 @@ export class FilesController {
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
// token should use a short-lived signed URL instead (FilesService.signUrl).
// TODO: enforce ownership-by-resource here next (scope the file to the
// caller's booking/company before streaming).
// caller's booking/company before streaming). Until that lands, any resource
// whose files are cross-tenant sensitive must opt OUT of this route and expose
// its own checked endpoint — see the support_message case below.
@ApiOperation({
summary: "Stream a file by ID",
description:
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
"No resource context (e.g. booking ID) required. Serves inline by default so " +
"the browser can preview it; pass ?download=1 to force a download.",
"the browser can preview it; pass ?download=1 to force a download. " +
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
})
@ApiQuery({
name: "download",
@@ -41,7 +51,19 @@ export class FilesController {
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const { stream, record } = await this.filesService.streamById(fileId);
const record = await this.filesService.findById(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
// another. SupportAttachmentController scopes the caller to the owning
// thread; refuse here rather than quietly serving the bytes.
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
throw new ForbiddenException(
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
);
}
const { stream } = await this.filesService.streamById(fileId);
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { FileRecord } from "./entities/file.entity";
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
return this.repository.find({ where: { resourceId, resource } });
}
/**
* Batch sibling of {@link findByResource} for hydrating a page of resources at
* once (a thread of chat messages, say) instead of one query per row.
*/
async findByResourceIds(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
if (resourceIds.length === 0) return [];
return this.repository.find({
where: { resourceId: In(resourceIds), resource },
order: { createdAt: "ASC" },
});
}
findByCode(
resourceId: string,
resource: string,

View File

@@ -3,6 +3,7 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
@@ -78,8 +79,19 @@ export class FilesService {
// percent-encoded in the URL and no longer match the MinIO key). The
// human-readable name is preserved separately on the record below.
const safeName = sanitizeObjectName(file.originalname);
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
// The random segment is load-bearing, not decoration. `Date.now()` alone is
// NOT unique across a batch: callers upload with Promise.all, every callback
// runs to its first await in the same tick, so they all read the same
// millisecond. Two files with one name in one batch — e.g. pasting two
// screenshots, which browsers both call "image.png" — would build identical
// keys, and the second putObject would overwrite the first while both rows
// persisted pointing at the same object.
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
const url = await this.minioService.uploadFile(
objectName,
file.buffer,
file.mimetype,
);
return this.filesRepository.create({
resourceId,
@@ -175,6 +187,27 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).
*/
async findByResourceIdsGrouped(
resourceIds: string[],
resource: string,
): Promise<Map<string, FileRecord[]>> {
const records = await this.filesRepository.findByResourceIds(
resourceIds,
resource,
);
const grouped = new Map<string, FileRecord[]>();
for (const record of records) {
const bucket = grouped.get(record.resourceId);
if (bucket) bucket.push(record);
else grouped.set(record.resourceId, [record]);
}
return grouped;
}
/**
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
* `url` is an un-signed object path that a browser cannot fetch directly;
@@ -190,7 +223,11 @@ export class FilesService {
resource: string,
code: string,
): Promise<FileRecord> {
const record = await this.filesRepository.findByCode(resourceId, resource, code);
const record = await this.filesRepository.findByCode(
resourceId,
resource,
code,
);
if (!record)
throw new NotFoundException(
`File with code "${code}" not found for ${resource} ${resourceId}`,
@@ -198,7 +235,9 @@ export class FilesService {
return record;
}
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
async streamById(
id: string,
): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);

View File

@@ -0,0 +1,25 @@
import {
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
} from "@edr/types";
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
/** Multipart field name carrying chat files. */
export const SUPPORT_ATTACHMENT_FIELD = "attachments";
/**
* Multer-level caps for the chat send routes.
*
* These duplicate the checks in `SupportChatService.assertSendable` on purpose,
* and are not a substitute for them: Multer stops reading the socket once a part
* exceeds `fileSize`, so an oversized upload is cut off mid-stream instead of
* being buffered into memory and rejected after the fact. The service-level
* check is what produces the readable error message and covers callers that
* don't come through this interceptor.
*/
export const supportAttachmentMulterOptions: MulterOptions = {
limits: {
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
},
};

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
/** Default page size for a thread — roughly two screens of bubbles. */
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
export class ListMessagesQueryDto {
@ApiPropertyOptional({
description:
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
"of messages immediately OLDER than the cursor. Omit for the newest page.",
})
@IsOptional()
@IsString()
before?: string;
@ApiPropertyOptional({
minimum: 1,
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
limit?: number;
}

View File

@@ -1,11 +1,15 @@
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, MaxLength } from "class-validator";
export class SendMessageDto implements ISendSupportMessageDto {
@ApiProperty({ description: "Message text." })
@ApiPropertyOptional({
description:
"Message text. Optional only when the request carries attachments — the " +
"service rejects a message that is neither text nor files.",
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(4000)
body!: string;
body?: string;
}

View File

@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
authorName?: string | null;
@Column({ name: "body", type: "text" })
body!: string;
/**
* NULL for an attachment-only message. Nullable rather than "" so the absence
* of text is representable instead of guessed at; the DTO maps NULL → "".
*/
@Column({ name: "body", type: "text", nullable: true })
body?: string | null;
}

View File

@@ -0,0 +1,54 @@
import { BadRequestException } from "@nestjs/common";
/**
* Keyset cursor for paging a thread backwards from newest.
*
* The cursor is just a message id. The sort key is the pair `(created_at, id)` —
* two messages can share a timestamp, and a cursor on a non-unique key either
* re-serves or skips the tied rows — but the *timestamp half is never sent over
* the wire*, because it cannot survive the trip.
*
* `support_messages.created_at` is `timestamptz(6)`; a JS `Date` holds only
* milliseconds, so the value TypeORM hands back is already truncated. Encoding
* that into the cursor and comparing against it would silently skip every row
* sharing the cursor's millisecond but earlier within it (`.254100` is not
* `< .254000`) — those rows would never appear on any page. Sending the id alone
* and letting Postgres look the real `(created_at, id)` up keeps the comparison
* at full precision on the server, where it was never lossy.
*
* Opaque on purpose (base64): clients must treat it as a token, so the sort key
* can change without a contract change.
*
* The passenger API's twin encodes a timestamp because its column is
* `TIMESTAMP(3)` — millisecond, matching JS exactly — so it has no such loss.
* The two formats are deliberately NOT interchangeable; each app reads only its
* own cursors.
*/
export function encodeMessageCursor(id: string): string {
return Buffer.from(id, "utf8").toString("base64url");
}
/**
* Parse a client-supplied cursor. Rejects anything malformed rather than
* silently falling back to "first page" — a corrupted cursor that degrades to
* page 1 makes an infinite scroll loop forever over the same rows.
*/
export function decodeMessageCursor(raw: string): string {
let id: string;
try {
id = Buffer.from(raw, "base64url").toString("utf8");
} catch {
throw new BadRequestException("Malformed pagination cursor.");
}
// The id goes into a parameterized query, but validate the shape anyway: a
// non-uuid can only be a mangled cursor, and failing loudly here beats an
// empty page that reads as "start of conversation".
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
) {
throw new BadRequestException("Malformed pagination cursor.");
}
return id;
}

View File

@@ -0,0 +1,100 @@
import { CurrentUser } from "@edr/api-common";
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { Response } from "express";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FilesService } from "../files/files.service";
import { SupportChatService } from "./support-chat.service";
/**
* Authenticated download for chat attachments.
*
* This exists instead of reusing `GET /files/:fileId` because that route streams
* any file to any authenticated caller who knows its UUID — fine-ish for a
* booking document the caller already had a link to, not fine for chat, where
* one customer guessing another's file id would be a cross-tenant leak. That
* route now refuses `support_message` files outright and points here.
*
* Inline previews still use the short-lived signed URL on the message DTO — a
* browser `<img>` can't send a Bearer token. This route is for explicit
* downloads and for clients that would rather stream through the API.
*/
@ApiTags("support-chat")
@ApiBearerAuth()
@Controller("support/attachments")
export class SupportAttachmentController {
constructor(
private readonly files: FilesService,
private readonly chat: SupportChatService,
) {}
@Get(":fileId")
@ApiOperation({
summary: "Download a support chat attachment",
description:
"Streams the file only if the caller is backoffice staff or belongs to the " +
"company that owns the thread the attachment was posted in.",
})
@ApiQuery({
name: "download",
required: false,
description: "Set to 1/true to force a download instead of inline preview.",
})
async download(
@CurrentUser() user: AuthUserPayload,
@Param("fileId", ParseUUIDPipe) fileId: string,
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const record = await this.files.findById(fileId);
// Don't let this route become a second general-purpose file endpoint: it can
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
// no reason to confirm the id exists).
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
throw new NotFoundException(`File ${fileId} not found`);
}
const allowed = await this.chat.canUserAccessMessage(
record.resourceId,
resolveAuthUserId(user),
);
if (!allowed) {
throw new ForbiddenException(
"This attachment belongs to another company's conversation.",
);
}
const { stream } = await this.files.streamById(fileId);
const forceDownload = download === "1" || download === "true";
res.setHeader("Content-Type", record.mimeType);
res.setHeader(
"Content-Disposition",
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
);
// Private only — this response is scoped to one caller's authorization, so a
// shared cache must never reuse it for the next person asking.
res.setHeader("Cache-Control", "private, max-age=300");
stream.pipe(res);
}
}

View File

@@ -1,5 +1,8 @@
import { CurrentUser } from "@edr/api-common";
import { SupportAuthorRole } from "@edr/types";
import {
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SupportAuthorRole,
} from "@edr/types";
import {
Body,
Controller,
@@ -8,14 +11,22 @@ import {
ParseUUIDPipe,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { StartConversationDto } from "./dto/start-conversation.dto";
import { SupportChatService } from "./support-chat.service";
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
}
@Get("conversations/:id/messages")
@ApiOperation({ summary: "List messages in a thread" })
messages(@Param("id", ParseUUIDPipe) id: string) {
return this.service.getMessages(id);
@ApiOperation({
summary: "List messages in a thread (newest page first)",
description:
"Keyset-paginated backwards from the newest message. Omit `before` for " +
"the newest page, then pass the previous response's `nextCursor` to walk " +
"back through history. `nextCursor: null` means the thread's start.",
})
messages(
@Param("id", ParseUUIDPipe) id: string,
@Query() query: ListMessagesQueryDto,
) {
return this.service.getMessages(id, query);
}
@Post("conversations/:id/messages")
@ApiOperation({ summary: "Reply as an agent" })
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
supportAttachmentMulterOptions,
),
)
// Accepts multipart (text + files) or plain JSON (text only) — Multer passes
// non-multipart requests straight through, so existing JSON clients are
// unaffected.
@ApiConsumes("multipart/form-data", "application/json")
@ApiBody({
schema: {
type: "object",
properties: {
body: { type: "string" },
attachments: {
type: "array",
items: { type: "string", format: "binary" },
},
},
},
})
@ApiOperation({ summary: "Reply as an agent, optionally with attachments" })
send(
@CurrentUser() user: AuthUserPayload,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
return this.service.sendAsAgent(
id,
resolveAuthUserId(user),
body.body,
attachments ?? [],
);
}
@Post("conversations/:id/read")

View File

@@ -1,12 +1,29 @@
import { CurrentUser } from "@edr/api-common";
import { SupportAuthorRole } from "@edr/types";
import { Body, Controller, Get, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SupportAuthorRole,
} from "@edr/types";
import {
Body,
Controller,
Get,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { SupportChatService } from "./support-chat.service";
@@ -29,17 +46,55 @@ export class SupportChatController {
}
@Get("conversation/messages")
@ApiOperation({ summary: "Messages in my company's support thread" })
messages(@CurrentUser() user: AuthUserPayload) {
return this.service.getCustomerMessages(resolveAuthUserId(user));
@ApiOperation({
summary: "Messages in my company's support thread (newest page first)",
description:
"Keyset-paginated backwards from the newest message. Omit `before` for " +
"the newest page, then pass the previous response's `nextCursor` to walk " +
"back through history. `nextCursor: null` means the thread's start.",
})
messages(
@CurrentUser() user: AuthUserPayload,
@Query() query: ListMessagesQueryDto,
) {
return this.service.getCustomerMessages(resolveAuthUserId(user), query);
}
@Post("conversation/messages")
@ApiOperation({
summary: "Send a message as the customer, opening the thread if needed",
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
supportAttachmentMulterOptions,
),
)
@ApiConsumes("multipart/form-data", "application/json")
@ApiBody({
schema: {
type: "object",
properties: {
body: { type: "string" },
attachments: {
type: "array",
items: { type: "string", format: "binary" },
},
},
},
})
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
@ApiOperation({
summary:
"Send a message as the customer (optionally with attachments), opening the thread if needed",
})
send(
@CurrentUser() user: AuthUserPayload,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendAsCustomer(
resolveAuthUserId(user),
body.body,
attachments ?? [],
);
}
@Post("conversation/read")

View File

@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { SupportAttachmentController } from "./support-attachment.controller";
import { SupportChatAgentController } from "./support-chat-agent.controller";
import { SupportChatController } from "./support-chat.controller";
import { SupportChatGateway } from "./support-chat.gateway";
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
BackofficeModule,
// WsAuthService — reused handshake authentication for the gateway.
NotificationInboxModule,
// FilesService — chat attachments are stored as polymorphic file records.
FilesModule,
],
controllers: [
SupportChatController,
SupportChatAgentController,
SupportAttachmentController,
],
controllers: [SupportChatController, SupportChatAgentController],
providers: [
SupportConversationRepository,
SupportMessageRepository,
SupportChatGateway,
SupportChatService,
],
// FilesController's ownership check for `support_message` files defers to this
// service — see SupportAttachmentAccess.
exports: [SupportChatService],
})
export class SupportChatModule {}

View File

@@ -1,22 +1,37 @@
import {
isSupportAttachmentAllowed,
SendSupportMessageResult,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SUPPORT_ATTACHMENT_RESOURCE,
SupportAttachmentDto,
SupportAuthorRole,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { QueryFailedError } from "typeorm";
import { BackofficeService } from "../backoffice/backoffice.service";
import { CompaniesService } from "../companies/companies.service";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
import { FileRecord } from "../files/entities/file.entity";
import { FilesService } from "../files/files.service";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import {
ListMessagesQueryDto,
SUPPORT_MESSAGES_DEFAULT_LIMIT,
} from "./dto/list-messages-query.dto";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
import { SupportChatGateway } from "./support-chat.gateway";
import { SupportConversationRepository } from "./support-conversation.repository";
import { SupportMessageRepository } from "./support-message.repository";
@@ -30,6 +45,9 @@ interface CustomerContext {
/** Postgres unique_violation — the one-thread-per-company index fired. */
const PG_UNIQUE_VIOLATION = "23505";
/** Stand-in preview for a message that is nothing but files. */
const ATTACHMENT_ONLY_PREVIEW = "📎";
@Injectable()
export class SupportChatService {
constructor(
@@ -38,6 +56,8 @@ export class SupportChatService {
private readonly gateway: SupportChatGateway,
private readonly externalProfiles: ExternalProfileRepository,
private readonly companies: CompaniesService,
private readonly files: FilesService,
private readonly backoffice: BackofficeService,
) {}
// ---- customer (portal) -------------------------------------------------
@@ -51,7 +71,9 @@ export class SupportChatService {
userId: string,
): Promise<SupportConversationDto | null> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return null;
const unread = await this.messages.unreadCountsByConversation(
[conversation.id],
@@ -63,17 +85,23 @@ export class SupportChatService {
);
}
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
async getCustomerMessages(
userId: string,
query: ListMessagesQueryDto = {},
): Promise<SupportMessageListResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
if (!conversation) return [];
return this.listMessages(conversation.id);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return { items: [], nextCursor: null };
return this.listMessages(conversation.id, query);
}
/** Send as the customer, opening the thread if this is the first message. */
async sendAsCustomer(
userId: string,
body: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SendSupportMessageResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.getOrCreate(
@@ -87,16 +115,19 @@ export class SupportChatService {
SupportAuthorRole.CUSTOMER,
body,
ctx.authorName,
attachments,
);
return {
conversation: this.toConversationDto(updated, 0),
message: this.toMessageDto(message),
message: message,
};
}
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (conversation) {
await this.conversations.update(conversation.id, {
customerLastReadAt: new Date(),
@@ -145,7 +176,8 @@ export class SupportChatService {
async sendAsAgent(
conversationId: string,
userId: string,
body: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
const { message } = await this.appendMessage(
@@ -153,8 +185,10 @@ export class SupportChatService {
userId,
SupportAuthorRole.AGENT,
body,
undefined,
attachments,
);
return this.toMessageDto(message);
return message;
}
async markAgentRead(
@@ -171,18 +205,56 @@ export class SupportChatService {
// ---- shared ------------------------------------------------------------
/**
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
* company owns it (portal route); omit for agents, who see every thread.
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
* to enforce that the caller's company owns it (portal route); omit for
* agents, who see every thread.
*/
async getMessages(
conversationId: string,
query: ListMessagesQueryDto = {},
asCustomerUserId?: string,
): Promise<SupportMessageDto[]> {
): Promise<SupportMessageListResult> {
const conversation = await this.requireConversation(conversationId);
if (asCustomerUserId) {
await this.assertCustomerOwns(conversation, asCustomerUserId);
}
return this.listMessages(conversationId);
return this.listMessages(conversationId, query);
}
/**
* May `userId` read the message that a chat attachment hangs off? Backstop for
* the file-download route, which otherwise streams any file to any
* authenticated caller who knows its UUID.
*
* Backoffice staff see every thread (they work a shared inbox); a portal user
* sees only their own company's. Fails **closed** — an unresolvable message,
* conversation, or staff list denies rather than falls through, since the
* caller uses this to decide whether to hand over raw bytes.
*/
async canUserAccessMessage(
messageId: string,
userId: string,
): Promise<boolean> {
const message = await this.messages.findById(messageId);
if (!message) return false;
const conversation = await this.conversations.findById(
message.conversationId,
);
if (!conversation) return false;
try {
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
if (staffIds.includes(userId)) return true;
} catch {
// Staff lookup is best-effort for room-joining in the gateway, but here it
// gates bytes: on failure fall through to the (stricter) company check
// rather than assuming staff.
}
const profile = await this.externalProfiles.findByUserId(userId);
return Boolean(
profile?.companyId && profile.companyId === conversation.companyId,
);
}
async unreadCount(
@@ -237,29 +309,86 @@ export class SupportChatService {
private async listMessages(
conversationId: string,
): Promise<SupportMessageDto[]> {
const rows = await this.messages.listByConversation(conversationId);
return rows.map((m) => this.toMessageDto(m));
query: ListMessagesQueryDto,
): Promise<SupportMessageListResult> {
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
const before = query.before ? decodeMessageCursor(query.before) : undefined;
// The repo returns newest-first and over-fetches by one to probe for a
// further page.
const rows = await this.messages.listByConversation(
conversationId,
limit,
before,
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const oldest = page[page.length - 1];
const nextCursor =
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
// Flip to oldest-first so the client can prepend a page as one block.
const items = await this.toMessageDtos([...page].reverse());
return { items, nextCursor };
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
/**
* Persist a message (plus any attachments), bump the conversation's
* denormalized fields, emit live.
*
* Files are validated *before* the row is written: a rejected upload should
* leave no message behind, and a half-uploaded batch is worse than none.
*/
private async appendMessage(
conversation: SupportConversation,
userId: string,
role: SupportAuthorRole,
body: string,
body: string | undefined,
authorName?: string | null,
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
attachments: Express.Multer.File[] = [],
): Promise<{
conversation: SupportConversation;
message: SupportMessageDto;
}> {
const text = (body ?? "").trim();
this.assertSendable(text, attachments);
const message = await this.messages.create({
conversationId: conversation.id,
authorUserId: userId,
authorRole: role,
authorName: authorName ?? null,
body,
// NULL, not "", so "this message has no text" is representable rather than
// inferred. The DTO flattens it back to "" for rendering.
body: text || null,
});
// The row has to exist before the files, since each one is stored against
// `resourceId = message.id`. That leaves a window: if a upload fails here,
// the message is already committed. Undo it rather than leave the thread
// with a permanently blank bubble — there is no delete flow, so an orphan
// would be unremovable, and an attachment-only message that lost its files
// has no content at all.
let stored: FileRecord[];
try {
stored = await Promise.all(
attachments.map((file) =>
this.files.upload({
resourceId: message.id,
resource: SUPPORT_ATTACHMENT_RESOURCE,
code: "attachment",
file,
}),
),
);
} catch (error) {
await this.messages.softDelete(message.id);
throw error;
}
conversation.lastMessageAt = message.createdAt;
conversation.lastMessagePreview = body.slice(0, 280);
conversation.lastMessagePreview = this.buildPreview(text, stored);
conversation.lastMessageAuthorRole = role;
await this.conversations.update(conversation.id, {
lastMessageAt: conversation.lastMessageAt,
@@ -267,13 +396,55 @@ export class SupportChatService {
lastMessageAuthorRole: role,
});
const messageDto = await this.toMessageDto(message, stored);
const dto = this.toConversationDto(conversation, 0);
this.gateway.emitMessage(
conversation.companyId,
dto,
this.toMessageDto(message),
);
return { conversation, message };
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
return { conversation, message: messageDto };
}
/**
* Guard the chat-specific upload rules. These are tighter than
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
* which exist for scanned business documents — chat files are pushed at
* another human, so the allowlist is narrower and SVG is excluded outright.
*/
private assertSendable(
text: string,
attachments: Express.Multer.File[],
): void {
if (!text && attachments.length === 0) {
throw new BadRequestException(
"A message needs text or at least one attachment.",
);
}
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
throw new BadRequestException(
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
}
for (const file of attachments) {
if (!isSupportAttachmentAllowed(file.mimetype)) {
throw new BadRequestException(
`Unsupported attachment type: ${file.mimetype}`,
);
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
throw new BadRequestException(
`"${file.originalname}" exceeds the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB attachment limit.`,
);
}
}
}
/** Inbox preview line — falls back to the filenames when there's no text. */
private buildPreview(text: string, attachments: FileRecord[]): string {
if (text) return text.slice(0, 280);
if (attachments.length === 1) {
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
}
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
}
private async buildListResult(
@@ -320,7 +491,9 @@ export class SupportChatService {
): Promise<CustomerContext> {
const ctx = await this.resolveCustomer(userId);
if (conversation.companyId !== ctx.companyId) {
throw new ForbiddenException("This conversation belongs to another company.");
throw new ForbiddenException(
"This conversation belongs to another company.",
);
}
return ctx;
}
@@ -353,15 +526,58 @@ export class SupportChatService {
};
}
private toMessageDto(m: SupportMessage): SupportMessageDto {
/** Hydrate + map a page of messages, batching the attachment lookup. */
private async toMessageDtos(
rows: SupportMessage[],
): Promise<SupportMessageDto[]> {
if (rows.length === 0) return [];
const grouped = await this.files.findByResourceIdsGrouped(
rows.map((r) => r.id),
SUPPORT_ATTACHMENT_RESOURCE,
);
return Promise.all(
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
);
}
private async toMessageDto(
m: SupportMessage,
attachments: FileRecord[],
): Promise<SupportMessageDto> {
return {
id: m.id,
conversationId: m.conversationId,
authorUserId: m.authorUserId,
authorRole: m.authorRole,
authorName: m.authorName ?? null,
body: m.body,
body: m.body ?? "",
attachments: attachments.map((a) => this.toAttachmentDto(a)),
createdAt: new Date(m.createdAt).toISOString(),
};
}
/**
* Where the browser fetches the bytes: the API's own ownership-checked stream
* route, NOT a presigned MinIO URL.
*
* Presigned object URLs are not reachable from the browser in this deployment
* — the same reason every other file in the app streams through
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
* attachments stream through `GET /api/support/attachments/:id`, which runs the
* same-company / staff ownership check before serving a byte.
*
* A root-relative path; the web app prepends its API origin. The `<img>` sends
* the `auth-token` cookie automatically (same-site across dev ports), which is
* how the guard authenticates a request that can't carry a bearer header.
*/
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
return {
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
url: `/api/support/attachments/${f.id}`,
};
}
}

View File

@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<SupportMessage> {
super(repo);
}
/** All messages of a conversation, oldest first. */
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
return this.repository.find({
where: { conversationId },
order: { createdAt: "ASC" },
});
/**
* One page of a thread, walking backwards from newest.
*
* Returns **newest-first** and takes one row more than asked, so the caller
* can tell "there is another page" from "this page happened to be full"
* without a second COUNT. The caller trims the probe row and flips the page
* to oldest-first for rendering.
*
* Rides IDX_SUPPORT_MSG_CONV_CREATED (conversation_id, created_at); the id in
* the keyset is a tiebreak only and doesn't need its own index.
*/
async listByConversation(
conversationId: string,
limit: number,
beforeId?: string,
): Promise<SupportMessage[]> {
const qb = this.repository
.createQueryBuilder("m")
.where("m.conversation_id = :conversationId", { conversationId })
// createQueryBuilder bypasses TypeORM's soft-delete filter, unlike find().
.andWhere("m.deleted_at IS NULL");
if (beforeId) {
// Row-value comparison: strictly older than the cursor row in
// (created_at, id) order.
//
// The cursor's timestamp is read back from the row itself rather than
// passed in. `created_at` is timestamptz(6) but a JS Date only holds
// milliseconds, so a timestamp that made the round-trip through the API
// would arrive truncated — and `.254100 < .254000` is false, so every row
// sharing the cursor's millisecond but earlier within it would be skipped
// on every page, permanently. Postgres compares the stored values at full
// precision instead.
qb.andWhere(
`(m.created_at, m.id) < (
SELECT c.created_at, c.id
FROM freight.support_messages c
WHERE c.id = :cursorId
)`,
{ cursorId: beforeId },
);
}
return qb
.orderBy("m.created_at", "DESC")
.addOrderBy("m.id", "DESC")
.take(limit + 1)
.getMany();
}
/**

View File

@@ -0,0 +1,78 @@
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
import { FileText, X } from "lucide-react";
import { formatBytes } from "./MessageAttachments";
import type { PendingAttachment } from "./useAttachmentDraft";
/**
* The staged-files strip above the composer. Shows what will be sent and lets
* the agent drop any of it before hitting send.
*/
export function AttachmentDraftBar({
attachments,
onRemove,
}: {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
}) {
if (attachments.length === 0) return null;
return (
<Group gap="xs" mb="xs" wrap="wrap">
{attachments.map((a) => (
<Paper
key={a.id}
withBorder
radius="md"
p={4}
style={{ position: "relative" }}
>
<Group gap={6} wrap="nowrap" pr={16}>
{a.previewUrl ? (
<Image
src={a.previewUrl}
alt={a.file.name}
w={36}
h={36}
radius="sm"
fit="cover"
/>
) : (
<Box
w={36}
h={36}
style={{
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<FileText size={16} />
</Box>
)}
<Box style={{ minWidth: 0, maxWidth: 120 }}>
<Text size="xs" fw={600} truncate>
{a.file.name}
</Text>
<Text size="10px" c="dimmed">
{formatBytes(a.file.size)}
</Text>
</Box>
</Group>
<ActionIcon
size="xs"
radius="xl"
color="gray"
variant="filled"
aria-label={`Remove ${a.file.name}`}
onClick={() => onRemove(a.id)}
style={{ position: "absolute", top: -6, right: -6 }}
>
<X size={10} />
</ActionIcon>
</Paper>
))}
</Group>
);
}

View File

@@ -0,0 +1,122 @@
import {
isSupportAttachmentImage,
type SupportAttachmentDto,
} from "@edr/types";
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
import { FileText, ImageOff } from "lucide-react";
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Cap the bubble: a tall screenshot would push the conversation off-screen. */
const THUMB = { maxHeight: 220, maxWidth: 260 } as const;
/**
* One image attachment. Its own component because the bytes are fetched through
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
* be called from inside a `.map()`.
*/
function ImageAttachment({
a,
onView,
}: {
a: SupportAttachmentDto;
onView: (a: SupportAttachmentDto, src: string) => void;
}) {
const { src, failed } = useAttachmentObjectUrl(a.url);
if (failed) {
return (
<Group gap={6} c="dimmed">
<ImageOff size={14} />
<Text size="xs">Couldn't load {a.name}</Text>
</Group>
);
}
if (!src) {
return (
<Box
style={{
width: THUMB.maxWidth,
height: 140,
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 8,
}}
>
<Loader size="xs" color="edr-green" />
</Box>
);
}
return (
<Box
onClick={() => onView(a, src)}
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
>
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
</Box>
);
}
/**
* Attachments inside a message bubble: images as thumbnails, everything else as
* a labelled file row. Non-images are not fetched until opened — pulling every
* document in a thread just to draw a filename would be wasteful.
*/
export function MessageAttachments({
attachments,
mine,
onView,
onOpenFile,
}: {
attachments: SupportAttachmentDto[];
mine: boolean;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
if (attachments.length === 0) return null;
return (
<Stack gap={6} mt={6}>
{attachments.map((a) =>
isSupportAttachmentImage(a.mimeType) ? (
<ImageAttachment key={a.id} a={a} onView={onView} />
) : (
<Paper
key={a.id}
onClick={() => onOpenFile(a)}
px="sm"
py={6}
radius="md"
style={{
cursor: "pointer",
background: mine ? "rgba(255,255,255,0.16)" : "white",
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
}}
>
<Group gap={8} wrap="nowrap">
<FileText size={16} style={{ flexShrink: 0 }} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate>
{a.name}
</Text>
<Text size="10px" opacity={0.75}>
{formatBytes(a.size)}
</Text>
</Box>
</Group>
</Paper>
),
)}
</Stack>
);
}

View File

@@ -1,8 +1,8 @@
import type {
SendSupportMessageDto,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import { api } from "@/auth/http";
@@ -14,6 +14,33 @@ export interface ListConversationsParams {
limit?: number;
}
export interface ListMessagesParams {
/** Opaque cursor from the previous page's `nextCursor`. */
before?: string;
limit?: number;
}
/** What the composer hands over: text, files, or both (never neither). */
export interface SendMessageInput {
body?: string;
attachments?: File[];
}
/**
* A message with files goes as multipart so the server can persist them against
* the message it creates in the same request; text-only stays JSON. Letting
* axios set the multipart boundary itself is deliberate — setting
* `Content-Type` by hand omits the boundary and the request fails to parse.
*/
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
if (!input.attachments?.length) return { body: input.body };
const form = new FormData();
if (input.body) form.append("body", input.body);
for (const file of input.attachments) form.append("attachments", file);
return form;
}
/**
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
* response interceptor already unwraps the `{ success, data }` envelope, so
@@ -29,19 +56,39 @@ export const supportApi = {
);
return data;
},
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
const { data } = await api.get<SupportMessageDto[]>(
listMessages: async (
id: string,
params: ListMessagesParams = {},
): Promise<SupportMessageListResult> => {
const { data } = await api.get<SupportMessageListResult>(
`/support/agent/conversations/${id}/messages`,
{ params },
);
return data;
},
/**
* Attachment bytes, fetched through the authenticated client.
*
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
* token from the Authorization header only — there is no cookie fallback — and
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
* Same reason `filesService.download` exists for booking documents. The caller
* wraps this blob in an object URL.
*/
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
// The DTO path is absolute from the API root (`/api/...`), but this client's
// baseURL already ends in `/api` — drop the duplicate prefix.
const path = relativeUrl.replace(/^\/api/, "");
const { data } = await api.get(path, { responseType: "blob" });
return data as unknown as Blob;
},
sendMessage: async (
id: string,
body: SendSupportMessageDto,
input: SendMessageInput,
): Promise<SupportMessageDto> => {
const { data } = await api.post<SupportMessageDto>(
`/support/agent/conversations/${id}/messages`,
body,
toRequestBody(input),
);
return data;
},

View File

@@ -0,0 +1,130 @@
import {
isSupportAttachmentImage,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
isSupportAttachmentAllowed,
} from "@edr/types";
import { useCallback, useEffect, useRef, useState } from "react";
/** A file staged in the composer, not yet sent. */
export interface PendingAttachment {
/** Local-only id; the server id doesn't exist until the message is sent. */
id: string;
file: File;
/** Object URL, images only. Revoked when the entry goes away. */
previewUrl?: string;
}
let nextId = 0;
/**
* Staging area for files being attached to a message.
*
* Files are held client-side until send, then posted alongside the text in one
* multipart request — there's no upload-then-reference step, so nothing to
* garbage-collect if the agent changes their mind.
*
* Object URLs for image previews are revoked on removal and unmount; without
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
* every image for the life of the tab.
*/
export function useAttachmentDraft(onReject?: (reason: string) => void) {
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const rejectRef = useRef(onReject);
rejectRef.current = onReject;
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
// still-live URLs) on every change to the list.
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
useEffect(
() => () => {
for (const a of attachmentsRef.current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
},
[],
);
const add = useCallback((files: File[]) => {
if (files.length === 0) return;
setAttachments((current) => {
const accepted: PendingAttachment[] = [];
for (const file of files) {
if (
current.length + accepted.length >=
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
) {
rejectRef.current?.(
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
break;
}
if (!isSupportAttachmentAllowed(file.type)) {
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
continue;
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
rejectRef.current?.(
`${file.name} is over the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB limit.`,
);
continue;
}
accepted.push({
id: `pending-${nextId++}`,
file,
previewUrl: isSupportAttachmentImage(file.type)
? URL.createObjectURL(file)
: undefined,
});
}
return accepted.length ? [...current, ...accepted] : current;
});
}, []);
const remove = useCallback((id: string) => {
setAttachments((current) => {
const target = current.find((a) => a.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return current.filter((a) => a.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachments((current) => {
for (const a of current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
return [];
});
}, []);
/**
* Pull files off a paste. Returns true if anything was taken, so the caller
* can suppress the default paste — otherwise pasting a screenshot also drops
* its filename (or nothing) into the textarea.
*
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
* clipboard, so check for files first and only then let the text through.
*/
const addFromPaste = useCallback(
(clipboard: DataTransfer | null): boolean => {
const files = Array.from(clipboard?.files ?? []);
if (files.length === 0) return false;
add(files);
return true;
},
[add],
);
return {
attachments,
files: attachments.map((a) => a.file),
add,
addFromPaste,
remove,
clear,
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { supportApi } from "./supportApi";
/**
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
*
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
* guard takes the bearer token from the `Authorization` header and has no cookie
* fallback, and an `<img>` request cannot carry that header — a direct src is an
* unavoidable 401. So the bytes are fetched through the authenticated client and
* handed to the browser as an object URL, the same way booking documents are
* downloaded.
*
* The URL is revoked on unmount and whenever the attachment changes, so a thread
* scrolled through hundreds of images doesn't pin all of them in memory.
*/
export function useAttachmentObjectUrl(relativeUrl: string): {
src?: string;
failed: boolean;
} {
const [src, setSrc] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let created: string | undefined;
setSrc(undefined);
setFailed(false);
supportApi
.fetchAttachment(relativeUrl)
.then((blob) => {
// The component may have unmounted mid-flight; creating a URL then would
// leak it, since the cleanup below has already run.
if (cancelled) return;
created = URL.createObjectURL(blob);
setSrc(created);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [relativeUrl]);
return { src, failed };
}
/**
* On-demand variant for files that aren't previewed inline (documents): fetch
* only when the user actually opens one, rather than pulling every attachment in
* the thread down just to render a filename row.
*
* Holds a single slot — opening another file revokes the previous URL, as does
* unmounting.
*/
export function useLazyAttachmentObjectUrl(): (
relativeUrl: string,
) => Promise<string> {
const current = useRef<string>();
useEffect(
() => () => {
if (current.current) URL.revokeObjectURL(current.current);
},
[],
);
return useCallback(async (relativeUrl: string) => {
const blob = await supportApi.fetchAttachment(relativeUrl);
if (current.current) URL.revokeObjectURL(current.current);
current.current = URL.createObjectURL(blob);
return current.current;
}, []);
}

View File

@@ -1,6 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
SupportConversationDto,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import { useMemo } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from "@tanstack/react-query";
import { supportApi, type ListConversationsParams } from "./supportApi";
import {
supportApi,
type ListConversationsParams,
type SendMessageInput,
} from "./supportApi";
export const SUPPORT_KEY = ["support"] as const;
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
@@ -8,20 +25,145 @@ export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
export const supportMessagesKey = (id: string) =>
["support", "messages", id] as const;
/** Shared inbox: every thread, filterable by unread + company-name search. */
/** Threads per page in the inbox. */
const CONVERSATIONS_PAGE_SIZE = 20;
/** Messages per page in a thread. */
const MESSAGES_PAGE_SIZE = 30;
/**
* Shared inbox: every thread, filterable by unread + company-name search.
*
* Pages on scroll. This previously asked for `limit: 100` and rendered whatever
* came back — which silently truncated the inbox at the server's own max of 100
* with no way to reach the rest.
*/
export function useConversations(params: ListConversationsParams = {}) {
return useQuery({
const query = useInfiniteQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
queryFn: ({ pageParam }) =>
supportApi.listConversations({
...params,
page: pageParam,
limit: CONVERSATIONS_PAGE_SIZE,
}),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, page) => n + page.items.length, 0);
return loaded < lastPage.count ? allPages.length + 1 : undefined;
},
});
/**
* Flatten for rendering, keeping the page-level fields (count/unreadCount)
* from the newest fetch so badges don't go stale as more pages load.
*
* De-duplicated by id because this list pages by OFFSET over a sort key that
* moves: a thread jumps to rank 1 the moment it gets a message, shifting
* everything down, so a row already shown on page 1 can be served again on
* page 2 — and React would then see two children with the same key. The
* conversations invalidate that rides along with every such event heals the
* ordering a beat later; this just stops the intervening render from breaking.
*
* Keyset wouldn't help here, unlike the message list: the sort key itself
* mutates, so no cursor over it is stable either.
*/
const items = useMemo(() => {
const seen = new Set<string>();
const flat: SupportConversationDto[] = [];
for (const page of query.data?.pages ?? []) {
for (const conversation of page.items) {
if (seen.has(conversation.id)) continue;
seen.add(conversation.id);
flat.push(conversation);
}
}
return flat;
}, [query.data]);
return {
...query,
items,
count: query.data?.pages[0]?.count ?? 0,
unreadCount: query.data?.pages[0]?.unreadCount ?? 0,
};
}
/**
* A thread's messages, paged backwards from newest.
*
* react-query's "next page" is *older* history here, so `pages` runs
* newest-block-first and has to be reversed to render top-to-bottom in time
* order. Cursor-based rather than offset so a message arriving mid-scroll
* doesn't shift the pages already loaded.
*/
export function useMessages(conversationId: string | null) {
return useQuery({
const query = useInfiniteQuery({
queryKey: supportMessagesKey(conversationId ?? ""),
queryFn: () => supportApi.listMessages(conversationId as string),
queryFn: ({ pageParam }) =>
supportApi.listMessages(conversationId as string, {
before: pageParam,
limit: MESSAGES_PAGE_SIZE,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
enabled: !!conversationId,
});
const messages = useMemo(
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
[query.data],
);
// Exposed so the view can tell a prepended history page from a message
// appended at the bottom — `messages` changing says nothing about which.
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
}
/**
* Splice a newly-arrived message into a cached thread.
*
* Deliberately not `invalidateQueries`: that refetches *every* page the agent
* has scrolled back through, so the cost of each inbound message would grow with
* how far they've read. Page 0 is the newest block and its items are oldest-first
* within the block, so the new message belongs on its end.
*
* No-ops when the thread isn't cached — nothing is rendering it, and seeding a
* partial cache here would leave a thread whose "first page" is one message and
* whose `nextCursor` is missing.
*/
/**
* Why this reports an outcome rather than a boolean: the two ways it can decline
* to append need opposite handling. A duplicate is the sender's own echo and must
* be ignored — refetching there would undo the whole point. "Uncached" means the
* thread's first page is still in flight and may have been read on the server
* *before* this message existed, so dropping it silently would lose it until
* something else happened to refetch; the caller refetches instead. That's cheap
* precisely because nothing is loaded yet.
*/
export type AppendOutcome = "appended" | "duplicate" | "uncached";
export function appendMessageToCache(
qc: QueryClient,
message: SupportMessageDto,
): AppendOutcome {
let outcome: AppendOutcome = "uncached";
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
supportMessagesKey(message.conversationId),
(current) => {
if (!current?.pages.length) return current;
const [newest, ...rest] = current.pages;
if (newest.items.some((m) => m.id === message.id)) {
outcome = "duplicate";
return current;
}
outcome = "appended";
return {
...current,
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
};
},
);
return outcome;
}
export function useSupportUnreadCount(enabled = true) {
@@ -36,10 +178,13 @@ export function useSupportUnreadCount(enabled = true) {
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: string) =>
supportApi.sendMessage(conversationId, { body }),
mutationFn: (input: SendMessageInput) =>
supportApi.sendMessage(conversationId, input),
// The gateway echoes our own message back over the socket, which appends it
// to the cache — so don't invalidate the thread here or every send would
// refetch every page the agent has scrolled through. The conversation list
// still needs a refresh for its last-message preview and ordering.
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});

View File

@@ -12,6 +12,7 @@ import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
appendMessageToCache,
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
@@ -45,14 +46,25 @@ export function useSupportSocket(
withCredentials: true,
});
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
});
socket.on(
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
(event: SupportMessageEvent) => {
// Append rather than invalidate: the thread is paginated, and invalidating
// it would refetch every page the agent has scrolled back through on every
// single inbound message.
if (appendMessageToCache(qc, event.message) === "uncached") {
// The thread's first page is still loading and may have been read before
// this message existed — without this it would go missing until some
// unrelated refetch. Cheap: there are no pages to re-fetch yet.
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
}
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,

View File

@@ -1,8 +1,11 @@
import {
SUPPORT_ATTACHMENT_ACCEPT,
SupportAuthorRole,
type SupportAttachmentDto,
type SupportConversationDto,
type SupportMessageDto,
} from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
import {
ActionIcon,
Avatar,
@@ -23,10 +26,22 @@ import {
ThemeIcon,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Building2,
Headset,
Paperclip,
Plus,
Search,
Send,
User,
} from "lucide-react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useLazyAttachmentObjectUrl } from "@/features/support/useAttachmentObjectUrl";
import { AttachmentDraftBar } from "@/features/support/AttachmentDraftBar";
import { MessageAttachments } from "@/features/support/MessageAttachments";
import { useAttachmentDraft } from "@/features/support/useAttachmentDraft";
import {
useConversations,
useMarkConversationRead,
@@ -39,6 +54,9 @@ import { customersService } from "@/services/customers.service";
type ReadFilter = "ALL" | "UNREAD";
/** Distance from an edge (px) that counts as "at" it. */
const SCROLL_EDGE_SLOP = 120;
function formatTime(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
@@ -54,12 +72,24 @@ export default function SupportInboxPage() {
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const inboxViewport = useRef<HTMLDivElement>(null);
/**
* The thread just opened from the company picker.
*
* Selection resolves against the *loaded* pages, and a company picked from the
* modal may well have a thread that sits far enough down the list to not be
* loaded yet — in which case the lookup below would find nothing and the pane
* would sit blank. Hold onto the conversation the server handed back so the
* pane can open immediately, regardless of where it falls in the inbox.
*/
const [startedConversation, setStartedConversation] =
useState<SupportConversationDto | null>(null);
const { data, isLoading } = useConversations({
search,
unreadOnly: readFilter === "UNREAD",
});
const items = data?.items ?? [];
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
useConversations({
search,
unreadOnly: readFilter === "UNREAD",
});
useSupportSocket(true, (event) => {
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
@@ -70,10 +100,13 @@ export default function SupportInboxPage() {
}
});
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
// Prefer the live row from the list (its unread count and last message stay
// current); fall back to the picker's copy while its page is still unloaded.
const selected = useMemo(() => {
const fromList = items.find((c) => c.id === selectedId);
if (fromList) return fromList;
return startedConversation?.id === selectedId ? startedConversation : null;
}, [items, selectedId, startedConversation]);
return (
<Box p="md">
@@ -109,7 +142,10 @@ export default function SupportInboxPage() {
borderRight: "1px solid var(--mantine-color-gray-2)",
}}
>
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<Box
p="sm"
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Button
fullWidth
color="edr-green"
@@ -140,24 +176,47 @@ export default function SupportInboxPage() {
]}
/>
</Box>
<ScrollArea style={{ flex: 1 }} type="hover">
<ScrollArea
style={{ flex: 1 }}
type="hover"
// Pull the next page in as the agent nears the end of the list.
// Previously the hook asked for 100 rows and stopped there, so any
// company past the hundredth was simply unreachable.
onScrollPositionChange={({ y }) => {
const el = inboxViewport.current;
if (!el || !hasNextPage || isFetchingNextPage) return;
if (el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP) {
fetchNextPage();
}
}}
viewportRef={inboxViewport}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" p="xl">
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
{readFilter === "UNREAD"
? "Nothing unread."
: "No conversations."}
</Text>
) : (
items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))
<>
{items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))}
{isFetchingNextPage && (
<Group justify="center" p="sm">
<Loader size="xs" color="edr-green" />
</Group>
)}
</>
)}
</ScrollArea>
</Stack>
@@ -168,7 +227,12 @@ export default function SupportInboxPage() {
<ConversationThread conversation={selected} />
) : (
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<ThemeIcon
variant="light"
color="edr-green"
radius="xl"
size={56}
>
<Headset size={28} />
</ThemeIcon>
<Text size="sm">Select a conversation, or start a new chat.</Text>
@@ -180,8 +244,9 @@ export default function SupportInboxPage() {
<CompanyPicker
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
onStarted={(id) => {
setSelectedId(id);
onStarted={(conversation) => {
setStartedConversation(conversation);
setSelectedId(conversation.id);
setPickerOpen(false);
}}
/>
@@ -200,7 +265,7 @@ function CompanyPicker({
}: {
opened: boolean;
onClose: () => void;
onStarted: (conversationId: string) => void;
onStarted: (conversation: SupportConversationDto) => void;
}) {
const [companyId, setCompanyId] = useState<string | null>(null);
const start = useStartConversation();
@@ -224,15 +289,23 @@ function CompanyPicker({
if (!companyId) return;
const conversation = await start.mutateAsync(companyId);
setCompanyId(null);
onStarted(conversation.id);
onStarted(conversation);
};
return (
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
<Modal
opened={opened}
onClose={onClose}
title="Start a chat"
radius="md"
centered
>
<Stack gap="md">
<Select
label="Customer"
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
placeholder={
isLoading ? "Loading companies…" : "Search for a company"
}
data={options}
value={companyId}
onChange={setCompanyId}
@@ -319,26 +392,133 @@ function ConversationThread({
}: {
conversation: SupportConversationDto;
}) {
const { data: messages, isLoading } = useMessages(conversation.id);
const {
messages,
pageCount,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const markRead = useMarkConversationRead();
const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { view, viewer } = useFileViewer();
const attach = useAttachmentDraft((reason) => toast.error(reason));
/**
* Scroll height captured just before an older page was requested, tagged with
* the page count at that moment.
*
* The page count is what makes this safe. Keyed on presence alone, a message
* arriving over the socket while history was still in flight would consume the
* snapshot on a one-bubble append, and the real 30-message prepend would then
* land with nothing to correct against — throwing the reader exactly as far as
* this exists to prevent. Comparing counts means only an actual new page can
* claim it.
*/
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
null,
);
/** Whether the agent is parked at the bottom and wants to follow new messages. */
const stick = useRef(true);
/** Which thread the refs above describe; a switch resets them. */
const anchoredThread = useRef(conversation.id);
const messageCount = messages.length;
useEffect(() => {
markRead.mutate(conversation.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, messages?.length]);
}, [conversation.id, messageCount]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length, conversation.id]);
/**
* Keep the viewport sensible as the list changes underneath it.
*
* Two different things change `messages`, and they want opposite behaviour: a
* new message at the bottom should follow (if the agent is already there),
* while an older page prepended at the top must NOT move what they're reading.
* Layout effect, not effect — this must run before paint or the prepend
* visibly jumps.
*/
useLayoutEffect(() => {
const el = viewport.current;
if (!el) return;
// Thread switch: start a fresh read at the bottom and drop the previous
// thread's anchoring state.
if (anchoredThread.current !== conversation.id) {
anchoredThread.current = conversation.id;
pendingRestore.current = null;
stick.current = true;
el.scrollTo({ top: el.scrollHeight });
return;
}
const restore = pendingRestore.current;
if (restore && pageCount > restore.atPageCount) {
// An older page went in above: push the scroll down by exactly the height
// that was added, so the same message stays under the cursor.
el.scrollTop += el.scrollHeight - restore.height;
pendingRestore.current = null;
return;
}
if (stick.current) el.scrollTo({ top: el.scrollHeight });
}, [messages, pageCount, conversation.id]);
const onScroll = ({ y }: { y: number }) => {
const el = viewport.current;
if (!el) return;
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
// A failed fetch leaves this set, which is harmless: the list didn't
// change, so the height is still accurate for the retry, and the count
// tag stops it being mistaken for a landed page in the meantime.
pendingRestore.current = {
height: el.scrollHeight,
atPageCount: pageCount,
};
fetchNextPage();
}
};
const submit = async () => {
const body = draft.trim();
if (!body) return;
if (!body && attach.attachments.length === 0) return;
const files = attach.files;
// Clear optimistically so the composer feels instant; on failure the text is
// restored below rather than silently lost.
setDraft("");
await send.mutateAsync(body);
attach.clear();
stick.current = true;
try {
await send.mutateAsync({ body: body || undefined, attachments: files });
} catch (error) {
setDraft(body);
toast.error(
error instanceof Error ? error.message : "Couldn't send that message.",
);
}
};
const loadAttachment = useLazyAttachmentObjectUrl();
// Images already hold their bytes as an object URL from rendering the
// thumbnail, so reuse it rather than fetching the same file twice.
const openAttachment = (a: SupportAttachmentDto, src: string) =>
view({ name: a.name, url: src, mimeType: a.mimeType });
// Documents aren't fetched until opened.
const openFile = async (a: SupportAttachmentDto) => {
try {
const src = await loadAttachment(a.url);
view({ name: a.name, url: src, mimeType: a.mimeType });
} catch {
toast.error(`Couldn't open ${a.name}.`);
}
};
return (
@@ -361,36 +541,108 @@ function ConversationThread({
</Group>
{/* Messages */}
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
<ScrollArea
style={{ flex: 1 }}
viewportRef={viewport}
type="hover"
onScrollPositionChange={onScroll}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : (messages ?? []).length === 0 ? (
) : messages.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" p="xl">
No messages yet say hello.
</Text>
) : (
<Stack gap="sm" p="md">
{(messages ?? []).map((m) => (
<AgentBubble key={m.id} m={m} />
{isFetchingNextPage && (
<Group justify="center" py="xs">
<Loader size="xs" color="edr-green" />
</Group>
)}
{!hasNextPage && (
<Text size="10px" c="dimmed" ta="center">
Start of conversation
</Text>
)}
{messages.map((m) => (
<AgentBubble
key={m.id}
m={m}
onView={openAttachment}
onOpenFile={openFile}
/>
))}
</Stack>
)}
</ScrollArea>
{/* Composer */}
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Box
p="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
outline: dragging
? "2px dashed var(--mantine-color-edr-green-6)"
: undefined,
outlineOffset: -4,
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
attach.add(Array.from(e.dataTransfer.files));
}}
>
<AttachmentDraftBar
attachments={attach.attachments}
onRemove={attach.remove}
/>
<Group gap="xs" align="flex-end" wrap="nowrap">
<input
ref={fileInput}
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
hidden
onChange={(e) => {
attach.add(Array.from(e.currentTarget.files ?? []));
// Reset so picking the same file twice in a row still fires change.
e.currentTarget.value = "";
}}
/>
<ActionIcon
size={38}
radius="md"
variant="subtle"
color="gray"
aria-label="Attach files"
onClick={() => fileInput.current?.click()}
>
<Paperclip size={18} />
</ActionIcon>
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder="Type your message… (Enter to send, Shift+Enter for newline)"
placeholder="Type a message, or paste an image… (Enter to send, Shift+Enter for newline)"
autosize
minRows={1}
maxRows={5}
radius="md"
style={{ flex: 1 }}
// Screenshots land on the clipboard as files. Take them and suppress
// the default, which would otherwise also paste the image's name (or
// nothing) as text.
onPaste={(e) => {
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -404,18 +656,27 @@ function ConversationThread({
color="edr-green"
variant="filled"
loading={send.isPending}
disabled={!draft.trim()}
disabled={!draft.trim() && attach.attachments.length === 0}
onClick={submit}
>
<Send size={18} />
</ActionIcon>
</Group>
</Box>
{viewer}
</Stack>
);
}
function AgentBubble({ m }: { m: SupportMessageDto }) {
function AgentBubble({
m,
onView,
onOpenFile,
}: {
m: SupportMessageDto;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
const mine = m.authorRole === SupportAuthorRole.AGENT;
return (
<Group
@@ -430,7 +691,13 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
</Avatar>
)}
<Box style={{ maxWidth: "70%" }}>
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
<Text
size="xs"
c="dimmed"
mb={2}
ml={mine ? 0 : 4}
ta={mine ? "right" : "left"}
>
{mine ? m.authorName || "You" : m.authorName || "Customer"}
</Text>
<Paper
@@ -446,9 +713,20 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
borderBottomLeftRadius: mine ? undefined : 4,
}}
>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{m.body}
</Text>
{m.body && (
<Text
size="sm"
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{m.body}
</Text>
)}
<MessageAttachments
attachments={m.attachments}
mine={mine}
onView={onView}
onOpenFile={onOpenFile}
/>
</Paper>
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
{formatTime(m.createdAt)}

View File

@@ -0,0 +1,80 @@
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
import { FileText, X } from "lucide-react";
import { formatBytes } from "./MessageAttachments";
import type { PendingAttachment } from "./useAttachmentDraft";
/**
* The staged-files strip above the composer. Shows what will be sent and lets
* the customer drop any of it before hitting send.
*/
export function AttachmentDraftBar({
attachments,
onRemove,
}: {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
}) {
if (attachments.length === 0) return null;
return (
<Group gap="xs" mb="xs" wrap="wrap">
{attachments.map((a) => (
<Paper
key={a.id}
withBorder
radius="md"
p={4}
style={{ position: "relative" }}
>
<Group gap={6} wrap="nowrap" pr={16}>
{a.previewUrl ? (
<Image
src={a.previewUrl}
alt={a.file.name}
w={32}
h={32}
radius="sm"
fit="cover"
/>
) : (
<Box
w={32}
h={32}
style={{
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<FileText size={14} />
</Box>
)}
{/* Narrower name cap than the backoffice so two chips still fit
across the widget's 384px column. */}
<Box style={{ minWidth: 0, maxWidth: 92 }}>
<Text size="xs" fw={600} truncate>
{a.file.name}
</Text>
<Text size="10px" c="dimmed">
{formatBytes(a.file.size)}
</Text>
</Box>
</Group>
<ActionIcon
size="xs"
radius="xl"
color="gray"
variant="filled"
aria-label={`Remove ${a.file.name}`}
onClick={() => onRemove(a.id)}
style={{ position: "absolute", top: -6, right: -6 }}
>
<X size={10} />
</ActionIcon>
</Paper>
))}
</Group>
);
}

View File

@@ -0,0 +1,123 @@
import {
isSupportAttachmentImage,
type SupportAttachmentDto,
} from "@edr/types";
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
import { FileText, ImageOff } from "lucide-react";
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Thumbnail box size — the widget is a narrow column, so keep it modest. */
const THUMB = { maxHeight: 180, maxWidth: 200 } as const;
/**
* One image attachment. Its own component because the bytes are fetched through
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
* be called from inside a `.map()`.
*/
function ImageAttachment({
a,
onView,
}: {
a: SupportAttachmentDto;
onView: (a: SupportAttachmentDto, src: string) => void;
}) {
const { src, failed } = useAttachmentObjectUrl(a.url);
if (failed) {
return (
<Group gap={6} c="dimmed">
<ImageOff size={14} />
<Text size="xs">Couldn't load {a.name}</Text>
</Group>
);
}
if (!src) {
return (
<Box
style={{
...THUMB,
width: THUMB.maxWidth,
height: 120,
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 8,
}}
>
<Loader size="xs" color="edr-green" />
</Box>
);
}
return (
<Box
onClick={() => onView(a, src)}
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
>
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
</Box>
);
}
/**
* Attachments inside a message bubble: images as thumbnails, everything else as
* a labelled file row. Non-images are not fetched until opened — pulling every
* document in a thread just to draw a filename would be wasteful.
*/
export function MessageAttachments({
attachments,
mine,
onView,
onOpenFile,
}: {
attachments: SupportAttachmentDto[];
mine: boolean;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
if (attachments.length === 0) return null;
return (
<Stack gap={6} mt={6}>
{attachments.map((a) =>
isSupportAttachmentImage(a.mimeType) ? (
<ImageAttachment key={a.id} a={a} onView={onView} />
) : (
<Paper
key={a.id}
onClick={() => onOpenFile(a)}
px="sm"
py={6}
radius="md"
style={{
cursor: "pointer",
background: mine ? "rgba(255,255,255,0.16)" : "white",
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
}}
>
<Group gap={8} wrap="nowrap">
<FileText size={16} style={{ flexShrink: 0 }} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate>
{a.name}
</Text>
<Text size="10px" opacity={0.75}>
{formatBytes(a.size)}
</Text>
</Box>
</Group>
</Paper>
),
)}
</Stack>
);
}

View File

@@ -1,4 +1,10 @@
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
import {
SUPPORT_ATTACHMENT_ACCEPT,
SupportAuthorRole,
type SupportAttachmentDto,
type SupportMessageDto,
} from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
import {
ActionIcon,
Avatar,
@@ -12,9 +18,14 @@ import {
Textarea,
ThemeIcon,
} from "@mantine/core";
import { Headset, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Headset, Paperclip, Send, X } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { AttachmentDraftBar } from "./AttachmentDraftBar";
import { MessageAttachments } from "./MessageAttachments";
import { useAttachmentDraft } from "./useAttachmentDraft";
import { useLazyAttachmentObjectUrl } from "./useAttachmentObjectUrl";
import {
useConversation,
useMarkConversationRead,
@@ -22,6 +33,9 @@ import {
useSendMessage,
} from "./useSupport";
/** Distance from an edge (px) that counts as "at" it. */
const SCROLL_EDGE_SLOP = 120;
function formatTime(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
@@ -43,32 +57,136 @@ export interface SupportPanelProps {
*/
export function SupportPanel({ onClose }: SupportPanelProps) {
const { data: conversation } = useConversation();
const { data: messages, isLoading } = useMessages();
const {
messages,
pageCount,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useMessages();
const send = useSendMessage();
const markRead = useMarkConversationRead();
const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { view, viewer } = useFileViewer();
const attach = useAttachmentDraft((reason) => toast.error(reason));
/**
* Scroll height captured just before an older page was requested, tagged with
* the page count at that moment.
*
* The page count is what makes this safe. Keyed on presence alone, a message
* arriving over the socket while history was still in flight would consume the
* snapshot on a one-bubble append, and the real 30-message prepend would then
* land with nothing to correct against — throwing the reader exactly as far as
* this exists to prevent. Comparing counts means only an actual new page can
* claim it.
*/
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
null,
);
/** Whether the customer 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);
// Mark read on open + whenever new messages arrive. No-op before the thread
// exists, so opening the panel never creates one.
useEffect(() => {
if (conversation) markRead.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation?.id, messages?.length]);
}, [conversation?.id, messages.length]);
// Auto-scroll to newest.
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 customer 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;
// A different thread underneath us (the first send creates one): start a
// fresh read at the bottom and drop the previous thread's anchoring state.
if (anchoredThread.current !== conversation?.id) {
anchoredThread.current = conversation?.id;
pendingRestore.current = null;
stick.current = true;
el.scrollTo({ top: el.scrollHeight });
return;
}
const restore = pendingRestore.current;
if (restore && pageCount > restore.atPageCount) {
// An older page went in above: push the scroll down by exactly the height
// that was added, so the same message stays under the cursor.
el.scrollTop += el.scrollHeight - restore.height;
pendingRestore.current = null;
return;
}
if (stick.current) el.scrollTo({ top: el.scrollHeight });
}, [messages, pageCount, conversation?.id]);
const onScroll = ({ y }: { y: number }) => {
const el = viewport.current;
if (!el) return;
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
// A failed fetch leaves this set, which is harmless: the list didn't
// change, so the height is still accurate for the retry, and the count
// tag stops it being mistaken for a landed page in the meantime.
pendingRestore.current = {
height: el.scrollHeight,
atPageCount: pageCount,
};
fetchNextPage();
}
};
const submit = async () => {
const body = draft.trim();
if (!body) return;
if (!body && attach.attachments.length === 0) return;
const files = attach.files;
// Clear optimistically so the composer feels instant; on failure the text is
// restored below rather than silently lost.
setDraft("");
await send.mutateAsync(body);
attach.clear();
stick.current = true;
try {
await send.mutateAsync({ body: body || undefined, attachments: files });
} catch (error) {
setDraft(body);
toast.error(
error instanceof Error ? error.message : "Couldn't send that message.",
);
}
};
const isEmpty = !isLoading && (messages ?? []).length === 0;
const loadAttachment = useLazyAttachmentObjectUrl();
// Images already hold their bytes as an object URL from rendering the
// thumbnail, so reuse it rather than fetching the same file twice.
const openAttachment = (a: SupportAttachmentDto, src: string) =>
view({ name: a.name, url: src, mimeType: a.mimeType });
// Documents aren't fetched until opened.
const openFile = async (a: SupportAttachmentDto) => {
try {
const src = await loadAttachment(a.url);
view({ name: a.name, url: src, mimeType: a.mimeType });
} catch {
toast.error(`Couldn't open ${a.name}.`);
}
};
const isEmpty = !isLoading && messages.length === 0;
return (
<Paper
@@ -113,7 +231,12 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
</ActionIcon>
</Group>
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
<ScrollArea
style={{ flex: 1 }}
viewportRef={viewport}
type="hover"
onScrollPositionChange={onScroll}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader size="sm" color="edr-green" />
@@ -129,24 +252,91 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
</Stack>
) : (
<Stack gap="sm" p="md">
{(messages ?? []).map((m) => (
<MessageBubble key={m.id} m={m} />
{isFetchingNextPage && (
<Group justify="center" py="xs">
<Loader size="xs" color="edr-green" />
</Group>
)}
{!hasNextPage && (
<Text size="10px" c="dimmed" ta="center">
Start of conversation
</Text>
)}
{messages.map((m) => (
<MessageBubble
key={m.id}
m={m}
onView={openAttachment}
onOpenFile={openFile}
/>
))}
</Stack>
)}
</ScrollArea>
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Box
p="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
outline: dragging
? "2px dashed var(--mantine-color-edr-green-6)"
: undefined,
outlineOffset: -4,
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
attach.add(Array.from(e.dataTransfer.files));
}}
>
<AttachmentDraftBar
attachments={attach.attachments}
onRemove={attach.remove}
/>
<Group gap="xs" align="flex-end" wrap="nowrap">
<input
ref={fileInput}
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
hidden
onChange={(e) => {
attach.add(Array.from(e.currentTarget.files ?? []));
// Reset so picking the same file twice in a row still fires change.
e.currentTarget.value = "";
}}
/>
<ActionIcon
size={38}
radius="md"
variant="subtle"
color="gray"
aria-label="Attach files"
onClick={() => fileInput.current?.click()}
>
<Paperclip size={18} />
</ActionIcon>
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder="Type a message…"
placeholder="Type a message, or paste an image…"
autosize
minRows={1}
maxRows={4}
radius="md"
style={{ flex: 1 }}
// Screenshots land on the clipboard as files. Take them and suppress
// the default, which would otherwise also paste the image's name (or
// nothing) as text.
onPaste={(e) => {
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -160,18 +350,27 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
color="edr-green"
variant="filled"
loading={send.isPending}
disabled={!draft.trim()}
disabled={!draft.trim() && attach.attachments.length === 0}
onClick={submit}
>
<Send size={18} />
</ActionIcon>
</Group>
</Box>
{viewer}
</Paper>
);
}
function MessageBubble({ m }: { m: SupportMessageDto }) {
function MessageBubble({
m,
onView,
onOpenFile,
}: {
m: SupportMessageDto;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
return (
<Group
@@ -204,9 +403,20 @@ function MessageBubble({ m }: { m: SupportMessageDto }) {
borderBottomLeftRadius: mine ? undefined : 4,
}}
>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{m.body}
</Text>
{m.body && (
<Text
size="sm"
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{m.body}
</Text>
)}
<MessageAttachments
attachments={m.attachments}
mine={mine}
onView={onView}
onOpenFile={onOpenFile}
/>
</Paper>
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
{formatTime(m.createdAt)}

View File

@@ -1,4 +1,4 @@
import { SupportAuthorRole } from "@edr/types";
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
import { Affix, Indicator, Transition } from "@mantine/core";
import { Headset } from "lucide-react";
import { useState } from "react";
@@ -10,6 +10,17 @@ import { SupportPanel } from "./SupportPanel";
import { useSupportUnreadCount } from "./useSupport";
import { useSupportSocket } from "./useSupportSocket";
/**
* Toast blurb for an inbound reply. A message may carry files and no text at
* all, which would otherwise toast a bare "Support replied:".
*/
function previewOf(message: SupportMessageDto): string {
const body = message.body.trim();
if (body) return body.length > 60 ? `${body.slice(0, 60)}` : body;
const count = message.attachments.length;
return count === 1 ? "sent a file" : `sent ${count} files`;
}
/**
* Floating customer-support launcher, mounted in the authenticated app shell.
* Shows an unread badge and opens the chat panel; live pushes keep the badge
@@ -23,12 +34,7 @@ export function SupportWidget() {
useSupportSocket(isAuthenticated, (event) => {
// Only the customer-visible side matters here; agent replies arrive as AGENT.
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
toast(
`Support replied: ${event.message.body.slice(0, 60)}${
event.message.body.length > 60 ? "…" : ""
}`,
{ icon: "💬" },
);
toast(`Support replied: ${previewOf(event.message)}`, { icon: "💬" });
}
});

View File

@@ -1,11 +1,38 @@
import type {
SendSupportMessageResult,
SupportConversationDto,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import { client } from "@/utils/api";
export interface ListMessagesParams {
/** Opaque cursor from the previous page's `nextCursor`. */
before?: string;
limit?: number;
}
/** What the composer hands over: text, files, or both (never neither). */
export interface SendMessageInput {
body?: string;
attachments?: File[];
}
/**
* A message with files goes as multipart so the server can persist them against
* the message it creates in the same request; text-only stays JSON. Letting
* axios set the multipart boundary itself is deliberate — setting
* `Content-Type` by hand omits the boundary and the request fails to parse.
*/
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
if (!input.attachments?.length) return { body: input.body };
const form = new FormData();
if (input.body) form.append("body", input.body);
for (const file of input.attachments) form.append("attachments", file);
return form;
}
/**
* Portal support-chat REST calls. My company has exactly one thread, so nothing
* here takes a conversation id — the server derives it from the caller.
@@ -20,14 +47,37 @@ export const supportApi = {
const { data } = await client.get("/api/support/conversation");
return data.data;
},
listMessages: async (): Promise<SupportMessageDto[]> => {
const { data } = await client.get("/api/support/conversation/messages");
listMessages: async (
params: ListMessagesParams = {},
): Promise<SupportMessageListResult> => {
const { data } = await client.get("/api/support/conversation/messages", {
params,
});
return data.data;
},
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
const { data } = await client.post("/api/support/conversation/messages", {
body,
});
/**
* Attachment bytes, fetched through the authenticated client.
*
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
* token from the Authorization header only — there is no cookie fallback — and
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
* The same reason `filesService.download` exists for booking documents. The
* caller wraps this blob in an object URL.
*
* `relativeUrl` is the DTO's `url` (`/api/support/attachments/:id`), so the
* route stays owned by the server.
*/
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
const { data } = await client.get(relativeUrl, { responseType: "blob" });
return data as Blob;
},
sendMessage: async (
input: SendMessageInput,
): Promise<SendSupportMessageResult> => {
const { data } = await client.post(
"/api/support/conversation/messages",
toRequestBody(input),
);
return data.data;
},
markRead: async (): Promise<{ unreadCount: number }> => {

View File

@@ -0,0 +1,130 @@
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 customer changes their mind.
*
* Object URLs for image previews are revoked on removal and unmount; without
* that, the widget — which lives for the whole session in the app shell — would
* hold the full bytes of every image ever staged for the life of the tab.
*/
export function useAttachmentDraft(onReject?: (reason: string) => void) {
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const rejectRef = useRef(onReject);
rejectRef.current = onReject;
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
// still-live URLs) on every change to the list.
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
useEffect(
() => () => {
for (const a of attachmentsRef.current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
},
[],
);
const add = useCallback((files: File[]) => {
if (files.length === 0) return;
setAttachments((current) => {
const accepted: PendingAttachment[] = [];
for (const file of files) {
if (
current.length + accepted.length >=
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
) {
rejectRef.current?.(
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
break;
}
if (!isSupportAttachmentAllowed(file.type)) {
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
continue;
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
rejectRef.current?.(
`${file.name} is over the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB limit.`,
);
continue;
}
accepted.push({
id: `pending-${nextId++}`,
file,
previewUrl: isSupportAttachmentImage(file.type)
? URL.createObjectURL(file)
: undefined,
});
}
return accepted.length ? [...current, ...accepted] : current;
});
}, []);
const remove = useCallback((id: string) => {
setAttachments((current) => {
const target = current.find((a) => a.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return current.filter((a) => a.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachments((current) => {
for (const a of current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
return [];
});
}, []);
/**
* Pull files off a paste. Returns true if anything was taken, so the caller
* can suppress the default paste — otherwise pasting a screenshot also drops
* its filename (or nothing) into the textarea.
*
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
* clipboard, so check for files first and only then let the text through.
*/
const addFromPaste = useCallback(
(clipboard: DataTransfer | null): boolean => {
const files = Array.from(clipboard?.files ?? []);
if (files.length === 0) return false;
add(files);
return true;
},
[add],
);
return {
attachments,
files: attachments.map((a) => a.file),
add,
addFromPaste,
remove,
clear,
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { supportApi } from "./supportApi";
/**
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
*
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
* guard takes the bearer token from the `Authorization` header and has no cookie
* fallback, and an `<img>` request cannot carry that header — a direct src is an
* unavoidable 401. So the bytes are fetched through the authenticated client and
* handed to the browser as an object URL, the same way booking documents are
* downloaded.
*
* The URL is revoked on unmount and whenever the attachment changes, so a thread
* scrolled through hundreds of images doesn't pin all of them in memory.
*/
export function useAttachmentObjectUrl(relativeUrl: string): {
src?: string;
failed: boolean;
} {
const [src, setSrc] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let created: string | undefined;
setSrc(undefined);
setFailed(false);
supportApi
.fetchAttachment(relativeUrl)
.then((blob) => {
// The component may have unmounted mid-flight; creating a URL then would
// leak it, since the cleanup below has already run.
if (cancelled) return;
created = URL.createObjectURL(blob);
setSrc(created);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [relativeUrl]);
return { src, failed };
}
/**
* On-demand variant for files that aren't previewed inline (documents): fetch
* only when the user actually opens one, rather than pulling every attachment in
* the thread down just to render a filename row.
*
* Holds a single slot — opening another file revokes the previous URL, as does
* unmounting.
*/
export function useLazyAttachmentObjectUrl(): (
relativeUrl: string,
) => Promise<string> {
const current = useRef<string>();
useEffect(
() => () => {
if (current.current) URL.revokeObjectURL(current.current);
},
[],
);
return useCallback(async (relativeUrl: string) => {
const blob = await supportApi.fetchAttachment(relativeUrl);
if (current.current) URL.revokeObjectURL(current.current);
current.current = URL.createObjectURL(blob);
return current.current;
}, []);
}

View File

@@ -1,13 +1,28 @@
import type { SupportConversationDto } from "@edr/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
SupportConversationDto,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { supportApi } from "./supportApi";
import { supportApi, type SendMessageInput } from "./supportApi";
export const SUPPORT_KEY = ["support"] as const;
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
/** Messages per page in the thread. */
const MESSAGES_PAGE_SIZE = 30;
/** My company's support thread — null until the first message is sent. */
export function useConversation(enabled = true) {
return useQuery({
@@ -17,13 +32,83 @@ export function useConversation(enabled = true) {
});
}
/** The thread's messages, oldest first. Empty until the thread exists. */
/**
* The 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(enabled = true) {
return useQuery({
const query = useInfiniteQuery({
queryKey: SUPPORT_MESSAGES_KEY,
queryFn: () => supportApi.listMessages(),
queryFn: ({ pageParam }) =>
supportApi.listMessages({ before: pageParam, limit: MESSAGES_PAGE_SIZE }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
enabled,
});
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 the cached thread.
*
* Deliberately not `invalidateQueries`: the thread is paginated, so that would
* refetch *every* page the customer has scrolled back through — making the cost
* of each inbound message 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.
*
* The socket only ever pushes this company's own thread, so there's no id to
* match on. Nothing is written when nothing is cached — the panel isn't
* rendering it, and a seeded one-message page would carry no `nextCursor` and
* strand the history.
*/
/**
* Why this reports an outcome rather than a boolean: the two ways it can decline
* to append need opposite handling. A duplicate is the sender's own echo and must
* be ignored — refetching there would undo the whole point. "Uncached" means the
* thread's first page is still in flight and may have been read on the server
* *before* this message existed, so dropping it silently would lose it until
* something else happened to refetch; the caller refetches instead. That's cheap
* precisely because nothing is loaded yet.
*/
export type AppendOutcome = "appended" | "duplicate" | "uncached";
export function appendMessageToCache(
qc: QueryClient,
message: SupportMessageDto,
): AppendOutcome {
let outcome: AppendOutcome = "uncached";
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
SUPPORT_MESSAGES_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.items.some((m) => m.id === message.id)) {
outcome = "duplicate";
return current;
}
outcome = "appended";
return {
...current,
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
};
},
);
return outcome;
}
export function useSupportUnreadCount(enabled = true) {
@@ -40,9 +125,12 @@ export function useSupportUnreadCount(enabled = true) {
export function useSendMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: string) => supportApi.sendMessage(body),
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 customer has scrolled through. The conversation
// itself still needs a refresh: the first send is what creates it.
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
},
});

View File

@@ -11,6 +11,7 @@ import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
appendMessageToCache,
SUPPORT_CONVERSATION_KEY,
SUPPORT_MESSAGES_KEY,
SUPPORT_UNREAD_KEY,
@@ -29,9 +30,9 @@ const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live support-chat pushes for the signed-in customer. The company
* only has one thread, so any push refreshes it wholesale — messages, the
* conversation itself, and the unread badge — and fires `onMessage` (the widget
* shows a toast when the panel is closed).
* only has one thread, so every push belongs to it: the message goes straight
* into the thread cache, the conversation and unread badge are refreshed, and
* `onMessage` fires (the widget shows a toast when the panel is closed).
*/
export function useSupportSocket(
enabled: boolean,
@@ -52,12 +53,23 @@ export function useSupportSocket(
withCredentials: true,
});
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
});
socket.on(
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
(event: SupportMessageEvent) => {
// Append rather than invalidate: the thread is paginated, and invalidating
// it would refetch every page the customer 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: SUPPORT_MESSAGES_KEY });
}
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,