Merge pull request #995 from Hexastack/993-bug-console-chat-widget-doesnt-show-quick-replies

fix: Show quick replies in Console Chat Widget
This commit is contained in:
Med Marrouchi 2025-05-13 15:01:36 +01:00 committed by GitHub
commit 7e22a43cd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 451 additions and 153 deletions

View File

@ -31,6 +31,9 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { buildTestingMocks } from '@/utils/test/utils';
import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe';
import { Room } from '@/websocket/types';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { MessageRepository } from '../repositories/message.repository';
import { Message, MessageModel } from '../schemas/message.schema';
@ -53,6 +56,13 @@ describe('MessageService', () => {
let recipient: Subscriber;
let messagesWithSenderAndRecipient: Message[];
let user: User;
let mockGateway: Partial<WebsocketGateway>;
let mockMessageService: MessageService;
const SESSION_ID = 'session-123';
const SUCCESS_PAYLOAD: IOOutgoingSubscribeMessage = {
success: true,
subscribe: Room.MESSAGE,
};
beforeAll(async () => {
const { getMocks } = await buildTestingMocks({
@ -102,11 +112,34 @@ describe('MessageService', () => {
recipient: allSubscribers.find(({ id }) => id === message.recipient)?.id,
sentBy: allUsers.find(({ id }) => id === message.sentBy)?.id,
}));
mockGateway = {
joinNotificationSockets: jest.fn(),
};
mockMessageService = new MessageService({} as any, mockGateway as any);
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('subscribe', () => {
it('should join Notification sockets message room and return a success response', async () => {
const req = { sessionID: SESSION_ID };
const res = {
json: jest.fn(),
status: jest.fn().mockReturnThis(),
};
await mockMessageService.subscribe(req as any, res as any);
expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith(
SESSION_ID,
Room.MESSAGE,
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD);
});
});
describe('findOneAndPopulate', () => {
it('should find message by id, and populate its corresponding sender and recipient', async () => {
jest.spyOn(messageRepository, 'findOneAndPopulate');

View File

@ -19,6 +19,7 @@ import {
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe';
import { Room } from '@/websocket/types';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
@ -53,11 +54,16 @@ export class MessageService extends BaseService<
*/
@SocketGet('/message/subscribe/')
@SocketPost('/message/subscribe/')
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
async subscribe(
@SocketReq() req: SocketRequest,
@SocketRes() res: SocketResponse,
): Promise<IOOutgoingSubscribeMessage> {
try {
this.gateway.io.socketsJoin(Room.MESSAGE);
await this.gateway.joinNotificationSockets(req.sessionID, Room.MESSAGE);
return res.status(200).json({
success: true,
subscribe: Room.MESSAGE,
});
} catch (e) {
this.logger.error('Websocket subscription', e);

View File

@ -38,6 +38,9 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { buildTestingMocks } from '@/utils/test/utils';
import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe';
import { Room } from '@/websocket/types';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
@ -58,6 +61,13 @@ describe('SubscriberService', () => {
let allSubscribers: Subscriber[];
let allLabels: Label[];
let allUsers: User[];
let mockGateway: Partial<WebsocketGateway>;
let mockSubscriberService: SubscriberService;
const SESSION_ID = 'session-123';
const SUCCESS_PAYLOAD: IOOutgoingSubscribeMessage = {
success: true,
subscribe: Room.SUBSCRIBER,
};
beforeAll(async () => {
const { getMocks } = await buildTestingMocks({
@ -103,11 +113,38 @@ describe('SubscriberService', () => {
allSubscribers = await subscriberRepository.findAll();
allLabels = await labelRepository.findAll();
allUsers = await userRepository.findAll();
mockGateway = {
joinNotificationSockets: jest.fn(),
};
mockSubscriberService = new SubscriberService(
{} as any,
{} as any,
mockGateway as any,
);
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('subscribe', () => {
it('should join Notification sockets subscriber room and return a success response', async () => {
const req = { sessionID: SESSION_ID };
const res = {
json: jest.fn(),
status: jest.fn().mockReturnThis(),
};
await mockSubscriberService.subscribe(req as any, res as any);
expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith(
SESSION_ID,
Room.SUBSCRIBER,
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD);
});
});
describe('findOneAndPopulate', () => {
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
jest.spyOn(subscriberService, 'findOneAndPopulate');

View File

@ -30,6 +30,7 @@ import {
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe';
import { Room } from '@/websocket/types';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
@ -71,10 +72,17 @@ export class SubscriberService extends BaseService<
*/
@SocketGet('/subscriber/subscribe/')
@SocketPost('/subscriber/subscribe/')
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
async subscribe(
@SocketReq() req: SocketRequest,
@SocketRes() res: SocketResponse,
): Promise<IOOutgoingSubscribeMessage> {
try {
this.gateway.io.socketsJoin(Room.SUBSCRIBER);
return res.json({
await this.gateway.joinNotificationSockets(
req.sessionID,
Room.SUBSCRIBER,
);
return res.status(200).json({
success: true,
subscribe: Room.SUBSCRIBER,
});

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -15,6 +15,8 @@ import {
import { config } from '@/config';
import { Room } from '../types';
export interface IOOutgoingMessage {
statusCode: number;
body: any;
@ -29,6 +31,11 @@ export interface IOIncomingMessage {
url: string;
}
export interface IOOutgoingSubscribeMessage {
success: boolean;
subscribe: Room;
}
@Injectable()
export class IOMessagePipe implements PipeTransform<string, IOIncomingMessage> {
transform(value: string, _metadata: ArgumentMetadata): IOIncomingMessage {

View File

@ -52,9 +52,11 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
allowUpgrades: config.sockets.allowUpgrades,
}),
...(config.sockets.cookie && { cookie: config.sockets.cookie }),
...(config.sockets.onlyAllowOrigins && {
cors: {
origin: async (origin, cb) => {
cors: {
origin: async (origin, cb) => {
if (config.env === 'test') {
cb(null, true);
} else {
// Retrieve the allowed origins from the settings
const app = AppInstance.getApp();
const settingService = app.get<SettingService>(SettingService);
@ -74,9 +76,9 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
}
})
.catch(cb);
},
}
},
}),
},
};
return opts;

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -70,9 +70,9 @@ export class SocketResponse {
return response;
}
json(data: any) {
json<T = Partial<IOOutgoingMessage>>(data: T): T {
this.set('Content-Type', 'application/json');
return this.send(data);
return this.send(data) as T;
}
public getPromise() {

View File

@ -7,8 +7,8 @@
*/
import { INestApplication } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Socket, io } from 'socket.io-client';
import { v4 as uuidv4 } from 'uuid';
import {
closeInMongodConnection,
@ -17,21 +17,22 @@ import {
import { buildTestingMocks } from '@/utils/test/utils';
import { SocketEventDispatcherService } from './services/socket-event-dispatcher.service';
import { Room } from './types';
import { WebsocketGateway } from './websocket.gateway';
describe('WebsocketGateway', () => {
let gateway: WebsocketGateway;
let app: INestApplication;
let ioClient: Socket;
let createSocket: (index: number) => Socket;
let sockets: Socket[];
let messageRoomSockets: Socket[];
let uuids: string[];
let validUuids: string[];
beforeAll(async () => {
// Instantiate the app
const { module } = await buildTestingMocks({
providers: [
WebsocketGateway,
EventEmitter2,
SocketEventDispatcherService,
],
providers: [WebsocketGateway, SocketEventDispatcherService],
imports: [
rootMongooseTestModule(({ uri, dbName }) => {
process.env.MONGO_URI = uri;
@ -44,14 +45,25 @@ describe('WebsocketGateway', () => {
// Get the gateway instance from the app instance
gateway = app.get<WebsocketGateway>(WebsocketGateway);
// Create a new client that will interact with the gateway
ioClient = io('http://localhost:3000', {
autoConnect: false,
transports: ['websocket', 'polling'],
// path: '/socket.io/?EIO=4&transport=websocket&channel=web-channel',
query: { EIO: '4', transport: 'websocket', channel: 'web-channel' },
});
app.listen(3000);
uuids = [uuidv4(), uuidv4(), uuidv4()];
validUuids = [uuids[0], uuids[2]];
createSocket = (index: number) =>
io('http://localhost:3000', {
autoConnect: false,
transports: ['websocket', 'polling'],
// path: '/socket.io/?EIO=4&transport=websocket&channel=web-channel',
query: { EIO: '4', transport: 'websocket', channel: 'web-channel' },
extraHeaders: { uuid: uuids[index] },
});
sockets = uuids.map((_e, index) => createSocket(index));
messageRoomSockets = sockets.filter((socket) =>
validUuids.includes(socket?.io.opts.extraHeaders?.['uuid'] || ''),
);
await app.listen(3000);
});
afterAll(async () => {
@ -64,22 +76,95 @@ describe('WebsocketGateway', () => {
});
it('should connect successfully', async () => {
ioClient.on('connect', () => {
expect(true).toBe(true);
const [socket1] = sockets;
socket1.connect();
await new Promise<void>((resolve) => {
socket1.on('connect', async () => {
expect(true).toBe(true);
resolve();
});
});
ioClient.connect();
ioClient.disconnect();
socket1.disconnect();
});
it('should emit "OK" on "healthcheck"', async () => {
ioClient.on('connect', () => {
ioClient.emit('healthcheck', 'Hello world');
const [socket1] = sockets;
ioClient.on('event', (data) => {
expect(data).toBe('OK');
socket1.connect();
await new Promise<void>((resolve) => {
socket1.on('connect', () => {
socket1.emit('healthcheck', 'Hello world!');
socket1.on('event', (data) => {
expect(data).toBe('OK');
resolve();
});
});
});
ioClient.connect();
ioClient.disconnect();
socket1.disconnect();
});
describe('joinNotificationSockets', () => {
it('should make socket1 and socket3 join the room MESSAGE', async () => {
messageRoomSockets.forEach((socket) => socket.connect());
for (const socket of messageRoomSockets) {
await new Promise<void>((resolve) => socket.on('connect', resolve));
}
const serverSockets = await gateway.io.fetchSockets();
expect(serverSockets.length).toBe(2);
jest.spyOn(gateway, 'getNotificationSockets').mockResolvedValueOnce(
serverSockets.filter(({ handshake: { headers } }) => {
const uuid = headers.uuid?.toString() || '';
return validUuids.includes(uuid);
}),
);
await gateway.joinNotificationSockets('sessionId', Room.MESSAGE);
gateway.io.to(Room.MESSAGE).emit('message', { data: 'OK' });
for (const socket of messageRoomSockets) {
await new Promise<void>((resolve) => {
socket.on('message', async ({ data }) => {
expect(data).toBe('OK');
resolve();
});
});
}
messageRoomSockets.forEach((socket) => socket.disconnect());
});
it('should throw an error when socket array is empty', async () => {
jest.spyOn(gateway, 'getNotificationSockets').mockResolvedValueOnce([]);
await expect(
gateway.joinNotificationSockets('sessionId', Room.MESSAGE),
).rejects.toThrow('No notification sockets found!');
expect(gateway.getNotificationSockets).toHaveBeenCalledWith('sessionId');
});
it('should throw an error with empty sessionId', async () => {
await expect(
gateway.joinNotificationSockets('', Room.MESSAGE),
).rejects.toThrow('SessionId is required!');
});
});
describe('getNotificationSockets', () => {
it('should throw an error with empty sessionId', async () => {
await expect(gateway.getNotificationSockets('')).rejects.toThrow(
'SessionId is required!',
);
});
});
});

View File

@ -21,7 +21,8 @@ import cookie from 'cookie';
import * as cookieParser from 'cookie-parser';
import signature from 'cookie-signature';
import { Session as ExpressSession, SessionData } from 'express-session';
import { Server, Socket } from 'socket.io';
import { RemoteSocket, Server, Socket } from 'socket.io';
import { DefaultEventsMap } from 'socket.io/dist/typed-events';
import { sync as uid } from 'uid-safe';
import { MessageFull } from '@/chat/schemas/message.schema';
@ -113,53 +114,56 @@ export class WebsocketGateway
this.io.to(subscriber.foreign_id).emit(type, content);
}
createAndStoreSession(client: Socket, next: (err?: Error) => void): void {
const sid = uid(24); // Sign the sessionID before sending
const signedSid = 's:' + signature.sign(sid, config.session.secret);
// Send session ID to client to set cookie
const cookies = cookie.serialize(
config.session.name,
signedSid,
config.session.cookie,
);
const newSession: SessionData<SubscriberStub> = {
cookie: {
// Prevent access from client-side javascript
httpOnly: true,
async createAndStoreSession(client: Socket): Promise<void> {
return new Promise<void>((resolve, reject) => {
const sid = uid(24); // Sign the sessionID before sending
const signedSid = 's:' + signature.sign(sid, config.session.secret);
// Send session ID to client to set cookie
const cookies = cookie.serialize(
config.session.name,
signedSid,
config.session.cookie,
);
const newSession: SessionData<SubscriberStub> = {
cookie: {
// Prevent access from client-side javascript
httpOnly: true,
// Restrict to path
path: '/',
// Restrict to path
path: '/',
originalMaxAge: config.session.cookie.maxAge,
},
passport: { user: {} },
}; // Initialize your session object as needed
getSessionStore().set(sid, newSession, (err) => {
if (err) {
this.logger.error('Error saving session:', err);
return next(new Error('Unable to establish a new socket session'));
}
originalMaxAge: config.session.cookie.maxAge,
},
passport: { user: {} },
}; // Initialize your session object as needed
getSessionStore().set(sid, newSession, (err) => {
if (err) {
this.logger.error('Error saving session:', err);
return reject(new Error('Unable to establish a new socket session'));
}
client.emit('set-cookie', cookies);
// Optionally set the cookie on the client's handshake object if needed
client.handshake.headers.cookie = cookies;
client.data.session = newSession;
this.logger.verbose(`
Could not fetch session, since connecting socket has no cookie in its handshake.
Generated a one-time-use cookie:
${client.handshake.headers.cookie}
and saved it on the socket handshake.
> This means the socket started off with an empty session, i.e. (req.session === {})
> That "anonymous" session will only last until the socket is disconnected. To work around this,
> make sure the socket sends a 'cookie' header or query param when it initially connects.
> (This usually arises due to using a non-browser client such as a native iOS/Android app,
> React Native, a Node.js script, or some other connected device. It can also arise when
> attempting to connect a cross-origin socket in the browser, particularly for Safari users.
> To work around this, either supply a cookie manually, or ignore this message and use an
> approach other than sessions-- e.g. an auth token.)
`);
return next();
client.emit('set-cookie', cookies);
// Optionally set the cookie on the client's handshake object if needed
client.handshake.headers.cookie = cookies;
client.data.session = newSession;
client.data.sessionID = sid;
this.logger.verbose(`
Could not fetch session, since connecting socket has no cookie in its handshake.
Generated a one-time-use cookie:
${client.handshake.headers.cookie}
and saved it on the socket handshake.
> This means the socket started off with an empty session, i.e. (req.session === {})
> That "anonymous" session will only last until the socket is disconnected. To work around this,
> make sure the socket sends a 'cookie' header or query param when it initially connects.
> (This usually arises due to using a non-browser client such as a native iOS/Android app,
> React Native, a Node.js script, or some other connected device. It can also arise when
> attempting to connect a cross-origin socket in the browser, particularly for Safari users.
> To work around this, either supply a cookie manually, or ignore this message and use an
> approach other than sessions-- e.g. an auth token.)
`);
return resolve();
});
});
}
@ -205,11 +209,14 @@ export class WebsocketGateway
this.logger.log('Initialized websocket gateway');
// Handle session
this.io.use((client, next) => {
this.io.use(async (client, next) => {
this.logger.verbose('Client connected, attempting to load session.');
try {
const { searchParams } = new URL(`ws://localhost${client.request.url}`);
if (config.env === 'test') {
await this.createAndStoreSession(client);
}
if (client.request.headers.cookie) {
const cookies = cookie.parse(client.request.headers.cookie);
if (cookies && config.session.name in cookies) {
@ -218,14 +225,19 @@ export class WebsocketGateway
config.session.secret,
);
if (sessionID) {
return this.loadSession(sessionID, (err, session) => {
return this.loadSession(sessionID, async (err, session) => {
if (err || !session) {
this.logger.warn(
'Unable to load session, creating a new one ...',
err,
);
if (searchParams.get('channel') !== 'console-channel') {
return this.createAndStoreSession(client, next);
try {
await this.createAndStoreSession(client);
next();
} catch (e) {
next(e);
}
} else {
return next(new Error('Unauthorized: Unknown session ID'));
}
@ -235,17 +247,22 @@ export class WebsocketGateway
next();
});
} else {
return next(new Error('Unable to parse session ID from cookie'));
next(new Error('Unable to parse session ID from cookie'));
}
}
} else if (searchParams.get('channel') === 'web-channel') {
return this.createAndStoreSession(client, next);
try {
await this.createAndStoreSession(client);
next();
} catch (e) {
next(e);
}
} else {
return next(new Error('Unauthorized to connect to WS'));
next(new Error('Unauthorized to connect to WS'));
}
} catch (e) {
this.logger.warn('Something unexpected happening');
return next(e);
next(e);
}
});
}
@ -268,7 +285,7 @@ export class WebsocketGateway
}
async handleDisconnect(client: Socket): Promise<void> {
this.logger.log(`Client id:${client.id} disconnected`);
this.logger.log(`Client id: ${client.id} disconnected`);
// Configurable custom afterDisconnect logic here
// (default: do nothing)
if (!config.sockets.afterDisconnect) {
@ -405,4 +422,46 @@ export class WebsocketGateway
);
return response.getPromise();
}
/**
* Retrieves notification sockets based on session id.
*
* @param sessionId - The session id
* @returns An RemoteSocket array
*/
async getNotificationSockets(
sessionId: string,
): Promise<RemoteSocket<DefaultEventsMap, any>[]> {
if (!sessionId) {
throw new Error('SessionId is required!');
}
const allSockets = await this.io.fetchSockets();
return allSockets.filter(
({ handshake, data }) =>
!handshake.query.channel && data.sessionID === sessionId,
);
}
/**
* Join notification sockets based on session id.
*
* @param sessionId - The session id
* @param room - the joined room name
*/
async joinNotificationSockets(sessionId: string, room: Room): Promise<void> {
if (!sessionId) {
throw new Error('SessionId is required!');
}
const notificationSockets = await this.getNotificationSockets(sessionId);
if (!notificationSockets.length) {
throw new Error('No notification sockets found!');
}
notificationSockets.forEach((notificationSocket) =>
notificationSocket.join(room),
);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -7,17 +7,16 @@
*/
import {
Dispatch,
PropsWithChildren,
createContext,
useState,
Dispatch,
useContext,
useState,
} from "react";
import { useGet } from "@/hooks/crud/useGet";
import { EntityType, Format } from "@/services/types";
import { ISubscriber } from "@/types/subscriber.types";
import { useSocketGetQuery } from "@/websocket/socket-hooks";
import { noop } from "../helpers/noop";
@ -26,7 +25,7 @@ interface IChatContext {
setSubscriberId: Dispatch<string | null>;
}
const chatContext = createContext<IChatContext>({
const ChatContext = createContext<IChatContext>({
subscriber: null,
setSubscriberId: noop,
});
@ -49,12 +48,8 @@ export const ChatProvider = ({ children }: PropsWithChildren) => {
setSubscriberId,
};
useSocketGetQuery("/message/subscribe/");
useSocketGetQuery("/subscriber/subscribe/");
return (
<chatContext.Provider value={context}>{children}</chatContext.Provider>
<ChatContext.Provider value={context}>{children}</ChatContext.Provider>
);
};
@ -63,7 +58,7 @@ export const ChatProvider = ({ children }: PropsWithChildren) => {
* @description this hook is used to get the active chat
*/
export const useChat = () => {
const context = useContext(chatContext);
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");

View File

@ -0,0 +1,30 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Grid } from "@mui/material";
import { LayoutProps } from ".";
import { ChatWidget } from "@/app-components/widget/ChatWidget";
import { Content } from "./content";
import { Header } from "./Header";
export const AnonymousLayout: React.FC<LayoutProps> = ({
children,
sxContent,
...rest
}) => (
<Grid container>
<Header />
<Content sx={sxContent} {...rest}>
{children}
</Content>
<ChatWidget />
</Grid>
);

View File

@ -0,0 +1,49 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Grid } from "@mui/material";
import { useState } from "react";
import { LayoutProps } from ".";
import { ChatWidget } from "@/app-components/widget/ChatWidget";
import { useSocketGetQuery } from "@/websocket/socket-hooks";
import { Content } from "./content";
import { Header } from "./Header";
import { VerticalMenu } from "./VerticalMenu";
export const AuthenticatedLayout: React.FC<LayoutProps> = ({
children,
sxContent,
...rest
}) => {
const [isSideBarOpen, setIsSideBarOpen] = useState(false);
useSocketGetQuery("/message/subscribe/");
useSocketGetQuery("/subscriber/subscribe/");
return (
<Grid container>
<Header
isSideBarOpen={isSideBarOpen}
onToggleSidebar={() => setIsSideBarOpen(true)}
/>
<VerticalMenu
isSideBarOpen={isSideBarOpen}
onToggleIn={() => setIsSideBarOpen(true)}
onToggleOut={() => setIsSideBarOpen(false)}
/>
<Content sx={sxContent} {...rest}>
{children}
</Content>
<ChatWidget />
</Grid>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -69,8 +69,8 @@ const StyledAppBar = styled(AppBar)(() => ({
}));
export type HeaderProps = {
isSideBarOpen: boolean;
onToggleSidebar: () => void;
isSideBarOpen?: boolean;
onToggleSidebar?: () => void;
};
export const Header: FC<HeaderProps> = ({ isSideBarOpen, onToggleSidebar }) => {
const { apiUrl, ssoEnabled } = useConfig();

View File

@ -1,19 +1,17 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { BoxProps, Grid } from "@mui/material";
import { useState } from "react";
import { BoxProps } from "@mui/material";
import { ChatWidget } from "@/app-components/widget/ChatWidget";
import { useAuth } from "@/hooks/useAuth";
import { Content } from "./content";
import { Header } from "./Header";
import { VerticalMenu } from "./VerticalMenu";
import { AnonymousLayout } from "./AnonymousLayout";
import { AuthenticatedLayout } from "./AuthenticatedLayout";
export interface IContentPaddingProps {
hasNoPadding?: boolean;
@ -23,28 +21,12 @@ export type LayoutProps = IContentPaddingProps & {
children: JSX.Element;
sxContent?: BoxProps;
};
export const Layout: React.FC<LayoutProps> = ({
children,
sxContent,
...rest
}) => {
const [isSideBarOpen, setIsSideBarOpen] = useState(false);
export const Layout: React.FC<LayoutProps> = ({ children, ...rest }) => {
const { isAuthenticated } = useAuth();
return (
<Grid container>
<Header
isSideBarOpen={isSideBarOpen}
onToggleSidebar={() => setIsSideBarOpen(true)}
/>
<VerticalMenu
isSideBarOpen={isSideBarOpen}
onToggleIn={() => setIsSideBarOpen(true)}
onToggleOut={() => setIsSideBarOpen(false)}
/>
<Content sx={sxContent} {...rest}>
{children}
</Content>
<ChatWidget />
</Grid>
return isAuthenticated ? (
<AuthenticatedLayout {...rest}>{children}</AuthenticatedLayout>
) : (
<AnonymousLayout {...rest}>{children}</AnonymousLayout>
);
};

View File

@ -15,7 +15,7 @@ import React, {
} from "react";
import { useTranslation } from "../hooks/useTranslation";
import { useChat } from "../providers/ChatProvider";
import { getQuickReplies, useChat } from "../providers/ChatProvider";
import { useColors } from "../providers/ColorProvider";
import { useConfig } from "../providers/ConfigProvider";
import { useSettings } from "../providers/SettingsProvider";
@ -42,6 +42,7 @@ const UserSubscription: React.FC = () => {
setConnectionState,
participants,
setParticipants,
setSuggestions,
} = useChat();
const [firstName, setFirstName] = useState<string>("");
const [lastName, setLastName] = useState<string>("");
@ -58,6 +59,9 @@ const UserSubscription: React.FC = () => {
`/webhook/${config.channel}/?first_name=${firstName}&last_name=${lastName}`,
);
const { messages, profile } = body;
const quickReplies = getQuickReplies(body.messages.at(-1));
setSuggestions(quickReplies);
localStorage.setItem("profile", JSON.stringify(profile));
messages.forEach((message) => {

View File

@ -35,6 +35,18 @@ import { useSettings } from "./SettingsProvider";
import { useSocket, useSubscribe } from "./SocketProvider";
import { useWidget } from "./WidgetProvider";
export const getQuickReplies = (message?: TMessage): ISuggestion[] =>
message && "data" in message && "quick_replies" in message.data
? (message.data.quick_replies || []).map(
(qr) =>
({
content_type: QuickReplyType.text,
text: qr.title,
payload: qr.payload,
} as ISuggestion),
)
: [];
interface Participant {
id: string;
name: string;
@ -257,24 +269,10 @@ const ChatProvider: React.FC<{
setScroll(0);
}
if (
newIOMessage &&
"data" in newIOMessage &&
"quick_replies" in newIOMessage.data
) {
setSuggestions(
(newIOMessage.data.quick_replies || []).map(
(qr) =>
({
content_type: QuickReplyType.text,
text: qr.title,
payload: qr.payload,
} as ISuggestion),
),
);
} else {
setSuggestions([]);
}
const quickReplies = getQuickReplies(newIOMessage as TMessage);
setSuggestions(quickReplies);
isOpen ||
updateNewMessagesCount((prevMessagesCount) => prevMessagesCount + 1);
settings.alwaysScrollToBottom && setScroll(101); // @hack
@ -327,6 +325,9 @@ const ChatProvider: React.FC<{
queryParams,
).toString()}`,
);
const quickReplies = getQuickReplies(body.messages.at(-1));
setSuggestions(quickReplies);
localStorage.setItem("profile", JSON.stringify(body.profile));
setMessages(