Skip to content

Commit 57fd468

Browse files
committed
add chatCompv2 + new chatData store
1 parent 0c4e885 commit 57fd468

4 files changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import alasql from "alasql";
2+
3+
// ─── Types ───────────────────────────────────────────────────────────────────
4+
5+
export interface ChatMessage {
6+
id: string;
7+
roomId: string;
8+
authorId: string;
9+
authorName: string;
10+
text: string;
11+
timestamp: number;
12+
}
13+
14+
export interface ChatRoom {
15+
id: string;
16+
name: string;
17+
description: string;
18+
type: "public" | "private";
19+
creatorId: string;
20+
createdAt: number;
21+
updatedAt: number;
22+
}
23+
24+
export interface RoomMember {
25+
roomId: string;
26+
userId: string;
27+
userName: string;
28+
joinedAt: number;
29+
}
30+
31+
export type ChatStoreListener = () => void;
32+
33+
// ─── Store ───────────────────────────────────────────────────────────────────
34+
35+
const CROSS_TAB_EVENT = "chatbox-v2-update";
36+
37+
/**
38+
* Thin wrapper around ALASql providing chat persistence.
39+
*
40+
* Design goals:
41+
* - One class, zero abstraction layers.
42+
* - Schema actually matches the data we need (rooms store type, description,
43+
* etc.; messages store authorId/authorName; membership in its own table).
44+
* - Synchronous-feel API (all methods are async because ALASql is, but there
45+
* is no extra indirection).
46+
* - Change listeners so React can subscribe without polling.
47+
*/
48+
export class ChatDataStore {
49+
private dbName: string;
50+
private ready = false;
51+
private listeners = new Set<ChatStoreListener>();
52+
53+
constructor(applicationId: string) {
54+
this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
55+
}
56+
57+
// ── Lifecycle ────────────────────────────────────────────────────────────
58+
59+
async init(): Promise<void> {
60+
if (this.ready) return;
61+
alasql.options.autocommit = true;
62+
63+
await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`);
64+
await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`);
65+
await alasql.promise(`USE ${this.dbName}`);
66+
67+
await alasql.promise(`
68+
CREATE TABLE IF NOT EXISTS rooms (
69+
id STRING PRIMARY KEY,
70+
name STRING,
71+
description STRING,
72+
type STRING,
73+
creatorId STRING,
74+
createdAt NUMBER,
75+
updatedAt NUMBER
76+
)
77+
`);
78+
await alasql.promise(`
79+
CREATE TABLE IF NOT EXISTS messages (
80+
id STRING PRIMARY KEY,
81+
roomId STRING,
82+
authorId STRING,
83+
authorName STRING,
84+
text STRING,
85+
timestamp NUMBER
86+
)
87+
`);
88+
await alasql.promise(`
89+
CREATE TABLE IF NOT EXISTS members (
90+
roomId STRING,
91+
userId STRING,
92+
userName STRING,
93+
joinedAt NUMBER
94+
)
95+
`);
96+
this.ready = true;
97+
98+
if (typeof window !== "undefined") {
99+
window.addEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate);
100+
}
101+
}
102+
103+
destroy(): void {
104+
if (typeof window !== "undefined") {
105+
window.removeEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate);
106+
}
107+
this.listeners.clear();
108+
}
109+
110+
// ── Subscriptions (React-friendly) ───────────────────────────────────────
111+
112+
subscribe(listener: ChatStoreListener): () => void {
113+
this.listeners.add(listener);
114+
return () => this.listeners.delete(listener);
115+
}
116+
117+
private notify(): void {
118+
this.listeners.forEach((fn) => fn());
119+
if (typeof window !== "undefined") {
120+
try {
121+
window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT));
122+
} catch {
123+
// CustomEvent not supported in test environments
124+
}
125+
}
126+
}
127+
128+
private onCrossTabUpdate = () => {
129+
this.listeners.forEach((fn) => fn());
130+
};
131+
132+
// ── Rooms ────────────────────────────────────────────────────────────────
133+
134+
async createRoom(
135+
name: string,
136+
type: "public" | "private",
137+
creatorId: string,
138+
creatorName: string,
139+
description = "",
140+
): Promise<ChatRoom> {
141+
this.assertReady();
142+
const id = this.uid();
143+
const now = Date.now();
144+
await alasql.promise(
145+
`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`,
146+
[id, name, description, type, creatorId, now, now],
147+
);
148+
await alasql.promise(
149+
`INSERT INTO members VALUES (?, ?, ?, ?)`,
150+
[id, creatorId, creatorName, now],
151+
);
152+
this.notify();
153+
return { id, name, description, type, creatorId, createdAt: now, updatedAt: now };
154+
}
155+
156+
async getRoom(roomId: string): Promise<ChatRoom | null> {
157+
this.assertReady();
158+
const rows = (await alasql.promise(
159+
`SELECT * FROM rooms WHERE id = ?`,
160+
[roomId],
161+
)) as ChatRoom[];
162+
return rows.length > 0 ? rows[0] : null;
163+
}
164+
165+
async getRoomByName(name: string): Promise<ChatRoom | null> {
166+
this.assertReady();
167+
const rows = (await alasql.promise(
168+
`SELECT * FROM rooms WHERE name = ?`,
169+
[name],
170+
)) as ChatRoom[];
171+
return rows.length > 0 ? rows[0] : null;
172+
}
173+
174+
async getAllRooms(): Promise<ChatRoom[]> {
175+
this.assertReady();
176+
return (await alasql.promise(
177+
`SELECT * FROM rooms ORDER BY updatedAt DESC`,
178+
)) as ChatRoom[];
179+
}
180+
181+
async getUserRooms(userId: string): Promise<ChatRoom[]> {
182+
this.assertReady();
183+
return (await alasql.promise(
184+
`SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`,
185+
[userId],
186+
)) as ChatRoom[];
187+
}
188+
189+
async getSearchableRooms(userId: string, query: string): Promise<ChatRoom[]> {
190+
this.assertReady();
191+
const q = `%${query}%`;
192+
return (await alasql.promise(
193+
`SELECT DISTINCT r.* FROM rooms r
194+
WHERE r.type = 'public'
195+
AND r.id NOT IN (SELECT roomId FROM members WHERE userId = ?)
196+
AND (r.name LIKE ? OR r.description LIKE ?)
197+
ORDER BY r.updatedAt DESC`,
198+
[userId, q, q],
199+
)) as ChatRoom[];
200+
}
201+
202+
// ── Membership ───────────────────────────────────────────────────────────
203+
204+
async joinRoom(roomId: string, userId: string, userName: string): Promise<boolean> {
205+
this.assertReady();
206+
const existing = (await alasql.promise(
207+
`SELECT * FROM members WHERE roomId = ? AND userId = ?`,
208+
[roomId, userId],
209+
)) as RoomMember[];
210+
if (existing.length > 0) return true; // already a member
211+
await alasql.promise(
212+
`INSERT INTO members VALUES (?, ?, ?, ?)`,
213+
[roomId, userId, userName, Date.now()],
214+
);
215+
this.notify();
216+
return true;
217+
}
218+
219+
async leaveRoom(roomId: string, userId: string): Promise<boolean> {
220+
this.assertReady();
221+
await alasql.promise(
222+
`DELETE FROM members WHERE roomId = ? AND userId = ?`,
223+
[roomId, userId],
224+
);
225+
this.notify();
226+
return true;
227+
}
228+
229+
async getRoomMembers(roomId: string): Promise<RoomMember[]> {
230+
this.assertReady();
231+
return (await alasql.promise(
232+
`SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`,
233+
[roomId],
234+
)) as RoomMember[];
235+
}
236+
237+
async isMember(roomId: string, userId: string): Promise<boolean> {
238+
this.assertReady();
239+
const rows = (await alasql.promise(
240+
`SELECT * FROM members WHERE roomId = ? AND userId = ?`,
241+
[roomId, userId],
242+
)) as RoomMember[];
243+
return rows.length > 0;
244+
}
245+
246+
// ── Messages ─────────────────────────────────────────────────────────────
247+
248+
async sendMessage(
249+
roomId: string,
250+
authorId: string,
251+
authorName: string,
252+
text: string,
253+
): Promise<ChatMessage> {
254+
this.assertReady();
255+
const msg: ChatMessage = {
256+
id: this.uid(),
257+
roomId,
258+
authorId,
259+
authorName,
260+
text,
261+
timestamp: Date.now(),
262+
};
263+
await alasql.promise(
264+
`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`,
265+
[msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp],
266+
);
267+
await alasql.promise(
268+
`UPDATE rooms SET updatedAt = ? WHERE id = ?`,
269+
[msg.timestamp, roomId],
270+
);
271+
this.notify();
272+
return msg;
273+
}
274+
275+
async getMessages(roomId: string, limit = 100): Promise<ChatMessage[]> {
276+
this.assertReady();
277+
const rows = (await alasql.promise(
278+
`SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`,
279+
[roomId],
280+
)) as ChatMessage[];
281+
return rows.slice(-limit);
282+
}
283+
284+
// ── Or-create helpers (for initial room setup) ───────────────────────────
285+
286+
async ensureRoom(
287+
name: string,
288+
type: "public" | "private",
289+
creatorId: string,
290+
creatorName: string,
291+
): Promise<ChatRoom> {
292+
let room = await this.getRoomByName(name);
293+
if (!room) {
294+
room = await this.createRoom(name, type, creatorId, creatorName);
295+
}
296+
const member = await this.isMember(room.id, creatorId);
297+
if (!member) {
298+
await this.joinRoom(room.id, creatorId, creatorName);
299+
}
300+
return room;
301+
}
302+
303+
// ── Internals ────────────────────────────────────────────────────────────
304+
305+
private assertReady(): void {
306+
if (!this.ready) throw new Error("ChatDataStore not initialized. Call init() first.");
307+
}
308+
309+
private uid(): string {
310+
return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
311+
}
312+
}
313+
314+
// Global cache keyed by applicationId so multiple components share one store.
315+
const storeCache = new Map<string, ChatDataStore>();
316+
317+
export function getChatStore(applicationId: string): ChatDataStore {
318+
if (!storeCache.has(applicationId)) {
319+
storeCache.set(applicationId, new ChatDataStore(applicationId));
320+
}
321+
return storeCache.get(applicationId)!;
322+
}

