import { produce } from 'immer'; import { atom, useSetAtom } from 'jotai'; import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; import { useEffect } from 'react'; import { useSetting } from './hooks/settings'; import { settingsAtom } from './settings'; export const TYPING_TIMEOUT_MS = 5000; // 5 seconds export type TypingReceipt = { userId: string; ts: number; }; export type IRoomIdToTypingMembers = Map; type TypingMemberPutAction = { type: 'PUT'; roomId: string; userId: string; ts: number; }; type TypingMemberDeleteAction = { type: 'DELETE'; roomId: string; userId: string; }; export type IRoomIdToTypingMembersAction = TypingMemberPutAction | TypingMemberDeleteAction; const baseRoomIdToTypingMembersAtom = atom(new Map()); const putTypingMember = ( roomToMembers: IRoomIdToTypingMembers, action: TypingMemberPutAction, ): IRoomIdToTypingMembers => { let typingMembers = roomToMembers.get(action.roomId) ?? []; typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId); typingMembers.push({ userId: action.userId, ts: action.ts, }); roomToMembers.set(action.roomId, typingMembers); return roomToMembers; }; const deleteTypingMember = ( roomToMembers: IRoomIdToTypingMembers, action: TypingMemberDeleteAction, ): IRoomIdToTypingMembers => { let typingMembers = roomToMembers.get(action.roomId) ?? []; typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId); if (typingMembers.length === 0) { roomToMembers.delete(action.roomId); } else { roomToMembers.set(action.roomId, typingMembers); } return roomToMembers; }; const timeoutReceipt = ( roomToMembers: IRoomIdToTypingMembers, roomId: string, userId: string, timeout: number, ): boolean | undefined => { const typingMembers = roomToMembers.get(roomId) ?? []; const target = typingMembers.find((receipt) => receipt.userId === userId); if (!target) return undefined; return Date.now() - target.ts >= timeout; }; const typingTimers = new Map>(); export const roomIdToTypingMembersAtom = atom< IRoomIdToTypingMembers, [IRoomIdToTypingMembersAction], undefined >( (get) => get(baseRoomIdToTypingMembersAtom), (get, set, action) => { const rToTyping = get(baseRoomIdToTypingMembersAtom); if (action.type === 'PUT') { set( baseRoomIdToTypingMembersAtom, produce(rToTyping, (draft) => putTypingMember(draft, action)), ); // remove typing receipt after some timeout // to prevent stuck typing members const timerKey = `${action.roomId}:${action.userId}`; const existingTimer = typingTimers.get(timerKey); if (existingTimer !== undefined) clearTimeout(existingTimer); typingTimers.set( timerKey, setTimeout(() => { typingTimers.delete(timerKey); const { roomId, userId } = action; const timeout = timeoutReceipt( get(baseRoomIdToTypingMembersAtom), roomId, userId, TYPING_TIMEOUT_MS, ); if (timeout) { set( baseRoomIdToTypingMembersAtom, produce(get(baseRoomIdToTypingMembersAtom), (draft) => deleteTypingMember(draft, { type: 'DELETE', roomId, userId, }), ), ); } }, TYPING_TIMEOUT_MS), ); } if ( action.type === 'DELETE' && rToTyping.get(action.roomId)?.find((receipt) => receipt.userId === action.userId) ) { set( baseRoomIdToTypingMembersAtom, produce(rToTyping, (draft) => deleteTypingMember(draft, action)), ); } }, ); export const useBindRoomIdToTypingMembersAtom = ( mx: MatrixClient, typingMembersAtom: typeof roomIdToTypingMembersAtom, ) => { const setTypingMembers = useSetAtom(typingMembersAtom); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); useEffect(() => { const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = ( event, member, ) => { if (hideActivity) { return; } setTypingMembers({ type: member.typing ? 'PUT' : 'DELETE', roomId: member.roomId, userId: member.userId, ts: Date.now(), }); }; mx.on(RoomMemberEvent.Typing, handleTypingEvent); return () => { mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent); }; }, [mx, setTypingMembers, hideActivity]); };