import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentPath } from "@chopin/protocol/document-url"; import { advanceDecisionView, countUnanswered, cursor, Decisions, Face, PlanEditor, QuestionnaireStore, ThreadStore, useHasPlanContent, useQuestionnaires, visibleDecisionView, } from "@chopin/editor"; import { Chat } from "./chat/chat"; import { rememberChannel } from "./channel-recovery"; import { decisionAttention, DecisionViewControl } from "./decision-view-control"; import { DocumentPicker } from "./document-picker"; import * as Api from "./api"; import { peopleHere } from "./presence"; import { RepositoryPicker } from "./repository-picker"; import { Wire } from "./wire"; import { HEADING, useWorkspaceMode, useWorkspaceState, Workspace } from "./workspace"; import { presentWorkspace } from "./workspace-model"; import type { Session } from "@chopin/protocol"; import type { DecisionView, DecisionViewState } from "@chopin/editor"; import type { HostedWorkspaceProps } from "./hosted"; import type { Status } from "./wire"; function Header( { canEdit, members, label, onRename, repository, room, userId, }: { canEdit: boolean; members: Session.Member[]; label: string; onRename: (channel: Pick) => void; repository: Api.Repository; room: string; userId: string; }, ) { let people = peopleHere(members); return (
chopin
); } export function RoomWorkspace( { agent = true, canEdit = true, handle, label, repository, room, slug, updatedAt, userId, }: HostedWorkspaceProps, ) { let [wire, setWire] = useState(); let [status, setStatus] = useState("connecting"); let [members, setMembers] = useState([]); let [effectiveCanEdit, setEffectiveCanEdit] = useState(canEdit); let [metadata, setMetadata] = useState({ title: label, slug, updatedAt }); let metadataRef = useRef(metadata); let user = useMemo(() => cursor(handle), [handle]); let mode = useWorkspaceMode(); let [workspace, dispatch] = useWorkspaceState(); let [questions] = useState(() => new QuestionnaireStore()); let [threads] = useState(() => new ThreadStore()); let [reveal, setReveal] = useState<{ widget: string; token: number }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); let unanswered = countUnanswered(entries); let hasPlanContent = useHasPlanContent(questions); let [decisionView, setDecisionView] = useState(() => { let stored = localStorage.getItem("chopin:view:document"); return { phase: "initial", preferred: stored === "decisions" ? "decisions" : "plan", }; }); let view = visibleDecisionView(decisionView, hasPlanContent, unanswered); let previousUnanswered = useRef(unanswered); let [attention, setAttention] = useState(false); let workspacePresentation = presentWorkspace(workspace, mode, view); let conversationActive = workspacePresentation.conversationVisible; let [conversationActivity, setConversationActivity] = useState({ unread: 0, busy: false }); let onConversationActivity = useCallback( (event: { type: "message" | "working"; busy: boolean }) => { setConversationActivity(current => ({ busy: event.busy, unread: event.type === "message" && !conversationActive ? current.unread + 1 : current.unread, })); }, [conversationActive], ); let updateMetadata = useCallback((next: { title: string; slug: string; updatedAt: string }) => { if (Date.parse(next.updatedAt) <= Date.parse(metadataRef.current.updatedAt)) return; metadataRef.current = next; setMetadata(next); rememberChannel(userId, { id: room, title: next.title, slug: next.slug }, repository); let path = documentPath(repository.owner, repository.name, next.slug); if (location.pathname !== path) { history.replaceState(null, "", `${path}${location.search}${location.hash}`); } }, [repository, room, userId]); useEffect(() => { setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); }, [hasPlanContent, unanswered]); useEffect(() => { if (!conversationActive) return; setConversationActivity(current => current.unread === 0 ? current : { ...current, unread: 0 }); }, [conversationActive]); useEffect(() => { let previous = previousUnanswered.current; previousUnanswered.current = unanswered; if (!decisionAttention(previous, unanswered)) return; setAttention(true); let timer = window.setTimeout(() => setAttention(false), 200); return () => window.clearTimeout(timer); }, [unanswered]); let selectView = (next: DecisionView, revealFirst = true) => { setDecisionView(state => ({ ...state, preferred: next })); if (hasPlanContent) localStorage.setItem("chopin:view:document", next); if (next === "decisions" && revealFirst) { let first = entries.find(entry => entry.value.questions.some(question => question.answer === undefined) ); setReveal({ widget: first?.id ?? "", token: Date.now() }); } }; let selectDestination = (destination: "plan" | "decisions") => { selectView(destination, mode === "split"); dispatch({ type: "set-conversation", open: false }); }; let setDesktopConversationOpen = (open: boolean) => { dispatch({ type: "set-desktop-conversation", open }); if (!open) dispatch({ type: "set-conversation", open: false }); }; let showPlan = (widget: string, question: string) => { selectDestination("plan"); requestAnimationFrame(() => { questions.reveal(widget, question); let card = document.querySelector( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${ CSS.escape(widget) }"]`, ); if (!card) return; card.tabIndex = -1; card.focus(); }); }; useEffect(() => { setEffectiveCanEdit(canEdit); }, [canEdit, room]); useEffect(() => { let next = { title: label, slug, updatedAt }; metadataRef.current = next; setMetadata(next); }, [label, room, slug, updatedAt]); useEffect(() => { let socket = new Wire({ channelId: room, onAuthenticationRequired: () => location.reload(), onStatus: next => { setStatus(next); }, }); setWire(socket); let off = [ socket.on("session:hello", frame => { setMembers(frame.members); setEffectiveCanEdit(frame.canEdit); updateMetadata(frame); }), socket.on("session:channel", frame => { if (frame.channelId === room) updateMetadata(frame); }), socket.on("session:presence", frame => setMembers(frame.members)), socket.on("session:access", frame => setEffectiveCanEdit(frame.canEdit)), threads.listen(socket), ]; return () => { for (let unsubscribe of off) unsubscribe(); socket.dispose(); setWire(undefined); }; }, [room, handle, threads, updateMetadata]); return ( } conversationActivity={conversationActivity} header={
} controls={ } mode={mode} onDesktopConversationOpen={setDesktopConversationOpen} onConversationOpen={open => dispatch({ type: "set-conversation", open })} onDestination={selectDestination} decisions={ } plan={ } state={workspace} unanswered={unanswered} view={view} /> ); }