mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
feat: setup attachment to the freight chat
This commit is contained in:
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
|
||||||
import { FilesService } from "./files.service";
|
import { FilesService } from "./files.service";
|
||||||
@@ -23,13 +30,16 @@ export class FilesController {
|
|||||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
// 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({
|
@ApiOperation({
|
||||||
summary: "Stream a file by ID",
|
summary: "Stream a file by ID",
|
||||||
description:
|
description:
|
||||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
"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 " +
|
"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({
|
@ApiQuery({
|
||||||
name: "download",
|
name: "download",
|
||||||
@@ -41,7 +51,19 @@ export class FilesController {
|
|||||||
@Query("download") download: string | undefined,
|
@Query("download") download: string | undefined,
|
||||||
@Res() res: Response,
|
@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 forceDownload = download === "1" || download === "true";
|
||||||
const disposition = forceDownload ? "attachment" : "inline";
|
const disposition = forceDownload ? "attachment" : "inline";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
|
|
||||||
import { FileRecord } from "./entities/file.entity";
|
import { FileRecord } from "./entities/file.entity";
|
||||||
|
|
||||||
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
return this.repository.find({ where: { resourceId, resource } });
|
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(
|
findByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
import { MinioService } from "../minio/minio.service";
|
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
|
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||||
// human-readable name is preserved separately on the record below.
|
// human-readable name is preserved separately on the record below.
|
||||||
const safeName = sanitizeObjectName(file.originalname);
|
const safeName = sanitizeObjectName(file.originalname);
|
||||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
|
// The random segment is load-bearing, not decoration. `Date.now()` alone is
|
||||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
// 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({
|
return this.filesRepository.create({
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -175,6 +187,27 @@ export class FilesService {
|
|||||||
return this.filesRepository.findByResource(resourceId, resource);
|
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
|
* 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;
|
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||||
@@ -190,7 +223,11 @@ export class FilesService {
|
|||||||
resource: string,
|
resource: string,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<FileRecord> {
|
): Promise<FileRecord> {
|
||||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
const record = await this.filesRepository.findByCode(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (!record)
|
if (!record)
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||||
@@ -198,7 +235,9 @@ export class FilesService {
|
|||||||
return record;
|
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 record = await this.findById(id);
|
||||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class SendMessageDto implements ISendSupportMessageDto {
|
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()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(4000)
|
@MaxLength(4000)
|
||||||
body!: string;
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
|
|||||||
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||||
authorName?: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SupportAuthorRole,
|
||||||
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -8,14 +11,22 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_FIELD,
|
||||||
|
supportAttachmentMulterOptions,
|
||||||
|
} from "./attachment-upload.options";
|
||||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||||
import { SendMessageDto } from "./dto/send-message.dto";
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversations/:id/messages")
|
@Get("conversations/:id/messages")
|
||||||
@ApiOperation({ summary: "List messages in a thread" })
|
@ApiOperation({
|
||||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
summary: "List messages in a thread (newest page first)",
|
||||||
return this.service.getMessages(id);
|
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")
|
@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(
|
send(
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() body: SendMessageDto,
|
@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")
|
@Post("conversations/:id/read")
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { SupportAuthorRole } from "@edr/types";
|
import {
|
||||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
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 {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} 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 { SendMessageDto } from "./dto/send-message.dto";
|
||||||
import { SupportChatService } from "./support-chat.service";
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
@@ -29,17 +46,55 @@ export class SupportChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("conversation/messages")
|
@Get("conversation/messages")
|
||||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
@ApiOperation({
|
||||||
messages(@CurrentUser() user: AuthUserPayload) {
|
summary: "Messages in my company's support thread (newest page first)",
|
||||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
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")
|
@Post("conversation/messages")
|
||||||
@ApiOperation({
|
@UseInterceptors(
|
||||||
summary: "Send a message as the customer, opening the thread if needed",
|
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) {
|
@ApiOperation({
|
||||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
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")
|
@Post("conversation/read")
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { SupportAttachmentController } from "./support-attachment.controller";
|
||||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||||
import { SupportChatController } from "./support-chat.controller";
|
import { SupportChatController } from "./support-chat.controller";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// WsAuthService — reused handshake authentication for the gateway.
|
// WsAuthService — reused handshake authentication for the gateway.
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
// FilesService — chat attachments are stored as polymorphic file records.
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
SupportChatController,
|
||||||
|
SupportChatAgentController,
|
||||||
|
SupportAttachmentController,
|
||||||
],
|
],
|
||||||
controllers: [SupportChatController, SupportChatAgentController],
|
|
||||||
providers: [
|
providers: [
|
||||||
SupportConversationRepository,
|
SupportConversationRepository,
|
||||||
SupportMessageRepository,
|
SupportMessageRepository,
|
||||||
SupportChatGateway,
|
SupportChatGateway,
|
||||||
SupportChatService,
|
SupportChatService,
|
||||||
],
|
],
|
||||||
|
// FilesController's ownership check for `support_message` files defers to this
|
||||||
|
// service — see SupportAttachmentAccess.
|
||||||
|
exports: [SupportChatService],
|
||||||
})
|
})
|
||||||
export class SupportChatModule {}
|
export class SupportChatModule {}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
|
isSupportAttachmentAllowed,
|
||||||
SendSupportMessageResult,
|
SendSupportMessageResult,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||||
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||||
|
SUPPORT_ATTACHMENT_RESOURCE,
|
||||||
|
SupportAttachmentDto,
|
||||||
SupportAuthorRole,
|
SupportAuthorRole,
|
||||||
SupportConversationDto,
|
SupportConversationDto,
|
||||||
SupportConversationListResult,
|
SupportConversationListResult,
|
||||||
SupportMessageDto,
|
SupportMessageDto,
|
||||||
|
SupportMessageListResult,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { QueryFailedError } from "typeorm";
|
import { QueryFailedError } from "typeorm";
|
||||||
|
|
||||||
|
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
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 { 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 { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
import { SupportMessage } from "./entities/support-message.entity";
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
|
||||||
import { SupportChatGateway } from "./support-chat.gateway";
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||||
import { SupportMessageRepository } from "./support-message.repository";
|
import { SupportMessageRepository } from "./support-message.repository";
|
||||||
@@ -30,6 +45,9 @@ interface CustomerContext {
|
|||||||
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||||
const PG_UNIQUE_VIOLATION = "23505";
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
/** Stand-in preview for a message that is nothing but files. */
|
||||||
|
const ATTACHMENT_ONLY_PREVIEW = "📎";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportChatService {
|
export class SupportChatService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -38,6 +56,8 @@ export class SupportChatService {
|
|||||||
private readonly gateway: SupportChatGateway,
|
private readonly gateway: SupportChatGateway,
|
||||||
private readonly externalProfiles: ExternalProfileRepository,
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly files: FilesService,
|
||||||
|
private readonly backoffice: BackofficeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---- customer (portal) -------------------------------------------------
|
// ---- customer (portal) -------------------------------------------------
|
||||||
@@ -51,7 +71,9 @@ export class SupportChatService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SupportConversationDto | null> {
|
): Promise<SupportConversationDto | null> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
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;
|
if (!conversation) return null;
|
||||||
const unread = await this.messages.unreadCountsByConversation(
|
const unread = await this.messages.unreadCountsByConversation(
|
||||||
[conversation.id],
|
[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 ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
if (!conversation) return [];
|
ctx.companyId,
|
||||||
return this.listMessages(conversation.id);
|
);
|
||||||
|
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. */
|
/** Send as the customer, opening the thread if this is the first message. */
|
||||||
async sendAsCustomer(
|
async sendAsCustomer(
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SendSupportMessageResult> {
|
): Promise<SendSupportMessageResult> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.getOrCreate(
|
const conversation = await this.getOrCreate(
|
||||||
@@ -87,16 +115,19 @@ export class SupportChatService {
|
|||||||
SupportAuthorRole.CUSTOMER,
|
SupportAuthorRole.CUSTOMER,
|
||||||
body,
|
body,
|
||||||
ctx.authorName,
|
ctx.authorName,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
conversation: this.toConversationDto(updated, 0),
|
conversation: this.toConversationDto(updated, 0),
|
||||||
message: this.toMessageDto(message),
|
message: message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
const conversation = await this.conversations.findByCompanyId(
|
||||||
|
ctx.companyId,
|
||||||
|
);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
customerLastReadAt: new Date(),
|
customerLastReadAt: new Date(),
|
||||||
@@ -145,7 +176,8 @@ export class SupportChatService {
|
|||||||
async sendAsAgent(
|
async sendAsAgent(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
|
attachments: Express.Multer.File[] = [],
|
||||||
): Promise<SupportMessageDto> {
|
): Promise<SupportMessageDto> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
const { message } = await this.appendMessage(
|
const { message } = await this.appendMessage(
|
||||||
@@ -153,8 +185,10 @@ export class SupportChatService {
|
|||||||
userId,
|
userId,
|
||||||
SupportAuthorRole.AGENT,
|
SupportAuthorRole.AGENT,
|
||||||
body,
|
body,
|
||||||
|
undefined,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
return this.toMessageDto(message);
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAgentRead(
|
async markAgentRead(
|
||||||
@@ -171,18 +205,56 @@ export class SupportChatService {
|
|||||||
// ---- shared ------------------------------------------------------------
|
// ---- shared ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
|
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
|
||||||
* company owns it (portal route); omit for agents, who see every thread.
|
* to enforce that the caller's company owns it (portal route); omit for
|
||||||
|
* agents, who see every thread.
|
||||||
*/
|
*/
|
||||||
async getMessages(
|
async getMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
query: ListMessagesQueryDto = {},
|
||||||
asCustomerUserId?: string,
|
asCustomerUserId?: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
): Promise<SupportMessageListResult> {
|
||||||
const conversation = await this.requireConversation(conversationId);
|
const conversation = await this.requireConversation(conversationId);
|
||||||
if (asCustomerUserId) {
|
if (asCustomerUserId) {
|
||||||
await this.assertCustomerOwns(conversation, 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(
|
async unreadCount(
|
||||||
@@ -237,29 +309,86 @@ export class SupportChatService {
|
|||||||
|
|
||||||
private async listMessages(
|
private async listMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SupportMessageDto[]> {
|
query: ListMessagesQueryDto,
|
||||||
const rows = await this.messages.listByConversation(conversationId);
|
): Promise<SupportMessageListResult> {
|
||||||
return rows.map((m) => this.toMessageDto(m));
|
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(
|
private async appendMessage(
|
||||||
conversation: SupportConversation,
|
conversation: SupportConversation,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: SupportAuthorRole,
|
role: SupportAuthorRole,
|
||||||
body: string,
|
body: string | undefined,
|
||||||
authorName?: string | null,
|
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({
|
const message = await this.messages.create({
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
authorUserId: userId,
|
authorUserId: userId,
|
||||||
authorRole: role,
|
authorRole: role,
|
||||||
authorName: authorName ?? null,
|
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.lastMessageAt = message.createdAt;
|
||||||
conversation.lastMessagePreview = body.slice(0, 280);
|
conversation.lastMessagePreview = this.buildPreview(text, stored);
|
||||||
conversation.lastMessageAuthorRole = role;
|
conversation.lastMessageAuthorRole = role;
|
||||||
await this.conversations.update(conversation.id, {
|
await this.conversations.update(conversation.id, {
|
||||||
lastMessageAt: conversation.lastMessageAt,
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
@@ -267,13 +396,55 @@ export class SupportChatService {
|
|||||||
lastMessageAuthorRole: role,
|
lastMessageAuthorRole: role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const messageDto = await this.toMessageDto(message, stored);
|
||||||
const dto = this.toConversationDto(conversation, 0);
|
const dto = this.toConversationDto(conversation, 0);
|
||||||
this.gateway.emitMessage(
|
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
|
||||||
conversation.companyId,
|
return { conversation, message: messageDto };
|
||||||
dto,
|
}
|
||||||
this.toMessageDto(message),
|
|
||||||
);
|
/**
|
||||||
return { conversation, message };
|
* 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(
|
private async buildListResult(
|
||||||
@@ -320,7 +491,9 @@ export class SupportChatService {
|
|||||||
): Promise<CustomerContext> {
|
): Promise<CustomerContext> {
|
||||||
const ctx = await this.resolveCustomer(userId);
|
const ctx = await this.resolveCustomer(userId);
|
||||||
if (conversation.companyId !== ctx.companyId) {
|
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;
|
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 {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
conversationId: m.conversationId,
|
conversationId: m.conversationId,
|
||||||
authorUserId: m.authorUserId,
|
authorUserId: m.authorUserId,
|
||||||
authorRole: m.authorRole,
|
authorRole: m.authorRole,
|
||||||
authorName: m.authorName ?? null,
|
authorName: m.authorName ?? null,
|
||||||
body: m.body,
|
body: m.body ?? "",
|
||||||
|
attachments: attachments.map((a) => this.toAttachmentDto(a)),
|
||||||
createdAt: new Date(m.createdAt).toISOString(),
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<SupportMessage> {
|
|||||||
super(repo);
|
super(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All messages of a conversation, oldest first. */
|
/**
|
||||||
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
|
* One page of a thread, walking backwards from newest.
|
||||||
return this.repository.find({
|
*
|
||||||
where: { conversationId },
|
* Returns **newest-first** and takes one row more than asked, so the caller
|
||||||
order: { createdAt: "ASC" },
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
SendSupportMessageDto,
|
|
||||||
SupportConversationDto,
|
SupportConversationDto,
|
||||||
SupportConversationListResult,
|
SupportConversationListResult,
|
||||||
SupportMessageDto,
|
SupportMessageDto,
|
||||||
|
SupportMessageListResult,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
@@ -14,6 +14,33 @@ export interface ListConversationsParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListMessagesParams {
|
||||||
|
/** Opaque cursor from the previous page's `nextCursor`. */
|
||||||
|
before?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the composer hands over: text, files, or both (never neither). */
|
||||||
|
export interface SendMessageInput {
|
||||||
|
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`
|
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
|
||||||
* response interceptor already unwraps the `{ success, data }` envelope, so
|
* response interceptor already unwraps the `{ success, data }` envelope, so
|
||||||
@@ -29,19 +56,39 @@ export const supportApi = {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
|
listMessages: async (
|
||||||
const { data } = await api.get<SupportMessageDto[]>(
|
id: string,
|
||||||
|
params: ListMessagesParams = {},
|
||||||
|
): Promise<SupportMessageListResult> => {
|
||||||
|
const { data } = await api.get<SupportMessageListResult>(
|
||||||
`/support/agent/conversations/${id}/messages`,
|
`/support/agent/conversations/${id}/messages`,
|
||||||
|
{ params },
|
||||||
);
|
);
|
||||||
return data;
|
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 (
|
sendMessage: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: SendSupportMessageDto,
|
input: SendMessageInput,
|
||||||
): Promise<SupportMessageDto> => {
|
): Promise<SupportMessageDto> => {
|
||||||
const { data } = await api.post<SupportMessageDto>(
|
const { data } = await api.post<SupportMessageDto>(
|
||||||
`/support/agent/conversations/${id}/messages`,
|
`/support/agent/conversations/${id}/messages`,
|
||||||
body,
|
toRequestBody(input),
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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_KEY = ["support"] as const;
|
||||||
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] 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) =>
|
export const supportMessagesKey = (id: string) =>
|
||||||
["support", "messages", id] as const;
|
["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 = {}) {
|
export function useConversations(params: ListConversationsParams = {}) {
|
||||||
return useQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
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) {
|
export function useMessages(conversationId: string | null) {
|
||||||
return useQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: supportMessagesKey(conversationId ?? ""),
|
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,
|
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) {
|
export function useSupportUnreadCount(enabled = true) {
|
||||||
@@ -36,10 +178,13 @@ export function useSupportUnreadCount(enabled = true) {
|
|||||||
export function useSendMessage(conversationId: string) {
|
export function useSendMessage(conversationId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: string) =>
|
mutationFn: (input: SendMessageInput) =>
|
||||||
supportApi.sendMessage(conversationId, { body }),
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
|||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendMessageToCache,
|
||||||
SUPPORT_CONVERSATIONS_KEY,
|
SUPPORT_CONVERSATIONS_KEY,
|
||||||
SUPPORT_UNREAD_KEY,
|
SUPPORT_UNREAD_KEY,
|
||||||
supportMessagesKey,
|
supportMessagesKey,
|
||||||
@@ -45,14 +46,25 @@ export function useSupportSocket(
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
socket.on(
|
||||||
qc.invalidateQueries({
|
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
|
||||||
queryKey: supportMessagesKey(event.message.conversationId),
|
(event: SupportMessageEvent) => {
|
||||||
});
|
// Append rather than invalidate: the thread is paginated, and invalidating
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
// it would refetch every page the agent has scrolled back through on every
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
// single inbound message.
|
||||||
onMessageRef.current?.(event);
|
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(
|
socket.on(
|
||||||
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
SUPPORT_ATTACHMENT_ACCEPT,
|
||||||
SupportAuthorRole,
|
SupportAuthorRole,
|
||||||
|
type SupportAttachmentDto,
|
||||||
type SupportConversationDto,
|
type SupportConversationDto,
|
||||||
type SupportMessageDto,
|
type SupportMessageDto,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -23,10 +26,22 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
|
import {
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 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 {
|
import {
|
||||||
useConversations,
|
useConversations,
|
||||||
useMarkConversationRead,
|
useMarkConversationRead,
|
||||||
@@ -39,6 +54,9 @@ import { customersService } from "@/services/customers.service";
|
|||||||
|
|
||||||
type ReadFilter = "ALL" | "UNREAD";
|
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 {
|
function formatTime(iso?: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -54,12 +72,24 @@ export default function SupportInboxPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
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({
|
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
search,
|
useConversations({
|
||||||
unreadOnly: readFilter === "UNREAD",
|
search,
|
||||||
});
|
unreadOnly: readFilter === "UNREAD",
|
||||||
const items = data?.items ?? [];
|
});
|
||||||
|
|
||||||
useSupportSocket(true, (event) => {
|
useSupportSocket(true, (event) => {
|
||||||
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
|
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
|
||||||
@@ -70,10 +100,13 @@ export default function SupportInboxPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const selected = useMemo(
|
// Prefer the live row from the list (its unread count and last message stay
|
||||||
() => items.find((c) => c.id === selectedId) ?? null,
|
// current); fall back to the picker's copy while its page is still unloaded.
|
||||||
[items, selectedId],
|
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 (
|
return (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
@@ -109,7 +142,10 @@ export default function SupportInboxPage() {
|
|||||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
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
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -140,24 +176,47 @@ export default function SupportInboxPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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 ? (
|
{isLoading ? (
|
||||||
<Group justify="center" p="xl">
|
<Group justify="center" p="xl">
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
</Group>
|
</Group>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||||
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
|
{readFilter === "UNREAD"
|
||||||
|
? "Nothing unread."
|
||||||
|
: "No conversations."}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
items.map((c) => (
|
<>
|
||||||
<InboxRow
|
{items.map((c) => (
|
||||||
key={c.id}
|
<InboxRow
|
||||||
c={c}
|
key={c.id}
|
||||||
active={c.id === selectedId}
|
c={c}
|
||||||
onClick={() => setSelectedId(c.id)}
|
active={c.id === selectedId}
|
||||||
/>
|
onClick={() => setSelectedId(c.id)}
|
||||||
))
|
/>
|
||||||
|
))}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Group justify="center" p="sm">
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -168,7 +227,12 @@ export default function SupportInboxPage() {
|
|||||||
<ConversationThread conversation={selected} />
|
<ConversationThread conversation={selected} />
|
||||||
) : (
|
) : (
|
||||||
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
|
<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} />
|
<Headset size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm">Select a conversation, or start a new chat.</Text>
|
<Text size="sm">Select a conversation, or start a new chat.</Text>
|
||||||
@@ -180,8 +244,9 @@ export default function SupportInboxPage() {
|
|||||||
<CompanyPicker
|
<CompanyPicker
|
||||||
opened={pickerOpen}
|
opened={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
onStarted={(id) => {
|
onStarted={(conversation) => {
|
||||||
setSelectedId(id);
|
setStartedConversation(conversation);
|
||||||
|
setSelectedId(conversation.id);
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -200,7 +265,7 @@ function CompanyPicker({
|
|||||||
}: {
|
}: {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onStarted: (conversationId: string) => void;
|
onStarted: (conversation: SupportConversationDto) => void;
|
||||||
}) {
|
}) {
|
||||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||||
const start = useStartConversation();
|
const start = useStartConversation();
|
||||||
@@ -224,15 +289,23 @@ function CompanyPicker({
|
|||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
const conversation = await start.mutateAsync(companyId);
|
const conversation = await start.mutateAsync(companyId);
|
||||||
setCompanyId(null);
|
setCompanyId(null);
|
||||||
onStarted(conversation.id);
|
onStarted(conversation);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<Stack gap="md">
|
||||||
<Select
|
<Select
|
||||||
label="Customer"
|
label="Customer"
|
||||||
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
|
placeholder={
|
||||||
|
isLoading ? "Loading companies…" : "Search for a company"
|
||||||
|
}
|
||||||
data={options}
|
data={options}
|
||||||
value={companyId}
|
value={companyId}
|
||||||
onChange={setCompanyId}
|
onChange={setCompanyId}
|
||||||
@@ -319,26 +392,133 @@ function ConversationThread({
|
|||||||
}: {
|
}: {
|
||||||
conversation: SupportConversationDto;
|
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 send = useSendMessage(conversation.id);
|
||||||
const markRead = useMarkConversationRead();
|
const markRead = useMarkConversationRead();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
markRead.mutate(conversation.id);
|
markRead.mutate(conversation.id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [conversation.id, messages?.length]);
|
}, [conversation.id, messageCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
* Keep the viewport sensible as the list changes underneath it.
|
||||||
}, [messages?.length, conversation.id]);
|
*
|
||||||
|
* 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 submit = async () => {
|
||||||
const body = draft.trim();
|
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("");
|
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 (
|
return (
|
||||||
@@ -361,36 +541,108 @@ function ConversationThread({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
<ScrollArea
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
viewportRef={viewport}
|
||||||
|
type="hover"
|
||||||
|
onScrollPositionChange={onScroll}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" p="xl">
|
<Group justify="center" p="xl">
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
</Group>
|
</Group>
|
||||||
) : (messages ?? []).length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||||
No messages yet — say hello.
|
No messages yet — say hello.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm" p="md">
|
<Stack gap="sm" p="md">
|
||||||
{(messages ?? []).map((m) => (
|
{isFetchingNextPage && (
|
||||||
<AgentBubble key={m.id} m={m} />
|
<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>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Composer */}
|
{/* 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">
|
<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
|
<Textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
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
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{ flex: 1 }}
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -404,18 +656,27 @@ function ConversationThread({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
loading={send.isPending}
|
loading={send.isPending}
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim() && attach.attachments.length === 0}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
<Send size={18} />
|
<Send size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
{viewer}
|
||||||
</Stack>
|
</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;
|
const mine = m.authorRole === SupportAuthorRole.AGENT;
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -430,7 +691,13 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<Box style={{ maxWidth: "70%" }}>
|
<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"}
|
{mine ? m.authorName || "You" : m.authorName || "Customer"}
|
||||||
</Text>
|
</Text>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -446,9 +713,20 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
|||||||
borderBottomLeftRadius: mine ? undefined : 4,
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
{m.body && (
|
||||||
{m.body}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{m.body}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<MessageAttachments
|
||||||
|
attachments={m.attachments}
|
||||||
|
mine={mine}
|
||||||
|
onView={onView}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||||
{formatTime(m.createdAt)}
|
{formatTime(m.createdAt)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -12,9 +18,14 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Headset, Send, X } from "lucide-react";
|
import { Headset, Paperclip, Send, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "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 {
|
import {
|
||||||
useConversation,
|
useConversation,
|
||||||
useMarkConversationRead,
|
useMarkConversationRead,
|
||||||
@@ -22,6 +33,9 @@ import {
|
|||||||
useSendMessage,
|
useSendMessage,
|
||||||
} from "./useSupport";
|
} from "./useSupport";
|
||||||
|
|
||||||
|
/** Distance from an edge (px) that counts as "at" it. */
|
||||||
|
const SCROLL_EDGE_SLOP = 120;
|
||||||
|
|
||||||
function formatTime(iso?: string | null): string {
|
function formatTime(iso?: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -43,32 +57,136 @@ export interface SupportPanelProps {
|
|||||||
*/
|
*/
|
||||||
export function SupportPanel({ onClose }: SupportPanelProps) {
|
export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||||
const { data: conversation } = useConversation();
|
const { data: conversation } = useConversation();
|
||||||
const { data: messages, isLoading } = useMessages();
|
const {
|
||||||
|
messages,
|
||||||
|
pageCount,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useMessages();
|
||||||
const send = useSendMessage();
|
const send = useSendMessage();
|
||||||
const markRead = useMarkConversationRead();
|
const markRead = useMarkConversationRead();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
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
|
// Mark read on open + whenever new messages arrive. No-op before the thread
|
||||||
// exists, so opening the panel never creates one.
|
// exists, so opening the panel never creates one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversation) markRead.mutate();
|
if (conversation) markRead.mutate();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [conversation?.id, messages?.length]);
|
}, [conversation?.id, messages.length]);
|
||||||
|
|
||||||
// Auto-scroll to newest.
|
/**
|
||||||
useEffect(() => {
|
* Keep the viewport sensible as the list changes underneath it.
|
||||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
*
|
||||||
}, [messages?.length]);
|
* 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 submit = async () => {
|
||||||
const body = draft.trim();
|
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("");
|
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 (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -113,7 +231,12 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
<ScrollArea
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
viewportRef={viewport}
|
||||||
|
type="hover"
|
||||||
|
onScrollPositionChange={onScroll}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" p="xl">
|
<Group justify="center" p="xl">
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
@@ -129,24 +252,91 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm" p="md">
|
<Stack gap="sm" p="md">
|
||||||
{(messages ?? []).map((m) => (
|
{isFetchingNextPage && (
|
||||||
<MessageBubble key={m.id} m={m} />
|
<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>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</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">
|
<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
|
<Textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
placeholder="Type a message…"
|
placeholder="Type a message, or paste an image…"
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={4}
|
maxRows={4}
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{ flex: 1 }}
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -160,18 +350,27 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
loading={send.isPending}
|
loading={send.isPending}
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim() && attach.attachments.length === 0}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
<Send size={18} />
|
<Send size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
{viewer}
|
||||||
</Paper>
|
</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;
|
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -204,9 +403,20 @@ function MessageBubble({ m }: { m: SupportMessageDto }) {
|
|||||||
borderBottomLeftRadius: mine ? undefined : 4,
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
{m.body && (
|
||||||
{m.body}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{m.body}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<MessageAttachments
|
||||||
|
attachments={m.attachments}
|
||||||
|
mine={mine}
|
||||||
|
onView={onView}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||||
{formatTime(m.createdAt)}
|
{formatTime(m.createdAt)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SupportAuthorRole } from "@edr/types";
|
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||||
import { Affix, Indicator, Transition } from "@mantine/core";
|
import { Affix, Indicator, Transition } from "@mantine/core";
|
||||||
import { Headset } from "lucide-react";
|
import { Headset } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -10,6 +10,17 @@ import { SupportPanel } from "./SupportPanel";
|
|||||||
import { useSupportUnreadCount } from "./useSupport";
|
import { useSupportUnreadCount } from "./useSupport";
|
||||||
import { useSupportSocket } from "./useSupportSocket";
|
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.
|
* Floating customer-support launcher, mounted in the authenticated app shell.
|
||||||
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
||||||
@@ -23,12 +34,7 @@ export function SupportWidget() {
|
|||||||
useSupportSocket(isAuthenticated, (event) => {
|
useSupportSocket(isAuthenticated, (event) => {
|
||||||
// Only the customer-visible side matters here; agent replies arrive as AGENT.
|
// Only the customer-visible side matters here; agent replies arrive as AGENT.
|
||||||
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
|
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
|
||||||
toast(
|
toast(`Support replied: ${previewOf(event.message)}`, { icon: "💬" });
|
||||||
`Support replied: ${event.message.body.slice(0, 60)}${
|
|
||||||
event.message.body.length > 60 ? "…" : ""
|
|
||||||
}`,
|
|
||||||
{ icon: "💬" },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import type {
|
import type {
|
||||||
SendSupportMessageResult,
|
SendSupportMessageResult,
|
||||||
SupportConversationDto,
|
SupportConversationDto,
|
||||||
SupportMessageDto,
|
SupportMessageListResult,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
import { client } from "@/utils/api";
|
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
|
* 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.
|
* 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");
|
const { data } = await client.get("/api/support/conversation");
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
listMessages: async (): Promise<SupportMessageDto[]> => {
|
listMessages: async (
|
||||||
const { data } = await client.get("/api/support/conversation/messages");
|
params: ListMessagesParams = {},
|
||||||
|
): Promise<SupportMessageListResult> => {
|
||||||
|
const { data } = await client.get("/api/support/conversation/messages", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
|
/**
|
||||||
const { data } = await client.post("/api/support/conversation/messages", {
|
* Attachment bytes, fetched through the authenticated client.
|
||||||
body,
|
*
|
||||||
});
|
* 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;
|
return data.data;
|
||||||
},
|
},
|
||||||
markRead: async (): Promise<{ unreadCount: number }> => {
|
markRead: async (): Promise<{ unreadCount: number }> => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import type { SupportConversationDto } from "@edr/types";
|
import type {
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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_KEY = ["support"] as const;
|
||||||
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
|
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
|
||||||
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
|
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
|
||||||
export const SUPPORT_UNREAD_KEY = ["support", "unread"] 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. */
|
/** My company's support thread — null until the first message is sent. */
|
||||||
export function useConversation(enabled = true) {
|
export function useConversation(enabled = true) {
|
||||||
return useQuery({
|
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) {
|
export function useMessages(enabled = true) {
|
||||||
return useQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: SUPPORT_MESSAGES_KEY,
|
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,
|
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) {
|
export function useSupportUnreadCount(enabled = true) {
|
||||||
@@ -40,9 +125,12 @@ export function useSupportUnreadCount(enabled = true) {
|
|||||||
export function useSendMessage() {
|
export function useSendMessage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { io } from "socket.io-client";
|
|||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendMessageToCache,
|
||||||
SUPPORT_CONVERSATION_KEY,
|
SUPPORT_CONVERSATION_KEY,
|
||||||
SUPPORT_MESSAGES_KEY,
|
SUPPORT_MESSAGES_KEY,
|
||||||
SUPPORT_UNREAD_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
|
* 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
|
* only has one thread, so every push belongs to it: the message goes straight
|
||||||
* conversation itself, and the unread badge — and fires `onMessage` (the widget
|
* into the thread cache, the conversation and unread badge are refreshed, and
|
||||||
* shows a toast when the panel is closed).
|
* `onMessage` fires (the widget shows a toast when the panel is closed).
|
||||||
*/
|
*/
|
||||||
export function useSupportSocket(
|
export function useSupportSocket(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -52,12 +53,23 @@ export function useSupportSocket(
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
socket.on(
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
(event: SupportMessageEvent) => {
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
// Append rather than invalidate: the thread is paginated, and invalidating
|
||||||
onMessageRef.current?.(event);
|
// 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(
|
socket.on(
|
||||||
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||||
|
|||||||
Reference in New Issue
Block a user