client/packages/lowcoder/src/comps/index.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/t
196196
import { ChatComp } from "./comps/chatComp";
197197
import { ChatBoxComp } from "./comps/chatBoxComponent";
198198
import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp";
199+
import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2";
199200

200201
type Registry = {
201202
[key in UICompType]?: UICompManifest;
@@ -973,6 +974,20 @@ export var uiCompMap: Registry = {
973974
isContainer: true,
974975
},
975976

977+
chatBoxV2: {
978+
name: "Chat Box V2",
979+
enName: "Chat Box V2",
980+
description: "Chat Box with rooms, messaging, and local persistence",
981+
categories: ["collaboration"],
982+
icon: CommentCompIcon,
983+
keywords: "chatbox,chat,conversation,rooms,messaging,v2",
984+
comp: ChatBoxV2Comp,
985+
layoutInfo: {
986+
w: 12,
987+
h: 24,
988+
},
989+
},
990+
976991
// Forms
977992

978993
form: {

client/packages/lowcoder/src/comps/uiCompRegistry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export type UICompType =
145145
| "chat" //Added By Kamal Qureshi
146146
| "chatBox" //Added By Kamal Qureshi
147147
| "chatController"
148+
| "chatBoxV2"
148149
| "autocomplete" //Added By Mousheng
149150
| "colorPicker" //Added By Mousheng
150151
| "floatingButton" //Added By Mousheng

client/packages/lowcoder/src/pages/editor/editorConstants.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,5 @@ export const CompStateIcon: {
309309
chat: <MemoizedIcon Icon={CommentCompIconSmall} />,
310310
chatBox: <MemoizedIcon Icon={CommentCompIconSmall} />,
311311
chatController: <MemoizedIcon Icon={CommentCompIconSmall} />,
312+
chatBoxV2: <MemoizedIcon Icon={CommentCompIconSmall} />,
312313
} as const;

0 commit comments

Comments
 (0)