snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

useEditLock.js (3937B)


      1 import { useCallback, useEffect, useRef, useState } from 'react';
      2 import {
      3   acquireEditLock,
      4   ApiError,
      5   refreshEditLock,
      6   releaseEditLock,
      7 } from '../lib/api.js';
      8 import { getOrCreateClientId } from '../lib/clientId.js';
      9 
     10 const REFRESH_INTERVAL_MS = 30 * 1000;
     11 
     12 export function useEditLock(editToken, enabled = true) {
     13   const [lockState, setLockState] = useState({
     14     status: 'idle',
     15     lockToken: null,
     16     lockExpiresAt: null,
     17     blockedExpiresAt: null,
     18   });
     19   const clientIdRef = useRef(getOrCreateClientId());
     20   const lockTokenRef = useRef(null);
     21 
     22   const acquire = useCallback(async () => {
     23     if (!editToken || !enabled) return;
     24     setLockState((s) => ({ ...s, status: 'acquiring' }));
     25 
     26     try {
     27       const result = await acquireEditLock(editToken, clientIdRef.current);
     28       lockTokenRef.current = result.lockToken;
     29       setLockState({
     30         status: 'held',
     31         lockToken: result.lockToken,
     32         lockExpiresAt: result.expiresAt,
     33         blockedExpiresAt: null,
     34       });
     35       return { locked: true, lockToken: result.lockToken };
     36     } catch (error) {
     37       if (error instanceof ApiError && error.status === 423) {
     38         setLockState({
     39           status: 'blocked',
     40           lockToken: null,
     41           lockExpiresAt: null,
     42           blockedExpiresAt: error.payload?.lockExpiresAt ?? null,
     43         });
     44         return { locked: false, lockExpiresAt: error.payload?.lockExpiresAt };
     45       }
     46       setLockState({
     47         status: 'error',
     48         lockToken: null,
     49         lockExpiresAt: null,
     50         blockedExpiresAt: null,
     51       });
     52       throw error;
     53     }
     54   }, [editToken, enabled]);
     55 
     56   const refresh = useCallback(async () => {
     57     if (!editToken || !lockTokenRef.current) return false;
     58     try {
     59       const result = await refreshEditLock(
     60         editToken,
     61         clientIdRef.current,
     62         lockTokenRef.current,
     63       );
     64       lockTokenRef.current = result.lockToken;
     65       setLockState((s) => ({
     66         ...s,
     67         status: 'held',
     68         lockExpiresAt: result.expiresAt,
     69       }));
     70       return true;
     71     } catch {
     72       lockTokenRef.current = null;
     73       setLockState({
     74         status: 'lost',
     75         lockToken: null,
     76         lockExpiresAt: null,
     77         blockedExpiresAt: null,
     78       });
     79       return false;
     80     }
     81   }, [editToken]);
     82 
     83   const release = useCallback(async () => {
     84     if (!editToken || !lockTokenRef.current) return;
     85     const token = lockTokenRef.current;
     86     lockTokenRef.current = null;
     87     try {
     88       await releaseEditLock(editToken, clientIdRef.current, token);
     89     } catch {
     90       /* best effort */
     91     }
     92     setLockState({
     93       status: 'released',
     94       lockToken: null,
     95       lockExpiresAt: null,
     96       blockedExpiresAt: null,
     97     });
     98   }, [editToken]);
     99 
    100   useEffect(() => {
    101     if (!enabled || lockState.status !== 'held' || !editToken) return;
    102 
    103     const interval = window.setInterval(() => {
    104       refresh();
    105     }, REFRESH_INTERVAL_MS);
    106 
    107     return () => window.clearInterval(interval);
    108   }, [enabled, editToken, lockState.status, refresh]);
    109 
    110   useEffect(() => {
    111     if (!enabled || !editToken) return;
    112 
    113     const onUnload = () => {
    114       if (!lockTokenRef.current) return;
    115       const body = JSON.stringify({
    116         clientId: clientIdRef.current,
    117         lockToken: lockTokenRef.current,
    118       });
    119       const url = `${import.meta.env.VITE_API_BASE ?? ''}/api/documents/edit/${encodeURIComponent(editToken)}/lock`;
    120       fetch(url, {
    121         method: 'DELETE',
    122         body,
    123         keepalive: true,
    124         headers: { 'Content-Type': 'application/json' },
    125       }).catch(() => {});
    126     };
    127 
    128     window.addEventListener('beforeunload', onUnload);
    129     return () => window.removeEventListener('beforeunload', onUnload);
    130   }, [enabled, editToken]);
    131 
    132   return {
    133     clientId: clientIdRef.current,
    134     lockState,
    135     acquire,
    136     refresh,
    137     release,
    138     hasLock: lockState.status === 'held' && !!lockTokenRef.current,
    139     lockToken: lockTokenRef.current,
    140   };
    141 }