Files
cinny/src/app/state/typingMembers.ts
T

163 lines
4.5 KiB
TypeScript
Raw Normal View History

import { produce } from 'immer';
2023-10-06 13:44:06 +11:00
import { atom, useSetAtom } from 'jotai';
import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
2023-10-06 13:44:06 +11:00
import { useEffect } from 'react';
2025-02-26 21:44:53 +11:00
import { useSetting } from './hooks/settings';
import { settingsAtom } from './settings';
2023-10-06 13:44:06 +11:00
export const TYPING_TIMEOUT_MS = 5000; // 5 seconds
2023-10-06 13:44:06 +11:00
export type TypingReceipt = {
userId: string;
ts: number;
};
export type IRoomIdToTypingMembers = Map<string, TypingReceipt[]>;
type TypingMemberPutAction = {
type: 'PUT';
roomId: string;
userId: string;
ts: number;
};
type TypingMemberDeleteAction = {
type: 'DELETE';
roomId: string;
userId: string;
};
export type IRoomIdToTypingMembersAction = TypingMemberPutAction | TypingMemberDeleteAction;
2023-10-06 13:44:06 +11:00
const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(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;
};
2024-01-21 23:50:56 +11:00
export const roomIdToTypingMembersAtom = atom<
IRoomIdToTypingMembers,
[IRoomIdToTypingMembersAction],
undefined
>(
2023-10-06 13:44:06 +11:00
(get) => get(baseRoomIdToTypingMembersAtom),
(get, set, action) => {
const rToTyping = get(baseRoomIdToTypingMembersAtom);
2023-10-06 13:44:06 +11:00
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)),
);
2023-10-06 13:44:06 +11:00
}
},
2023-10-06 13:44:06 +11:00
);
export const useBindRoomIdToTypingMembersAtom = (
mx: MatrixClient,
typingMembersAtom: typeof roomIdToTypingMembersAtom,
2023-10-06 13:44:06 +11:00
) => {
const setTypingMembers = useSetAtom(typingMembersAtom);
2025-02-26 21:44:53 +11:00
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
2023-10-06 13:44:06 +11:00
useEffect(() => {
const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = (
event,
member,
2023-10-06 13:44:06 +11:00
) => {
2025-02-26 21:44:53 +11:00
if (hideActivity) {
return;
}
2023-10-06 13:44:06 +11:00
setTypingMembers({
type: member.typing ? 'PUT' : 'DELETE',
roomId: member.roomId,
userId: member.userId,
ts: Date.now(),
2023-10-06 13:44:06 +11:00
});
};
mx.on(RoomMemberEvent.Typing, handleTypingEvent);
return () => {
mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent);
};
2025-02-26 21:44:53 +11:00
}, [mx, setTypingMembers, hideActivity]);
2023-10-06 13:44:06 +11:00
};