Skip to content

Commit 030ada6

Browse files
committed
feat(web): in-project collaboration client layer
1 parent 5caca3c commit 030ada6

12 files changed

Lines changed: 1264 additions & 0 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.bar {
2+
display: inline-flex;
3+
align-items: center;
4+
padding-left: 4px;
5+
}
6+
7+
.avatar,
8+
.overflow {
9+
display: inline-flex;
10+
align-items: center;
11+
justify-content: center;
12+
width: 24px;
13+
height: 24px;
14+
margin-left: -6px;
15+
border-radius: 50%;
16+
font-size: 10px;
17+
font-weight: 600;
18+
line-height: 1;
19+
letter-spacing: 0.02em;
20+
color: #fff;
21+
background: var(--color-accent, #4f46e5);
22+
box-shadow: 0 0 0 2px var(--color-surface, #fff);
23+
user-select: none;
24+
}
25+
26+
.avatar[data-role='owner'] {
27+
background: #b45309;
28+
}
29+
30+
.avatar[data-role='admin'] {
31+
background: #0f766e;
32+
}
33+
34+
.overflow {
35+
background: var(--color-muted, #6b7280);
36+
font-size: 9px;
37+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { CollabPresenceMember } from './collab-client';
2+
import styles from './PresenceBar.module.css';
3+
4+
export interface PresenceBarProps {
5+
members: CollabPresenceMember[];
6+
/** Max avatars before collapsing into a "+N" chip. */
7+
max?: number;
8+
/** The viewer's own member id — excluded from the overlay. */
9+
selfMemberId?: string;
10+
}
11+
12+
function initials(member: CollabPresenceMember): string {
13+
const source = (member.name?.trim() || member.memberId).trim();
14+
const parts = source.split(/\s+/).filter(Boolean);
15+
if (parts.length >= 2) {
16+
const first = parts[0]![0] ?? '';
17+
const second = parts[1]![0] ?? '';
18+
return (first + second).toUpperCase();
19+
}
20+
return source.slice(0, 2).toUpperCase();
21+
}
22+
23+
/**
24+
* Presence overlay (presence, the spec): a compact avatar stack of the members
25+
* currently viewing the shared project. Poll-driven — the set comes from
26+
* {@link useCollab}; there are no live cursors.
27+
*/
28+
export function PresenceBar({ members, max = 5, selfMemberId }: PresenceBarProps) {
29+
const others = members.filter((m) => m.memberId !== selfMemberId);
30+
if (others.length === 0) return null;
31+
32+
const shown = others.slice(0, max);
33+
const overflow = others.length - shown.length;
34+
35+
return (
36+
<div
37+
className={styles.bar}
38+
role="group"
39+
aria-label={`${others.length} collaborator${others.length === 1 ? '' : 's'} present`}
40+
>
41+
{shown.map((member) => (
42+
<span
43+
key={member.memberId}
44+
className={styles.avatar}
45+
data-role={member.role ?? 'member'}
46+
title={member.name ?? member.memberId}
47+
>
48+
{initials(member)}
49+
</span>
50+
))}
51+
{overflow > 0 && (
52+
<span className={styles.overflow} title={`${overflow} more present`}>
53+
+{overflow}
54+
</span>
55+
)}
56+
</div>
57+
);
58+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Team collaboration client integration. Ties the daemon collab capabilities
2+
// together for a shared-project session: heartbeat presence, poll the published
3+
// head version (so a member knows when to pull), and report author-side changes
4+
// / request a publish. It is the glue the read-only collab view consumes.
5+
//
6+
// Polling-based by design (live cursors were cut; content is polled — the spec).
7+
8+
import type { CollabPresenceMember, ProjectSyncState } from '@open-design/contracts';
9+
10+
// Presence identity is the shared contract DTO; re-export so collab consumers
11+
// keep importing it from the client module.
12+
export type { CollabPresenceMember };
13+
14+
export interface CollabSnapshot {
15+
present: CollabPresenceMember[];
16+
publishedVersion: number | null;
17+
/** project sync state; null until the first status poll lands. */
18+
syncState: ProjectSyncState | null;
19+
/** The member who shared this project (its single writer); null if unshared. */
20+
ownerMemberId: string | null;
21+
}
22+
23+
export interface CollabClientOptions {
24+
projectId: string;
25+
member: CollabPresenceMember;
26+
/** Injectable for tests; defaults to the global fetch. */
27+
fetch?: typeof fetch;
28+
/** Daemon API base; default '' (same origin). */
29+
baseUrl?: string;
30+
heartbeatMs?: number;
31+
statusPollMs?: number;
32+
onUpdate?: (snapshot: CollabSnapshot) => void;
33+
onError?: (error: unknown) => void;
34+
}
35+
36+
const DEFAULT_HEARTBEAT_MS = 10_000;
37+
const DEFAULT_STATUS_POLL_MS = 5_000;
38+
39+
export class CollabClient {
40+
private readonly projectId: string;
41+
private readonly member: CollabPresenceMember;
42+
private readonly fetchImpl: typeof fetch;
43+
private readonly baseUrl: string;
44+
private readonly heartbeatMs: number;
45+
private readonly statusPollMs: number;
46+
private readonly onUpdate?: CollabClientOptions['onUpdate'];
47+
private readonly onError?: CollabClientOptions['onError'];
48+
private readonly timers: ReturnType<typeof setInterval>[] = [];
49+
private snapshot: CollabSnapshot = { present: [], publishedVersion: null, syncState: null, ownerMemberId: null };
50+
private running = false;
51+
52+
constructor(options: CollabClientOptions) {
53+
this.projectId = options.projectId;
54+
this.member = options.member;
55+
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
56+
this.baseUrl = options.baseUrl ?? '';
57+
this.heartbeatMs = Math.max(1_000, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
58+
this.statusPollMs = Math.max(1_000, options.statusPollMs ?? DEFAULT_STATUS_POLL_MS);
59+
this.onUpdate = options.onUpdate;
60+
this.onError = options.onError;
61+
}
62+
63+
getSnapshot(): CollabSnapshot {
64+
return this.snapshot;
65+
}
66+
67+
start(): void {
68+
if (this.running) return;
69+
this.running = true;
70+
void this.heartbeat();
71+
void this.pollStatus();
72+
this.timers.push(setInterval(() => void this.heartbeat(), this.heartbeatMs));
73+
this.timers.push(setInterval(() => void this.pollStatus(), this.statusPollMs));
74+
}
75+
76+
stop(): void {
77+
if (!this.running) return;
78+
this.running = false;
79+
for (const timer of this.timers) clearInterval(timer);
80+
this.timers.length = 0;
81+
void this.leave();
82+
}
83+
84+
/** The author edited a file — schedule a coalesced publish. */
85+
async reportChange(): Promise<void> {
86+
await this.post('/collab/changed');
87+
}
88+
89+
/** Run boundary — flush the pending publish now. */
90+
async requestPublish(): Promise<void> {
91+
await this.post('/collab/publish');
92+
}
93+
94+
async heartbeat(): Promise<void> {
95+
try {
96+
const body = await this.post('/presence/heartbeat', this.member);
97+
if (Array.isArray(body?.present)) this.update({ present: body.present as CollabPresenceMember[] });
98+
} catch (error) {
99+
this.onError?.(error);
100+
}
101+
}
102+
103+
async pollStatus(): Promise<void> {
104+
try {
105+
const body = await this.get('/collab/status');
106+
const version = typeof body?.publishedVersion === 'number' ? body.publishedVersion : null;
107+
const syncState = (body?.syncState as ProjectSyncState | undefined) ?? null;
108+
const ownerMemberId = typeof body?.ownerMemberId === 'string' ? body.ownerMemberId : null;
109+
this.update({ publishedVersion: version, syncState, ownerMemberId });
110+
} catch (error) {
111+
this.onError?.(error);
112+
}
113+
}
114+
115+
private async leave(): Promise<void> {
116+
try {
117+
await this.post('/presence/leave', { memberId: this.member.memberId });
118+
} catch (error) {
119+
this.onError?.(error);
120+
}
121+
}
122+
123+
/**
124+
* Best-effort leave that survives page unload. A normal fetch is aborted when
125+
* the tab closes, so a hard close would otherwise leave the member lingering
126+
* until the daemon's presence TTL sweeps it (~30s). sendBeacon (with a
127+
* keepalive-fetch fallback) hands the request to the browser to deliver after
128+
* the page is gone, so the present set drops promptly.
129+
*/
130+
leaveBeacon(): void {
131+
const url = this.url('/presence/leave');
132+
const body = JSON.stringify({ memberId: this.member.memberId });
133+
try {
134+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
135+
const blob = new Blob([body], { type: 'application/json' });
136+
if (navigator.sendBeacon(url, blob)) return;
137+
}
138+
} catch {
139+
// fall through to the keepalive fetch
140+
}
141+
void this.fetchImpl(url, {
142+
method: 'POST',
143+
headers: { 'content-type': 'application/json' },
144+
body,
145+
keepalive: true,
146+
}).catch(() => {});
147+
}
148+
149+
private update(patch: Partial<CollabSnapshot>): void {
150+
this.snapshot = { ...this.snapshot, ...patch };
151+
this.onUpdate?.(this.snapshot);
152+
}
153+
154+
private async get(path: string): Promise<Record<string, unknown> | null> {
155+
const response = await this.fetchImpl(this.url(path));
156+
if (!response.ok) throw new Error(`collab GET ${path} failed: ${response.status}`);
157+
return (await response.json()) as Record<string, unknown>;
158+
}
159+
160+
private async post(path: string, body?: unknown): Promise<Record<string, unknown> | null> {
161+
const init: RequestInit = { method: 'POST' };
162+
if (body !== undefined) {
163+
init.headers = { 'content-type': 'application/json' };
164+
init.body = JSON.stringify(body);
165+
}
166+
const response = await this.fetchImpl(this.url(path), init);
167+
if (!response.ok) throw new Error(`collab POST ${path} failed: ${response.status}`);
168+
return (await response.json()) as Record<string, unknown>;
169+
}
170+
171+
private url(path: string): string {
172+
return `${this.baseUrl}/api/projects/${encodeURIComponent(this.projectId)}${path}`;
173+
}
174+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type {
2+
CollabPresenceMember,
3+
WorkspaceCollabContext,
4+
WorkspaceLifecycleState,
5+
} from '@open-design/contracts';
6+
7+
// The the collaboration surface seam onto the B (workspace) + D (visibility) lanes. B owns the
8+
// CurrentWorkspaceContext (identity token → workspaceMemberId + role + lifecycle);
9+
// the visibility surface owns whether a workspace/project is team-shared. Collab (presence + sync)
10+
// should only run for an active member of a live team workspace. The context
11+
// shape is the shared contract DTO (a faithful subset of B's context), so wiring
12+
// B's real context in is a direct field pass-through.
13+
14+
export type { WorkspaceCollabContext };
15+
16+
export interface CollabSessionDecision {
17+
/** Whether to start the presence heartbeat + sync poll. */
18+
enabled: boolean;
19+
/** Diagnostic reason when disabled (never user-facing copy). */
20+
reason: string;
21+
/** The presence identity, when enabled. */
22+
member: CollabPresenceMember | null;
23+
}
24+
25+
// Lifecycle states in which the workspace is still functional enough to
26+
// collaborate. `locked` (frozen after expiry) / `deleting` / `deleted` are not.
27+
const LIVE_LIFECYCLE: ReadonlySet<WorkspaceLifecycleState> = new Set([
28+
'active',
29+
'billing_past_due',
30+
]);
31+
32+
/**
33+
* Decide whether collab should run for the current workspace context, and who
34+
* the present member is. Gating (in order):
35+
* - no context → off
36+
* - personal workspace → off (D: only team workspaces are collaborative)
37+
* - removed member → off
38+
* - frozen/deleting/deleted lifecycle → off
39+
* - otherwise → on, identity from workspaceMemberId
40+
*/
41+
export function resolveCollabSession(ctx: WorkspaceCollabContext | null): CollabSessionDecision {
42+
if (!ctx) return { enabled: false, reason: 'no-workspace-context', member: null };
43+
if (ctx.workspaceType !== 'team') {
44+
return { enabled: false, reason: 'personal-workspace', member: null };
45+
}
46+
if (ctx.memberStatus !== 'active') {
47+
return { enabled: false, reason: 'member-removed', member: null };
48+
}
49+
if (!LIVE_LIFECYCLE.has(ctx.lifecycleState)) {
50+
return { enabled: false, reason: `lifecycle-${ctx.lifecycleState}`, member: null };
51+
}
52+
const member: CollabPresenceMember = { memberId: ctx.workspaceMemberId, role: ctx.role };
53+
if (ctx.displayName && ctx.displayName.trim()) member.name = ctx.displayName.trim();
54+
return { enabled: true, reason: 'ok', member };
55+
}

0 commit comments

Comments
 (0)