mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #284 from ArnasDon/feat/team-member-presence
feat(team): member presence (online / away / offline) on the roster
This commit is contained in:
commit
1bacaf6372
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
|
||||||
|
|
||||||
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
||||||
// itself can stay a server component and export metadata (noindex) —
|
// itself can stay a server component and export metadata (noindex) —
|
||||||
@ -40,6 +41,9 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<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} />
|
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Header onOpenSidebar={() => setSidebarOpen(true)} />
|
<Header onOpenSidebar={() => setSidebarOpen(true)} />
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
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 { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
@ -163,6 +166,7 @@ export function MessageThread({
|
|||||||
onToggleContactPanel,
|
onToggleContactPanel,
|
||||||
}: MessageThreadProps) {
|
}: MessageThreadProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { getPresence, getRow, now } = usePresence();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||||
@ -949,6 +953,7 @@ export function MessageThread({
|
|||||||
) : (
|
) : (
|
||||||
profiles.map((p) => {
|
profiles.map((p) => {
|
||||||
const isSelected = p.user_id === assignedAgentId;
|
const isSelected = p.user_id === assignedAgentId;
|
||||||
|
const presence = getPresence(p.user_id);
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@ -958,6 +963,15 @@ export function MessageThread({
|
|||||||
isSelected ? "text-primary" : "text-popover-foreground"
|
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">
|
<span className="flex-1">
|
||||||
{p.full_name}
|
{p.full_name}
|
||||||
{p.user_id === user?.id ? " (me)" : ""}
|
{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,
|
UsersRound,
|
||||||
} from 'lucide-react';
|
} 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 { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@ -54,7 +64,13 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { RequireRole } from '@/components/auth/require-role';
|
import { RequireRole } from '@/components/auth/require-role';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { usePresence } from '@/hooks/use-presence';
|
||||||
import type { AccountRole } from '@/lib/auth/roles';
|
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 { InviteMemberDialog } from './invite-member-dialog';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
import { ROLE_META } from './role-meta';
|
import { ROLE_META } from './role-meta';
|
||||||
@ -110,6 +126,7 @@ function fmtExpiresIn(iso: string): string {
|
|||||||
|
|
||||||
export function MembersTab() {
|
export function MembersTab() {
|
||||||
const { user, canManageMembers } = useAuth();
|
const { user, canManageMembers } = useAuth();
|
||||||
|
const { getPresence, getRow, now } = usePresence();
|
||||||
|
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
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 */}
|
{/* Roster */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@ -286,6 +329,13 @@ export function MembersTab() {
|
|||||||
const isSelf = member.user_id === user?.id;
|
const isSelf = member.user_id === user?.id;
|
||||||
const isOwnerRow = member.role === 'owner';
|
const isOwnerRow = member.role === 'owner';
|
||||||
const isBusy = pendingMemberAction === member.user_id;
|
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 (
|
return (
|
||||||
<li
|
<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"
|
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">
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||||
<Avatar className="size-9 shrink-0">
|
<Tooltip>
|
||||||
{member.avatar_url ? (
|
<TooltipTrigger
|
||||||
<AvatarImage
|
render={
|
||||||
src={member.avatar_url}
|
<Avatar className="size-9 shrink-0">
|
||||||
alt={member.full_name || 'Member'}
|
{member.avatar_url ? (
|
||||||
/>
|
<AvatarImage
|
||||||
) : null}
|
src={member.avatar_url}
|
||||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
alt={member.full_name || 'Member'}
|
||||||
{(member.full_name || member.email || 'U')
|
/>
|
||||||
.charAt(0)
|
) : null}
|
||||||
.toUpperCase()}
|
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||||
</AvatarFallback>
|
{(member.full_name || member.email || 'U')
|
||||||
</Avatar>
|
.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="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<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