@@ -30,6 +30,13 @@ export interface RoomMember {
3030 joinedAt : number ;
3131}
3232
33+ export interface TypingUser {
34+ userId : string ;
35+ userName : string ;
36+ roomId : string ;
37+ startedAt : number ;
38+ }
39+
3340export type ChatStoreListener = ( ) => void ;
3441
3542export type SyncMode = "local" | "collaborative" | "hybrid" ;
@@ -60,6 +67,10 @@ export interface IChatStore {
6067
6168 sendMessage ( roomId : string , authorId : string , authorName : string , text : string ) : Promise < ChatMessage > ;
6269 getMessages ( roomId : string , limit ?: number ) : Promise < ChatMessage [ ] > ;
70+
71+ startTyping ( roomId : string , userId : string , userName : string ) : void ;
72+ stopTyping ( roomId : string , userId : string ) : void ;
73+ getTypingUsers ( roomId : string , excludeUserId ?: string ) : TypingUser [ ] ;
6374}
6475
6576// ─── ALASql local store ──────────────────────────────────────────────────────
@@ -70,6 +81,7 @@ export class LocalChatStore implements IChatStore {
7081 private dbName : string ;
7182 private ready = false ;
7283 private listeners = new Set < ChatStoreListener > ( ) ;
84+ private typingMap = new Map < string , TypingUser > ( ) ;
7385
7486 constructor ( applicationId : string ) {
7587 this . dbName = `ChatV2_${ applicationId . replace ( / [ ^ a - z A - Z 0 - 9 _ ] / g, "_" ) } ` ;
@@ -236,6 +248,36 @@ export class LocalChatStore implements IChatStore {
236248 return rows . slice ( - limit ) ;
237249 }
238250
251+ // ── Typing ─────────────────────────────────────────────────────────────
252+
253+ private typingKey ( roomId : string , userId : string ) { return `${ roomId } ::${ userId } ` ; }
254+
255+ startTyping ( roomId : string , userId : string , userName : string ) : void {
256+ this . typingMap . set ( this . typingKey ( roomId , userId ) , { userId, userName, roomId, startedAt : Date . now ( ) } ) ;
257+ this . notify ( ) ;
258+ }
259+
260+ stopTyping ( roomId : string , userId : string ) : void {
261+ if ( this . typingMap . delete ( this . typingKey ( roomId , userId ) ) ) {
262+ this . notify ( ) ;
263+ }
264+ }
265+
266+ getTypingUsers ( roomId : string , excludeUserId ?: string ) : TypingUser [ ] {
267+ const now = Date . now ( ) ;
268+ const result : TypingUser [ ] = [ ] ;
269+ for ( const [ key , entry ] of this . typingMap ) {
270+ if ( entry . roomId !== roomId ) continue ;
271+ if ( excludeUserId && entry . userId === excludeUserId ) continue ;
272+ if ( now - entry . startedAt > 5000 ) {
273+ this . typingMap . delete ( key ) ;
274+ continue ;
275+ }
276+ result . push ( entry ) ;
277+ }
278+ return result ;
279+ }
280+
239281 // ── Internal ───────────────────────────────────────────────────────────
240282
241283 private assert ( ) : void {
@@ -251,6 +293,7 @@ export class YjsChatStore implements IChatStore {
251293 private messagesMap : Y . Map < any > | null = null ;
252294 private roomsMap : Y . Map < any > | null = null ;
253295 private membersMap : Y . Map < any > | null = null ;
296+ private typingYMap : Y . Map < any > | null = null ;
254297 private listeners = new Set < ChatStoreListener > ( ) ;
255298 private ready = false ;
256299 private wsConnected = false ;
@@ -299,12 +342,14 @@ export class YjsChatStore implements IChatStore {
299342 this . messagesMap = ydoc . getMap ( "messages" ) ;
300343 this . roomsMap = ydoc . getMap ( "rooms" ) ;
301344 this . membersMap = ydoc . getMap ( "members" ) ;
345+ this . typingYMap = ydoc . getMap ( "typing" ) ;
302346
303347 // React to any Yjs mutation → notify listeners
304348 const onChange = ( ) => this . notify ( ) ;
305349 this . messagesMap . observe ( onChange ) ;
306350 this . roomsMap . observe ( onChange ) ;
307351 this . membersMap . observe ( onChange ) ;
352+ this . typingYMap . observe ( onChange ) ;
308353
309354 if ( wsProvider ) {
310355 wsProvider . on ( "status" , ( e : { status : string } ) => {
@@ -336,6 +381,7 @@ export class YjsChatStore implements IChatStore {
336381 this . messagesMap = null ;
337382 this . roomsMap = null ;
338383 this . membersMap = null ;
384+ this . typingYMap = null ;
339385 this . listeners . clear ( ) ;
340386 this . ready = false ;
341387 }
@@ -487,6 +533,35 @@ export class YjsChatStore implements IChatStore {
487533 return msgs . slice ( - limit ) ;
488534 }
489535
536+ // ── Typing ─────────────────────────────────────────────────────────────
537+
538+ private typingKey ( roomId : string , userId : string ) { return `${ roomId } ::${ userId } ` ; }
539+
540+ startTyping ( roomId : string , userId : string , userName : string ) : void {
541+ this . typingYMap ?. set ( this . typingKey ( roomId , userId ) , { userId, userName, roomId, startedAt : Date . now ( ) } as TypingUser ) ;
542+ }
543+
544+ stopTyping ( roomId : string , userId : string ) : void {
545+ this . typingYMap ?. delete ( this . typingKey ( roomId , userId ) ) ;
546+ }
547+
548+ getTypingUsers ( roomId : string , excludeUserId ?: string ) : TypingUser [ ] {
549+ if ( ! this . typingYMap ) return [ ] ;
550+ const now = Date . now ( ) ;
551+ const result : TypingUser [ ] = [ ] ;
552+ this . typingYMap . forEach ( ( v : any , key : string ) => {
553+ const entry = v as TypingUser ;
554+ if ( entry . roomId !== roomId ) return ;
555+ if ( excludeUserId && entry . userId === excludeUserId ) return ;
556+ if ( now - entry . startedAt > 5000 ) {
557+ this . typingYMap ! . delete ( key ) ;
558+ return ;
559+ }
560+ result . push ( entry ) ;
561+ } ) ;
562+ return result ;
563+ }
564+
490565 // ── Internal ───────────────────────────────────────────────────────────
491566
492567 private assert ( ) : void {
@@ -600,6 +675,30 @@ export class HybridChatStore implements IChatStore {
600675 }
601676
602677 async getMessages ( roomId : string , limit ?: number ) { return this . reader . getMessages ( roomId , limit ) ; }
678+
679+ // ── Typing (prefer Yjs for real-time sync, fallback to local) ─────────
680+
681+ startTyping ( roomId : string , userId : string , userName : string ) : void {
682+ if ( this . yjs . isReady ( ) ) {
683+ this . yjs . startTyping ( roomId , userId , userName ) ;
684+ } else {
685+ this . local . startTyping ( roomId , userId , userName ) ;
686+ }
687+ }
688+
689+ stopTyping ( roomId : string , userId : string ) : void {
690+ if ( this . yjs . isReady ( ) ) {
691+ this . yjs . stopTyping ( roomId , userId ) ;
692+ } else {
693+ this . local . stopTyping ( roomId , userId ) ;
694+ }
695+ }
696+
697+ getTypingUsers ( roomId : string , excludeUserId ?: string ) : TypingUser [ ] {
698+ return this . yjs . isReady ( )
699+ ? this . yjs . getTypingUsers ( roomId , excludeUserId )
700+ : this . local . getTypingUsers ( roomId , excludeUserId ) ;
701+ }
603702}
604703
605704// ─── Helpers & cache ─────────────────────────────────────────────────────────
0 commit comments