UNPKG

@ably/chat

Version:

Ably Chat is a set of purpose-built APIs for a host of chat features enabling you to create 1:1, 1:Many, Many:1 and Many:Many chat rooms for any scale. It is designed to meet a wide range of chat use cases, such as livestreams, in-game communication, cust

489 lines (452 loc) 18.2 kB
import * as Ably from 'ably'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ConnectionStatus } from '../../core/connection.js'; import { Presence, PresenceData, PresenceStateChange, PresenceStateChangeListener } from '../../core/presence.js'; import { Room } from '../../core/room.js'; import { RoomStatus } from '../../core/room-status.js'; import { Subscription } from '../../core/subscription.js'; import { wrapRoomPromise } from '../helper/room-promise.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { StatusParams } from '../types/status-params.js'; import { useEventListenerRef } from './internal/use-event-listener-ref.js'; import { useRoomLogger } from './internal/use-logger.js'; import { useRoomContext } from './internal/use-room-context.js'; import { useRoomStatus } from './internal/use-room-status.js'; import { useChatConnection } from './use-chat-connection.js'; /** * The options for the {@link usePresence} hook. */ export interface UsePresenceParams extends StatusParams { /** * The initial data to enter the room with when auto-entering (autoEnterLeave=true). Any JSON serializable data can be provided. * This data is only used for the initial auto-enter when the component mounts. Changes to this value * after the first render are ignored. To update presence data after the initial enter, use the * `update` or `enter` methods returned by the hook. * @example * ```tsx * // This will cause the hook to auto-enter presence with the provided data * // autoEnterLeave is implicitly true * const { presence, update } = usePresence({ * initialData: { status: 'online', lastSeen: Date.now() } * }); * * // Subsequent data updates must be done via calls to enter/update * await update({status: 'away'}); * ``` * @defaultValue undefined */ initialData?: PresenceData; /** * Controls whether the hook should automatically enter presence when the component mounts and the room * becomes attached, and automatically leave presence when the component unmounts. * * Also controls whether the hook will automatically re-enter presence if the room is detached and then re-attached. * * **Important** If {@link UsePresenceResponse.leave} is called, then the hook will NOT auto-enter. To re-enable * auto-enter behavior, you must call {@link UsePresenceResponse.enter} or {@link UsePresenceResponse.update}. * When set to false, you have full manual control over entering and leaving presence. * * Defaults to true if not provided. * @defaultValue true */ autoEnterLeave?: boolean; } export interface UsePresenceResponse extends ChatStatusResponse { /** * A shortcut to the {@link Presence.update} method. * * This is a stable reference and will not be changed between renders for the same room. * * **Important** When called, if {@link UsePresenceParams.autoEnterLeave} is set to true, the hook will attempt to * auto-enter presence automatically when conditions are met. */ readonly update: Presence['update']; /** * A shortcut to the {@link Presence.enter} method, which can be used to manually enter presence when * `autoEnterLeave` is false, or to explicitly re-enter presence with new data. * * This is a stable reference and will not be changed between renders for the same room. * * **Important** When called, if {@link UsePresenceParams.autoEnterLeave} is set to true, the hook will attempt to * auto-enter presence automatically when conditions are met. * @example * ```tsx * // Manual control over presence with conditional logic * const { enter, leave } = usePresence({ autoEnterLeave: false }); * * useEffect(() => { * if (effectCondition) { * enter({ status: 'active' }); * } * * return () => { * if (effectCondition) { * leave(); * } * }; * }, [effectCondition, enter, leave]); * ``` */ readonly enter: Presence['enter']; /** * A shortcut to the {@link Presence.leave} method. * * This is a stable reference and will not be changed between renders for the same room. * * **Important** When called, this will prevent the hook from automatically re-entering presence, even when `autoEnterLeave` is true. * * This is useful for manually controlling when presence is left. * @example * ```tsx * // Manual control over presence with conditional logic * const { enter, leave } = usePresence({ autoEnterLeave: false }); * * useEffect(() => { * if (effectCondition) { * enter({ status: 'active' }); * } * * return () => { * if (effectCondition) { * leave(); * } * }; * }, [effectCondition, enter, leave]); * ``` * @example * ```tsx * // Enter presence automatically with some initial data * const { leave, enter } = usePresence({ initialData: { status: 'online' } }); * * // Leave presence explicitly, disabling auto re-entry * await leave(); * * // Re-enter presence again, re-enabling auto-entry if selected in the hook * await enter({ status: 'online again' }) * ``` */ readonly leave: Presence['leave']; /** * The current presence state of this client. */ readonly myPresenceState: { /** * Indicates if the user is currently present in the room. */ present: boolean; /** * Indicates if an error occurred while trying to enter or leave presence. */ error?: Ably.ErrorInfo; }; } // Internal interface for presence with state change subscription interface PresenceWithStateChangeListener extends Presence { onPresenceStateChange(listener: PresenceStateChangeListener): Subscription; } /** * A set of connection states that are considered inactive and where presence operations should not be attempted. */ const INACTIVE_CONNECTION_STATES = new Set<ConnectionStatus>([ConnectionStatus.Suspended, ConnectionStatus.Failed]); /** * A hook that provides access to the {@link Presence} instance in the room. * It will use the instance belonging to the room in the nearest {@link ChatRoomProvider} in the component tree. * * By default (when `autoEnterLeave` is true or not provided), the hook will automatically `enter` the room * when the component mounts and the room is attached, and `leave` when the component unmounts. The hook will * also automatically re-enter presence after room detachment/reattachment cycles. * * When `autoEnterLeave` is false, you have full manual control over entering and leaving presence using the * returned `enter` and `leave` methods. * * The {@link UsePresenceResponse.myPresenceState} can be used to determine if the user is currently present * in the room, and if any errors occurred while trying to enter or leave presence. * * **Important** When using `autoEnterLeave`, you should not use multiple instances of this hook within the same * ChatClientProvider instance, as each hook uses the same underlying presence instance, and maintains internal * state for the automatic re-entry behavior. If you need to have multiple areas of your application updating the * presence data you could either: * 1. Set `autoEnterLeave` to `false` and manage presence state automatically. * 2. Hold your presence state and call functions at a higher-level (e.g. a context provider), with your * lower-level components passing data back up the hierarchy to be contained in the presence data. * @example * ```tsx * // Example hook usage with auto-entry of presence on mount and auto-leave on unmount * const MyComponent = () => { * const { presence, myPresenceState, update } = usePresence({ * initialData: { status: 'online' }, * onConnectionStatusChange: (change) => console.log('Connection:', change.current), * onDiscontinuity: (error) => console.error('Discontinuity:', error) * }); * * return <div>Present: {myPresenceState.present}</div>; * }; * ``` * @example * ```tsx * // Example with full manual control (no auto-enter/leave) * const ManualPresenceComponent = () => { * const { enter, leave, update, myPresenceState } = usePresence({ * autoEnterLeave: false, * initialData: { status: 'available' } * }); * * const handleJoin = () => enter({ status: 'online' }); * const handleLeave = () => leave(); * const handleUpdateStatus = () => update({ status: 'busy' }); * * return ( * <div> * <button onClick={handleJoin}>Join</button> * <button onClick={handleLeave}>Leave</button> * <button onClick={handleUpdateStatus}>Update Status</button> * <div>Present: {myPresenceState.present}</div> * </div> * ); * }; * ``` * @example * ```tsx * // Example with auto-enter but taking manual control via leave. * // This pattern is useful if you have multiple components in your app updating presence data. * const MixedControlComponent = () => { * const { leave, update, myPresenceState } = usePresence({ * initialData: { status: 'online' } * }); * * const handleGoOffline = () => { * // Calling leave() prevents auto re-entry until enter() or update() is called * leave({ status: 'offline' }); * }; * * const handleUpdatePresence = () => { * // Calling update() re-enables auto-enter behavior * update({ status: 'back online' }); * }; * * return ( * <div> * <button onClick={handleGoOffline}>Go Offline</button> * <button onClick={handleUpdatePresence}>Update Presence</button> * <div>Present: {myPresenceState.present}</div> * </div> * ); * }; * ``` * @example * ```tsx * // Example with manual mount/unmount behavior using enter/leave explicitly * const ManualMountComponent = () => { * const { enter, leave, myPresenceState } = usePresence({ * autoEnterLeave: false, * initialData: { status: 'ready' } * }); * * // Manual mount behavior - enter presence when component mounts * useEffect(() => { * enter({ status: 'active' }); * * // Manual unmount behavior - leave presence when component unmounts * return () => { * leave({ status: 'disconnecting' }); * }; * }, [enter, leave]); * * return <div>Present: {myPresenceState.present}</div>; * }; * ``` * @param params - Configuration options for the hook behavior and optional callbacks. * @returns UsePresenceResponse - An object containing the {@link Presence} instance and methods to interact with it. */ export const usePresence = (params?: UsePresenceParams): UsePresenceResponse => { const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); const context = useRoomContext('usePresence'); const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useRoomLogger(); logger.trace('usePresence();', { params }); // Default to true for autoEnterLeave if not provided const shouldAutoEnterLeave = useMemo(() => params?.autoEnterLeave !== false, [params?.autoEnterLeave]); const [myPresenceState, setMyPresenceState] = useState<{ present: boolean; error?: Ably.ErrorInfo; }>({ present: false, error: undefined, }); // store the roomStatus in a ref to ensure the correct value is used in the effect cleanup const roomStatusAndConnectionStatusRef = useRef({ roomStatus, connectionStatus }); // create a stable reference for the onDiscontinuity listener const onDiscontinuityRef = useEventListenerRef(params?.onDiscontinuity); // Track the latest presence data - set initialData once on first render, then updated by manual calls const latestDataRef = useRef<PresenceData>(params?.initialData); // Track if leave() has been explicitly called - prevents auto re-enter const hasExplicitlyLeftRef = useRef<boolean>(false); // Track if we've ever successfully auto-entered (for first-time logic) const hasAutoEnteredRef = useRef<boolean>(false); // Track if room has been detached since last auto-enter (for recovery logic) const roomWasDetachedRef = useRef<boolean>(false); // If the context changes, then we'll assume auto-enter is required. useEffect(() => { hasAutoEnteredRef.current = false; roomWasDetachedRef.current = false; }, [context]); // Keep track of the room and connection statuses useEffect(() => { roomStatusAndConnectionStatusRef.current = { roomStatus, connectionStatus }; // keep track of the room becoming detached if (roomStatus === RoomStatus.Detached) { roomWasDetachedRef.current = true; } }, [roomStatus, connectionStatus]); // Subscribe to presence state changes useEffect(() => { logger.debug('usePresence(); subscribing to presence state changes'); return wrapRoomPromise( context.room, (room: Room) => { // Subscribe to presence state changes const subscription = (room.presence as PresenceWithStateChangeListener).onPresenceStateChange( (stateChange: PresenceStateChange) => { logger.debug('usePresence(); presence state changed', { stateChange }); setMyPresenceState({ ...stateChange.current, error: stateChange.error, }); }, ); return () => { logger.debug('usePresence(); unsubscribing from presence state changes'); subscription.unsubscribe(); }; }, logger, ).unmount(); }, [context, logger]); // enter the room when the hook is mounted (if autoEnterLeave is enabled) useEffect(() => { logger.debug('usePresence(); running auto-enter hook'); if (!shouldAutoEnterLeave) { logger.debug('usePresence(); auto enter/leave disabled'); return () => { // no-op }; } return wrapRoomPromise( context.room, (room: Room) => { const canJoinPresence = room.status === RoomStatus.Attached && !INACTIVE_CONNECTION_STATES.has(connectionStatus); // Check if we should auto-enter: first time OR room was previously detached const shouldAutoEnter = !hasAutoEnteredRef.current || roomWasDetachedRef.current; // wait until the room is attached before attempting to enter, and ensure the connection is active // also check if we haven't explicitly left presence and if we should auto-enter if (!canJoinPresence || hasExplicitlyLeftRef.current || !shouldAutoEnter) { logger.debug('usePresence(); skipping enter room', { roomStatus, connectionStatus, hasExplicitlyLeft: hasExplicitlyLeftRef.current, shouldAutoEnter, hasAutoEntered: hasAutoEnteredRef.current, roomWasDetached: roomWasDetachedRef.current, }); return () => { // no-op }; } // Enter the room using latest data - state updates are handled by presence.ts logger.debug('usePresence(); entering room'); room.presence .enter(latestDataRef.current) .then(() => { logger.debug('usePresence(); entered room'); // Mark that we've successfully auto-entered and reset the detachment flag hasAutoEnteredRef.current = true; roomWasDetachedRef.current = false; }) .catch((error: unknown) => { logger.error('usePresence(); error entering room', { error }); }); return () => { const canLeavePresence = room.status === RoomStatus.Attached && !INACTIVE_CONNECTION_STATES.has(roomStatusAndConnectionStatusRef.current.connectionStatus); logger.debug('usePresence(); unmounting', { canLeavePresence, roomStatus, connectionStatus, }); if (canLeavePresence && !hasExplicitlyLeftRef.current) { // Only auto-leave if we haven't already explicitly left // Leave the room - state updates are handled by presence.ts room.presence .leave() .then(() => { logger.debug('usePresence(); left room'); }) .catch((error: unknown) => { logger.error('usePresence(); error leaving room', { error }); }); } }; }, logger, ).unmount(); }, [context, connectionStatus, roomStatus, logger, shouldAutoEnterLeave]); // if provided, subscribes the user provided onDiscontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; return wrapRoomPromise( context.room, (room: Room) => { const { off } = room.onDiscontinuity(onDiscontinuityRef); return () => { logger.debug('usePresence(); removing onDiscontinuity listener'); off(); }; }, logger, ).unmount(); }, [context, onDiscontinuityRef, logger]); // memoize the methods to avoid re-renders and ensure the same instance is used const update = useCallback( async (data?: PresenceData) => { latestDataRef.current = data; // Reset the explicit leave flag when update is called explicitly hasExplicitlyLeftRef.current = false; const room = await context.room; await room.presence.update(data); }, [context], ); const enter = useCallback( async (data?: PresenceData) => { latestDataRef.current = data; // Reset the explicit leave flag when enter is called explicitly hasExplicitlyLeftRef.current = false; const room = await context.room; await room.presence.enter(data); }, [context], ); const leave = useCallback( async (data?: PresenceData) => { // Mark that leave has been explicitly called hasExplicitlyLeftRef.current = true; const room = await context.room; await room.presence.leave(data); }, [context], ); return { connectionStatus, connectionError, roomStatus, roomError, update, enter, leave, myPresenceState, }; };