mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
This commit is contained in:
parent
c56ef7d5af
commit
054c9e4e47
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
|
||||
|
||||
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
||||
// itself can stay a server component and export metadata (noindex) —
|
||||
@ -40,6 +41,9 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Reports this tab's online/away presence once we know a user is
|
||||
signed in. Headless — renders nothing. */}
|
||||
<PresenceHeartbeat />
|
||||
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header onOpenSidebar={() => setSidebarOpen(true)} />
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePresence } from "@/hooks/use-presence";
|
||||
import { PresenceDot } from "@/components/presence/presence-dot";
|
||||
import { presenceLabel } from "@/lib/presence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
Conversation,
|
||||
@ -163,6 +166,7 @@ export function MessageThread({
|
||||
onToggleContactPanel,
|
||||
}: MessageThreadProps) {
|
||||
const { user } = useAuth();
|
||||
const { getPresence, getRow, now } = usePresence();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
@ -949,6 +953,7 @@ export function MessageThread({
|
||||
) : (
|
||||
profiles.map((p) => {
|
||||
const isSelected = p.user_id === assignedAgentId;
|
||||
const presence = getPresence(p.user_id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
@ -958,6 +963,15 @@ export function MessageThread({
|
||||
isSelected ? "text-primary" : "text-popover-foreground"
|
||||
)}
|
||||
>
|
||||
<PresenceDot
|
||||
status={presence}
|
||||
label={presenceLabel(
|
||||
presence,
|
||||
getRow(p.user_id)?.last_seen_at ?? null,
|
||||
now
|
||||
)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{p.full_name}
|
||||
{p.user_id === user?.id ? " (me)" : ""}
|
||||
|
||||
40
src/components/presence/presence-dot.tsx
Normal file
40
src/components/presence/presence-dot.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PresenceStatus } from "@/lib/presence";
|
||||
|
||||
// Single source of truth for presence colours. Semantic accents
|
||||
// (emerald / amber / muted), mirroring the role-chip palette already
|
||||
// used across settings, so they're intentionally not tokenized.
|
||||
export const PRESENCE_DOT_CLASS: Record<PresenceStatus, string> = {
|
||||
online: "bg-emerald-500",
|
||||
away: "bg-amber-500",
|
||||
offline: "bg-muted-foreground/50",
|
||||
};
|
||||
|
||||
/**
|
||||
* A small presence dot. Used inline (e.g. the inbox Assign dropdown)
|
||||
* and, with `asAvatarBadge`-style positioning supplied via className,
|
||||
* layered onto an avatar. `label` powers the native title/aria for
|
||||
* the lightweight inline case; the roster wraps it in a real Tooltip.
|
||||
*/
|
||||
export function PresenceDot({
|
||||
status,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
status: PresenceStatus;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"inline-block size-2 shrink-0 rounded-full",
|
||||
PRESENCE_DOT_CLASS[status],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
105
src/components/presence/presence-heartbeat.tsx
Normal file
105
src/components/presence/presence-heartbeat.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { HEARTBEAT_MS, IDLE_AFTER_MS, type StoredPresence } from "@/lib/presence";
|
||||
|
||||
/**
|
||||
* PresenceHeartbeat — headless. Mount ONCE per signed-in dashboard tab
|
||||
* (in the dashboard shell, below the auth gate). Reports this tab's
|
||||
* presence to the `member_presence` table via the `touch_presence` RPC
|
||||
* roughly every HEARTBEAT_MS.
|
||||
*
|
||||
* The client only ever reports 'online' or 'away':
|
||||
* - 'away' when the tab is hidden, or no user input for IDLE_AFTER_MS
|
||||
* - 'online' otherwise
|
||||
* It keeps heartbeating while away (so the row stays fresh, i.e. not
|
||||
* offline). When the tab closes the beats simply stop and viewers derive
|
||||
* 'offline' from staleness — no unreliable unload write needed.
|
||||
*/
|
||||
export function PresenceHeartbeat() {
|
||||
const { accountId } = useAuth();
|
||||
|
||||
// 0 = "never recorded"; set on mount so we don't read the clock during
|
||||
// render (impure). Until the effect runs the tab counts as active.
|
||||
const lastActivityRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Hold off until the account is known. Beating during the brief
|
||||
// window on a fresh signup — authed but profile/account row not yet
|
||||
// created — would make touch_presence raise "No account for caller"
|
||||
// and log a spurious error. The effect re-runs once accountId lands.
|
||||
if (!accountId) return;
|
||||
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
let lastBeatAt = 0;
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
const markActive = () => {
|
||||
lastActivityRef.current = Date.now();
|
||||
};
|
||||
|
||||
const currentStatus = (): StoredPresence => {
|
||||
if (typeof document !== "undefined" && document.hidden) return "away";
|
||||
if (Date.now() - lastActivityRef.current > IDLE_AFTER_MS) return "away";
|
||||
return "online";
|
||||
};
|
||||
|
||||
const beat = async () => {
|
||||
if (cancelled) return;
|
||||
// Coalesce bursts: a tab refocus fires visibilitychange AND focus
|
||||
// together, so skip a beat within 1s of the last to avoid two RPCs
|
||||
// in the same frame. The 30s interval is never affected.
|
||||
const t = Date.now();
|
||||
if (t - lastBeatAt < 1_000) return;
|
||||
lastBeatAt = t;
|
||||
const { error } = await supabase.rpc("touch_presence", {
|
||||
p_status: currentStatus(),
|
||||
});
|
||||
if (error && !cancelled) {
|
||||
// Non-fatal: presence is best-effort. Log once per failure so a
|
||||
// misconfigured RPC is visible without spamming.
|
||||
console.error("[PresenceHeartbeat] touch_presence failed:", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Activity listeners. `passive` so we never block scroll/input.
|
||||
const activityEvents: (keyof DocumentEventMap)[] = [
|
||||
"mousemove",
|
||||
"keydown",
|
||||
"pointerdown",
|
||||
"scroll",
|
||||
];
|
||||
activityEvents.forEach((e) =>
|
||||
document.addEventListener(e, markActive, { passive: true }),
|
||||
);
|
||||
|
||||
// Returning to the tab should beat immediately so a member flips
|
||||
// back to online without a 30s wait. The debounce in beat() absorbs
|
||||
// the visibilitychange + focus double-fire.
|
||||
const onReturn = () => {
|
||||
if (!document.hidden) markActive();
|
||||
void beat();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onReturn);
|
||||
window.addEventListener("focus", onReturn);
|
||||
|
||||
void beat();
|
||||
const interval = setInterval(() => void beat(), HEARTBEAT_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
activityEvents.forEach((e) =>
|
||||
document.removeEventListener(e, markActive),
|
||||
);
|
||||
document.removeEventListener("visibilitychange", onReturn);
|
||||
window.removeEventListener("focus", onReturn);
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -33,8 +33,18 @@ import {
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@ -54,7 +64,13 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RequireRole } from '@/components/auth/require-role';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { usePresence } from '@/hooks/use-presence';
|
||||
import type { AccountRole } from '@/lib/auth/roles';
|
||||
import { presenceLabel, summarize } from '@/lib/presence';
|
||||
import {
|
||||
PRESENCE_DOT_CLASS,
|
||||
PresenceDot,
|
||||
} from '@/components/presence/presence-dot';
|
||||
import { InviteMemberDialog } from './invite-member-dialog';
|
||||
import { SettingsPanelHead } from './settings-panel-head';
|
||||
import { ROLE_META } from './role-meta';
|
||||
@ -110,6 +126,7 @@ function fmtExpiresIn(iso: string): string {
|
||||
|
||||
export function MembersTab() {
|
||||
const { user, canManageMembers } = useAuth();
|
||||
const { getPresence, getRow, now } = usePresence();
|
||||
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
@ -276,6 +293,32 @@ export function MembersTab() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Live presence summary across the roster. Updates without a
|
||||
full refresh as heartbeats and the local re-derive tick land. */}
|
||||
{members.length > 0 &&
|
||||
(() => {
|
||||
const counts = summarize(members.map((m) => getPresence(m.user_id)));
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="online" />
|
||||
{counts.online} online
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="away" />
|
||||
{counts.away} away
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="offline" />
|
||||
{counts.offline} offline
|
||||
</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
· {members.length} member{members.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Roster */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
@ -286,6 +329,13 @@ export function MembersTab() {
|
||||
const isSelf = member.user_id === user?.id;
|
||||
const isOwnerRow = member.role === 'owner';
|
||||
const isBusy = pendingMemberAction === member.user_id;
|
||||
const presence = getPresence(member.user_id);
|
||||
const presenceRow = getRow(member.user_id);
|
||||
const presenceText = presenceLabel(
|
||||
presence,
|
||||
presenceRow?.last_seen_at ?? null,
|
||||
now,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -298,19 +348,35 @@ export function MembersTab() {
|
||||
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
{member.avatar_url ? (
|
||||
<AvatarImage
|
||||
src={member.avatar_url}
|
||||
alt={member.full_name || 'Member'}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||
{(member.full_name || member.email || 'U')
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Avatar className="size-9 shrink-0">
|
||||
{member.avatar_url ? (
|
||||
<AvatarImage
|
||||
src={member.avatar_url}
|
||||
alt={member.full_name || 'Member'}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||
{(member.full_name || member.email || 'U')
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
{/* role+label so screen readers announce
|
||||
presence — the hover tooltip alone isn't
|
||||
reachable by keyboard/AT on a non-focusable
|
||||
avatar. */}
|
||||
<AvatarBadge
|
||||
role="img"
|
||||
aria-label={presenceText}
|
||||
className={PRESENCE_DOT_CLASS[presence]}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{presenceText}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
171
src/hooks/use-presence.ts
Normal file
171
src/hooks/use-presence.ts
Normal file
@ -0,0 +1,171 @@
|
||||
"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 };
|
||||
}
|
||||
87
src/lib/presence.test.ts
Normal file
87
src/lib/presence.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
OFFLINE_AFTER_MS,
|
||||
derivePresence,
|
||||
formatLastSeen,
|
||||
presenceLabel,
|
||||
summarize,
|
||||
} from "./presence";
|
||||
|
||||
// Fixed reference clock so every case is deterministic.
|
||||
const NOW = new Date("2026-06-22T12:00:00.000Z").getTime();
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
||||
|
||||
describe("derivePresence", () => {
|
||||
it("returns the stored status for a fresh heartbeat", () => {
|
||||
expect(derivePresence("online", ago(1_000), NOW)).toBe("online");
|
||||
expect(derivePresence("away", ago(1_000), NOW)).toBe("away");
|
||||
});
|
||||
|
||||
it("reads as offline when the heartbeat is stale", () => {
|
||||
expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe(
|
||||
"offline",
|
||||
);
|
||||
// Stored 'away' goes stale to offline too (tab was closed while idle).
|
||||
expect(derivePresence("away", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe(
|
||||
"offline",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a missing row or timestamp as offline", () => {
|
||||
expect(derivePresence(undefined, null, NOW)).toBe("offline");
|
||||
expect(derivePresence("online", null, NOW)).toBe("offline");
|
||||
expect(derivePresence("online", "not-a-date", NOW)).toBe("offline");
|
||||
});
|
||||
|
||||
it("stays online exactly at the threshold and flips just past it", () => {
|
||||
expect(derivePresence("online", ago(OFFLINE_AFTER_MS), NOW)).toBe("online");
|
||||
expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1), NOW)).toBe(
|
||||
"offline",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLastSeen", () => {
|
||||
it("describes recent activity coarsely", () => {
|
||||
expect(formatLastSeen(ago(10_000), NOW)).toBe("just now");
|
||||
expect(formatLastSeen(ago(60_000), NOW)).toBe("1 minute ago");
|
||||
expect(formatLastSeen(ago(5 * 60_000), NOW)).toBe("5 minutes ago");
|
||||
});
|
||||
|
||||
it("rolls up into hours and days", () => {
|
||||
expect(formatLastSeen(ago(60 * 60_000), NOW)).toBe("1 hour ago");
|
||||
expect(formatLastSeen(ago(2 * 60 * 60_000), NOW)).toBe("2 hours ago");
|
||||
expect(formatLastSeen(ago(24 * 60 * 60_000), NOW)).toBe("1 day ago");
|
||||
expect(formatLastSeen(ago(3 * 24 * 60 * 60_000), NOW)).toBe("3 days ago");
|
||||
});
|
||||
|
||||
it("falls back gracefully on missing/invalid input", () => {
|
||||
expect(formatLastSeen(null, NOW)).toBe("a while ago");
|
||||
expect(formatLastSeen("nonsense", NOW)).toBe("a while ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presenceLabel", () => {
|
||||
it("labels each state for the tooltip", () => {
|
||||
expect(presenceLabel("online", ago(1_000), NOW)).toBe(
|
||||
"Online — active now",
|
||||
);
|
||||
expect(presenceLabel("away", ago(1_000), NOW)).toBe("Away — idle");
|
||||
expect(presenceLabel("offline", ago(2 * 60 * 60_000), NOW)).toBe(
|
||||
"Offline — last seen 2 hours ago",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarize", () => {
|
||||
it("counts each status", () => {
|
||||
expect(
|
||||
summarize(["online", "online", "online", "away", "offline"]),
|
||||
).toEqual({ online: 3, away: 1, offline: 1 });
|
||||
});
|
||||
|
||||
it("returns zeroes for an empty roster", () => {
|
||||
expect(summarize([])).toEqual({ online: 0, away: 0, offline: 0 });
|
||||
});
|
||||
});
|
||||
121
src/lib/presence.ts
Normal file
121
src/lib/presence.ts
Normal file
@ -0,0 +1,121 @@
|
||||
// ============================================================
|
||||
// Presence helpers — pure, unit-testable, no I/O.
|
||||
//
|
||||
// Mirrors the `member_presence` table from migration
|
||||
// 024_member_presence.sql. The DB stores only what the active
|
||||
// client reports ('online' / 'away'); "offline" is never stored
|
||||
// — it is derived here from staleness so a closed tab resolves to
|
||||
// offline without an unload write.
|
||||
//
|
||||
// `now` is always passed in (epoch ms) rather than read from the
|
||||
// clock, so derivation and formatting stay deterministic and
|
||||
// testable. See presence.test.ts.
|
||||
// ============================================================
|
||||
|
||||
/** How often the active client heartbeats its own presence row. */
|
||||
export const HEARTBEAT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A member whose last heartbeat is older than this is treated as
|
||||
* offline regardless of its stored status. ~2.5 missed beats, so a
|
||||
* single dropped heartbeat doesn't flap a member offline.
|
||||
*/
|
||||
export const OFFLINE_AFTER_MS = 75_000;
|
||||
|
||||
/** No input / hidden tab for this long flips the client to 'away'. */
|
||||
export const IDLE_AFTER_MS = 5 * 60_000;
|
||||
|
||||
/** What the active client reports (and what the DB stores). */
|
||||
export type StoredPresence = "online" | "away";
|
||||
|
||||
/** What a viewer sees — adds the derived 'offline' state. */
|
||||
export type PresenceStatus = "online" | "away" | "offline";
|
||||
|
||||
/** Raw presence row as read from the `member_presence` table. */
|
||||
export interface PresenceRow {
|
||||
status: StoredPresence;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the user-facing presence for a member. A missing row, or a
|
||||
* heartbeat staler than OFFLINE_AFTER_MS, reads as offline; otherwise
|
||||
* the member's last reported status (online / away) stands.
|
||||
*/
|
||||
export function derivePresence(
|
||||
stored: StoredPresence | undefined,
|
||||
lastSeenAt: string | null | undefined,
|
||||
now: number,
|
||||
): PresenceStatus {
|
||||
if (!stored || !lastSeenAt) return "offline";
|
||||
const last = new Date(lastSeenAt).getTime();
|
||||
if (Number.isNaN(last)) return "offline";
|
||||
if (now - last > OFFLINE_AFTER_MS) return "offline";
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative "last seen" string for tooltips. Coarse on purpose — the
|
||||
* issue calls for relative time only, never a precise timestamp.
|
||||
*
|
||||
* Deliberately separate from `formatRelative` in
|
||||
* src/lib/automations/trigger-meta.ts: that one reads `Date.now()`
|
||||
* internally (not injectable) and emits terse chip wording ("2h ago"),
|
||||
* whereas presence needs an injected `now` — so the dots and labels
|
||||
* advance in lockstep and the unit tests stay deterministic — plus
|
||||
* full-sentence wording for the tooltip ("Offline — last seen …").
|
||||
*/
|
||||
export function formatLastSeen(
|
||||
lastSeenAt: string | null | undefined,
|
||||
now: number,
|
||||
): string {
|
||||
if (!lastSeenAt) return "a while ago";
|
||||
const last = new Date(lastSeenAt).getTime();
|
||||
if (Number.isNaN(last)) return "a while ago";
|
||||
|
||||
const diff = Math.max(0, now - last);
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins === 1) return "1 minute ago";
|
||||
if (mins < 60) return `${mins} minutes ago`;
|
||||
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours === 1) return "1 hour ago";
|
||||
if (hours < 24) return `${hours} hours ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return "1 day ago";
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip / aria label for a presence dot, e.g.
|
||||
* "Online — active now"
|
||||
* "Away — idle"
|
||||
* "Offline — last seen 2 hours ago"
|
||||
*/
|
||||
export function presenceLabel(
|
||||
status: PresenceStatus,
|
||||
lastSeenAt: string | null | undefined,
|
||||
now: number,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "Online — active now";
|
||||
case "away":
|
||||
return "Away — idle";
|
||||
case "offline":
|
||||
return `Offline — last seen ${formatLastSeen(lastSeenAt, now)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Roster header summary, e.g. for "3 online · 1 away · 1 offline". */
|
||||
export function summarize(statuses: PresenceStatus[]): {
|
||||
online: number;
|
||||
away: number;
|
||||
offline: number;
|
||||
} {
|
||||
const counts = { online: 0, away: 0, offline: 0 };
|
||||
for (const s of statuses) counts[s] += 1;
|
||||
return counts;
|
||||
}
|
||||
101
supabase/migrations/024_member_presence.sql
Normal file
101
supabase/migrations/024_member_presence.sql
Normal file
@ -0,0 +1,101 @@
|
||||
-- ============================================================
|
||||
-- 024_member_presence.sql — team member presence (online / away)
|
||||
--
|
||||
-- Adds a lightweight presence layer so the Team members roster (and
|
||||
-- the inbox Assign dropdown) can show who is actively using the
|
||||
-- dashboard, idle, or gone. Implements wacrm#269.
|
||||
--
|
||||
-- Design
|
||||
--
|
||||
-- The active client heartbeats its own row through the
|
||||
-- `touch_presence` RPC roughly every 30s, storing only 'online'
|
||||
-- or 'away'. "Offline" is NOT stored — viewers derive it from
|
||||
-- staleness (`now() - last_seen_at` beyond a threshold), so a
|
||||
-- closed tab / logout resolves to offline automatically without
|
||||
-- relying on an unreliable unload write.
|
||||
--
|
||||
-- A dedicated table keeps the high-write heartbeat off the
|
||||
-- otherwise-stable `profiles` row and scopes Realtime cleanly.
|
||||
--
|
||||
-- Visibility
|
||||
--
|
||||
-- Any account member can read presence for their account — the
|
||||
-- same visibility as the read-only roster (`is_account_member`).
|
||||
-- Writes go ONLY through the SECURITY DEFINER RPC, which derives
|
||||
-- the account from the caller's profile (never client-supplied).
|
||||
--
|
||||
-- Idempotent — safe to run multiple times.
|
||||
-- ============================================================
|
||||
|
||||
-- ---- table -------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS member_presence (
|
||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'away')),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS member_presence_account_idx
|
||||
ON member_presence(account_id);
|
||||
|
||||
-- ---- RLS ---------------------------------------------------
|
||||
ALTER TABLE member_presence ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Account members may read every presence row for their account.
|
||||
-- No client INSERT/UPDATE/DELETE policy exists: all writes flow
|
||||
-- through touch_presence() below.
|
||||
DROP POLICY IF EXISTS member_presence_select ON member_presence;
|
||||
CREATE POLICY member_presence_select ON member_presence FOR SELECT
|
||||
USING (is_account_member(account_id));
|
||||
|
||||
-- ---- heartbeat RPC -----------------------------------------
|
||||
-- Upserts the caller's presence row. SECURITY DEFINER so it can
|
||||
-- write despite the absence of a client write policy; the account
|
||||
-- is resolved from the caller's own profile, so a client can never
|
||||
-- spoof which account it appears in.
|
||||
CREATE OR REPLACE FUNCTION public.touch_presence(
|
||||
p_status TEXT DEFAULT 'online'
|
||||
) RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_account_id UUID;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_status NOT IN ('online', 'away') THEN
|
||||
RAISE EXCEPTION 'Invalid presence status: %', p_status
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT account_id INTO v_account_id
|
||||
FROM profiles
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF v_account_id IS NULL THEN
|
||||
RAISE EXCEPTION 'No account for caller' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
INSERT INTO member_presence (user_id, account_id, status, last_seen_at)
|
||||
VALUES (auth.uid(), v_account_id, p_status, now())
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET status = excluded.status,
|
||||
last_seen_at = now(),
|
||||
account_id = excluded.account_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---- realtime ----------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND tablename = 'member_presence'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE member_presence;
|
||||
END IF;
|
||||
END $$;
|
||||
Loading…
Reference in New Issue
Block a user