wacrm/src/hooks/use-presence.ts
Arnas Donauskas 054c9e4e47
feat(team): member presence (online / away / offline) on the roster
Adds a live presence layer so the Settings → Team members roster and the
inbox Assign dropdown show who is actively using the dashboard, idle, or
gone. Implements #269.

How it works
- New `member_presence` table + `touch_presence` SECURITY DEFINER RPC
  (migration 024). Account-scoped SELECT RLS mirrors the read-only
  roster; the RPC derives account_id server-side, never client-supplied.
- The active client heartbeats 'online'/'away' every ~30s; 'offline' is
  derived by viewers from staleness, so a closed tab resolves to offline
  without an unreliable unload write.
- Pure, unit-tested derivation/formatting in src/lib/presence.ts.
- `usePresence` reads the table, subscribes to Realtime (subscribe-then-
  merge so events landing mid-fetch aren't clobbered), and re-derives
  offline on a 15s tick.
- Roster: avatar badge + tooltip + "N online · N away · N offline"
  summary, with an aria-label so AT/keyboard users get presence too.
  Inbox Assign dropdown: matching dot before each teammate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:37:29 +03:00

172 lines
5.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import type { RealtimeChannel } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import {
derivePresence,
type PresenceRow,
type PresenceStatus,
type StoredPresence,
} from "@/lib/presence";
// How often the viewer re-derives presence locally. The online→offline
// transition fires NO database event (it's just the clock passing the
// staleness threshold), so without this tick a member who closes their
// tab would appear online forever. ~15s keeps "offline" responsive
// without busy-spinning.
const RE_DERIVE_MS = 15_000;
type PresenceMap = Map<string, PresenceRow>;
interface UsePresenceResult {
/** Derived status for one member (defaults to offline if unseen). */
getPresence: (userId: string) => PresenceStatus;
/** Raw row for tooltips ("last seen …"). */
getRow: (userId: string) => PresenceRow | undefined;
/**
* The clock value the hook is currently deriving against. Pass this
* to `presenceLabel` / `formatLastSeen` so labels stay in lockstep
* with the dots (both advance on the same ~15s re-derive tick).
*/
now: number;
}
/**
* Live presence for every member of the caller's account. Reads the
* `member_presence` table (RLS-scoped to the account), subscribes to
* Realtime changes, and re-derives "offline" on a local timer.
*
* Account comes from useAuth; pass `enabled: false` to opt a consumer
* out (e.g. while a parent sheet is closed).
*/
export function usePresence(enabled = true): UsePresenceResult {
const { accountId } = useAuth();
// Presence rows keyed by user_id, held in immutable state — each
// update replaces the Map so React renders and the derived getters
// recompute. No ref/version dance needed.
const [rows, setRows] = useState<PresenceMap>(() => new Map());
// `now` ticks so derivePresence re-evaluates staleness over time.
const [now, setNow] = useState(() => Date.now());
const active = enabled && !!accountId;
useEffect(() => {
if (!active || !accountId) return;
const supabase = createClient();
let cancelled = false;
const applyRow = (row: {
user_id: string;
status: StoredPresence;
last_seen_at: string;
}) => {
setRows((prev) => {
const next = new Map(prev);
next.set(row.user_id, {
status: row.status,
last_seen_at: row.last_seen_at,
});
return next;
});
};
// Subscribe FIRST, then snapshot. The snapshot MERGES into whatever
// Realtime has already delivered (keeping the newer last_seen_at)
// rather than replacing the map — so an event that lands while the
// fetch is in flight isn't clobbered by a staler snapshot row.
const channel: RealtimeChannel = supabase
.channel(`presence:${accountId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "member_presence",
filter: `account_id=eq.${accountId}`,
},
(payload) => {
if (payload.eventType === "DELETE") {
const old = payload.old as { user_id?: string };
if (!old.user_id) return;
setRows((prev) => {
if (!prev.has(old.user_id!)) return prev;
const next = new Map(prev);
next.delete(old.user_id!);
return next;
});
return;
}
applyRow(
payload.new as {
user_id: string;
status: StoredPresence;
last_seen_at: string;
},
);
},
)
.subscribe();
supabase
.from("member_presence")
.select("user_id, status, last_seen_at")
.eq("account_id", accountId)
.then(({ data, error }) => {
if (cancelled) return;
if (error) {
console.error("[usePresence] initial fetch error:", error.message);
return;
}
setRows((prev) => {
const next = new Map(prev);
for (const r of data ?? []) {
const userId = r.user_id as string;
const incoming: PresenceRow = {
status: r.status as StoredPresence,
last_seen_at: r.last_seen_at as string,
};
const existing = next.get(userId);
// A live event that arrived first must win over a staler
// snapshot row.
if (
!existing ||
new Date(incoming.last_seen_at) >= new Date(existing.last_seen_at)
) {
next.set(userId, incoming);
}
}
return next;
});
});
const tick = setInterval(() => setNow(Date.now()), RE_DERIVE_MS);
return () => {
cancelled = true;
clearInterval(tick);
supabase.removeChannel(channel);
};
}, [active, accountId]);
const getRow = useCallback(
(userId: string): PresenceRow | undefined => rows.get(userId),
[rows],
);
const getPresence = useCallback(
(userId: string): PresenceStatus => {
const row = rows.get(userId);
return derivePresence(row?.status, row?.last_seen_at, now);
},
[rows, now],
);
return { getPresence, getRow, now };
}