import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { apiClient, type ChatChannel, type TeamInviteLink, type TeamMember, type TeamReceivedInvitation, type TeamSentInvitation, type TeamSummary, } from "../../../../api-client"; import { ApiRequestError } from "../../../../api-client/errors"; import { QueryBar, Tabs, loadingText, usePaneFooter, usePaneHeaderTabs, usePaneMenuItems, type PaneFooterSegment, type PaneHint } from "../../../../components"; import { useShortcut } from "../../../../react/input"; import { colors } from "../../../../theme/colors"; import type { PaneProps } from "../../../../types/plugin"; import { Box, ScrollBox, Text, TextAttributes, useRendererHost, useUiCapabilities, type BoxRenderable, type ScrollBoxRenderable, } from "../../../../ui"; import { isPlainKey } from "../../../../utils/keyboard"; import { usePluginAppActions, usePluginPaneState } from "../../../runtime"; import { chatController } from "../../chat/controller"; import { SignInWall } from "../auth-actions"; import { afterLayout, revealInScrollBox } from "../reveal-in-scroll-box"; import { useCloudUpgradeAction } from "../../shared/cloud-upgrade"; import { usePlanAccess } from "../../shared/plan-access"; import { canInviteToTeam, canManageTeam, describeExpiry, normalizeTeamChannelName, sortTeamChannels, teamAccentHex, teamChannelId, teamIdFromChannelId, teamPrefix, userHandle, } from "./model"; import { TEAM_PANE_SECTIONS, cycleAccent, describeMemberCount, draftChanges, draftFromTeam, draftProblem, emptyTeamDraft, isTextFieldId, nextFieldId, nextNonTextFieldId, restingFieldId, sectionFieldIds, type TeamDraft, } from "./pane-model"; import { TEAM_PANE_ID, consumeRequestedTeamPaneView, subscribeRequestedTeamPaneView, type TeamPaneSection, type TeamPaneView, } from "./pane-request"; import { ChannelsSection, CreateTeamForm, InvitesSection, MembersSection, SettingsSection, } from "./pane-sections"; import { Muted, PaneButton, TeamPaneFocusContext, type TeamPaneFocus } from "./pane-ui"; import { teamStore } from "./store"; type Message = { tone: "info" | "success" | "error"; text: string } | null; function errorText(error: unknown, fallback: string): string { if (error instanceof ApiRequestError) { if (error.status === 402 || (error.status === 403 && /pro|organization/i.test(error.message))) { return "Creating a team needs a Pro plan. Joining one is free."; } return error.message || fallback; } return error instanceof Error && error.message ? error.message : fallback; } interface TeamDetails { members: TeamMember[]; invitations: TeamSentInvitation[]; links: TeamInviteLink[]; loading: boolean; error: string | null; } const EMPTY_DETAILS: TeamDetails = { members: [], invitations: [], links: [], loading: false, error: null }; /** * Members, sent invitations, and invite links for the team on screen. Reloads * when the server says the team changed, so two people editing the same team * see each other's work. */ function useTeamDetails(team: TeamSummary | null) { const [details, setDetails] = useState(EMPTY_DETAILS); const teamId = team?.id ?? null; const role = team?.role ?? null; const allowMemberInvites = team?.allowMemberInvites ?? false; const generation = useRef(0); const reload = useCallback(async () => { if (!teamId || !role) { setDetails(EMPTY_DETAILS); return; } const current = ++generation.current; setDetails((previous) => ({ ...previous, loading: true, error: null })); const manage = canManageTeam(role); const canLink = manage || allowMemberInvites; try { const [membersResult, invitations, links] = await Promise.all([ apiClient.getTeamMembers(teamId), manage ? apiClient.listTeamInvitations(teamId).catch(() => []) : Promise.resolve([]), canLink ? apiClient.listTeamInviteLinks(teamId).catch(() => []) : Promise.resolve([]), ]); if (generation.current !== current) return; setDetails({ members: membersResult.members, invitations, links, loading: false, error: null }); } catch (error) { if (generation.current !== current) return; setDetails((previous) => ({ ...previous, loading: false, error: errorText(error, "Could not load the team.") })); } }, [allowMemberInvites, role, teamId]); useEffect(() => { void reload(); }, [reload]); useEffect(() => { if (!teamId) return; return teamStore.onTeamUpdated((event) => { if (event.teamId === teamId && event.change !== "deleted") void reload(); }); }, [reload, teamId]); return { details, setDetails, reload }; } function InvitationBanner({ invitation, width, busy, onAccept, onDecline, }: { invitation: TeamReceivedInvitation; width: number; busy: boolean; onAccept: () => void; onDecline: () => void; }) { const accent = teamAccentHex(invitation.team.accentColor); return ( {`${teamPrefix(invitation.team)} ${invitation.team.name}`} {`${userHandle(invitation.inviter)} invited you · ${describeMemberCount(invitation.team.memberCount)} · ${describeExpiry(invitation.expiresAt)}`} {/* In the keyboard ring, ahead of the section below. */} ); } export function TeamPane({ focused, width, height, close }: PaneProps) { const { createPaneFromTemplate, notify } = usePluginAppActions(); const rendererHost = useRendererHost(); const openUpgrade = useCloudUpgradeAction(); const plan = usePlanAccess(); const { nativePaneChrome } = useUiCapabilities(); const snapshot = useSyncExternalStore( (onChange) => teamStore.subscribe(onChange), () => teamStore.getSnapshot(), ); const signedIn = useSyncExternalStore( (onChange) => apiClient.subscribeCurrentUser(onChange), () => apiClient.isVerified(), ); const selfUserId = apiClient.getCurrentUser()?.id ?? null; const [teamId, setTeamId] = useState(null); const [section, setSection] = usePluginPaneState("section", "members"); const [creating, setCreating] = useState(false); const [draft, setDraft] = useState(() => emptyTeamDraft()); const [createDraft, setCreateDraft] = useState(() => emptyTeamDraft()); const [inviteUsername, setInviteUsername] = useState(""); const [channelName, setChannelName] = useState(""); const [busy, setBusy] = useState(null); const [message, setMessage] = useState(null); const [activeField, setActiveFieldState] = useState(null); const actions = useRef(new Map void>()); const team = useMemo(() => { if (teamId) return snapshot.teams.find((entry) => entry.id === teamId) ?? null; return snapshot.teams.find((entry) => entry.id === teamStore.getDefaultTeamId()) ?? snapshot.teams[0] ?? null; }, [snapshot.teams, teamId]); const showCreate = creating || (snapshot.loaded && snapshot.teams.length === 0); // Requests from commands and chips: which team, which section, or the form. const applyView = useCallback((view: TeamPaneView) => { if (view.mode === "create") { setCreating(true); } else { setCreating(false); if (view.teamId) setTeamId(view.teamId); } if (view.section) setSection(view.section); setMessage(null); }, []); useEffect(() => { const pending = consumeRequestedTeamPaneView(); if (pending) applyView(pending); return subscribeRequestedTeamPaneView(applyView); }, [applyView]); // The settings draft follows the team until the person starts editing. const draftTeamId = useRef(null); useEffect(() => { if (!team) return; if (draftTeamId.current !== team.id) { draftTeamId.current = team.id; setDraft(draftFromTeam(team)); } }, [team]); const dirty = team ? Object.keys(draftChanges(team, draft)).length > 0 : false; useEffect(() => { // A save elsewhere (another device, a teammate) refreshes an untouched draft. if (team && !dirty) setDraft(draftFromTeam(team)); }, [dirty, team]); const { details, setDetails, reload } = useTeamDetails(team); // The chat controller builds a fresh snapshot per call, so it is read // through a subscription and copied into state only when something changed. const [chat, setChat] = useState(() => chatController.getChannels()); const channels = useMemo( () => (team ? sortTeamChannels(chat.filter((channel) => channel.kind === "team" && teamIdFromChannelId(channel.id) === team.id)) : []), [chat, team], ); const [unreadByChannel, setUnreadByChannel] = useState>(() => new Map()); useEffect(() => chatController.subscribe((state) => { setChat((previous) => (previous === state.channels ? previous : state.channels)); setUnreadByChannel((previous) => { const next = new Map(); for (const entry of state.channelStates) { if (entry.channelId.startsWith("team:") && entry.unreadCount > 0) next.set(entry.channelId, entry.unreadCount); } if (next.size === previous.size && [...next].every(([id, count]) => previous.get(id) === count)) return previous; return next; }); }), []); useEffect(() => { if (!team) return; return teamStore.onTeamUpdated((event) => { if (event.teamId === team.id && event.change === "channels") void chatController.refreshChatState(); }); }, [team]); // Keyboard ring. const fieldIds = useMemo(() => sectionFieldIds({ section: showCreate ? "create" : section, team: showCreate ? null : team, members: details.members, invitationIds: details.invitations.map((entry) => entry.id), linkTokens: details.links.map((entry) => entry.token), channelIds: channels.map((entry) => entry.id), selfUserId, receivedInvitationIds: snapshot.invitations.map((entry) => entry.id), }), [channels, details.invitations, details.links, details.members, section, selfUserId, showCreate, snapshot.invitations, team]); useEffect(() => { if (activeField && fieldIds.includes(activeField)) return; const resting = restingFieldId(fieldIds, showCreate); if (resting !== activeField) setActiveFieldState(resting); }, [activeField, fieldIds, showCreate]); const setActiveField = useCallback((id: string) => setActiveFieldState(id), []); const register = useCallback((id: string, action: (() => void) | null) => { if (action) actions.current.set(id, action); else actions.current.delete(id); return () => { if (actions.current.get(id) === action) actions.current.delete(id); }; }, []); const nodes = useRef(new Map()); const registerNode = useCallback((id: string, node: BoxRenderable | null) => { if (node) nodes.current.set(id, node); else nodes.current.delete(id); }, []); const focus = useMemo( () => ({ activeField, setActiveField, focused, register, registerNode }), [activeField, focused, register, registerNode, setActiveField], ); // The ring can walk past the rows on screen in a larger team. The invitation // banners sit above the scrolling body, so they never move it. const bodyScrollRef = useRef(null); useEffect(() => { if (!activeField || activeField.startsWith("accept:") || activeField.startsWith("decline:")) return; return afterLayout(() => revealInScrollBox(bodyScrollRef.current, nodes.current.get(activeField) ?? null)); }, [activeField]); // Each result is a toast, and the last one stays in the footer until the // next action or view change. const report = useCallback((result: Message) => { setMessage(result); if (result) notify({ body: result.text, type: result.tone }); }, [notify]); const run = useCallback(async (key: string, work: () => Promise) => { if (busy) return; setBusy(key); setMessage(null); try { report(await work()); } catch (error) { report({ tone: "error", text: errorText(error, "That did not work.") }); } finally { setBusy(null); } }, [busy, report]); const openChannel = useCallback((channel: ChatChannel | string) => { createPaneFromTemplate("new-chat-pane", { arg: typeof channel === "string" ? channel : channel.id }); }, [createPaneFromTemplate]); const createTeam = useCallback(() => run("create", async () => { const created = await apiClient.createTeam({ name: createDraft.name.trim(), shortName: createDraft.shortName, accentColor: createDraft.accentColor, }); teamStore.upsertTeam(created); void teamStore.refresh(); void chatController.refreshChatState(); setCreating(false); setTeamId(created.id); setSection("invites"); setCreateDraft(emptyTeamDraft()); return { tone: "success", text: `${teamPrefix(created)} ${created.name} is ready. Invite people, then find its #general in chat.` }; }), [createDraft, run]); const saveSettings = useCallback(() => { if (!team) return; const changes = draftChanges(team, draft); if (Object.keys(changes).length === 0) return; void run("save", async () => { const updated = await apiClient.updateTeam(team.id, changes); teamStore.upsertTeam(updated); draftTeamId.current = null; return { tone: "success", text: "Saved." }; }); }, [draft, run, team]); const invite = useCallback(() => { if (!team) return; const username = inviteUsername.trim().replace(/^@/, ""); if (!username) return; void run("invite", async () => { const invitation = await apiClient.inviteTeamMemberByUsername(team.id, username); setInviteUsername(""); setDetails((previous) => ({ ...previous, invitations: [ { id: invitation.id, status: invitation.status, role: "member", expiresAt: invitation.expiresAt, createdAt: new Date().toISOString(), inviter: { id: selfUserId ?? "", username: apiClient.getCurrentUser()?.username ?? null, displayName: "You" }, invitee: invitation.invitee, }, ...previous.invitations.filter((entry) => entry.id !== invitation.id), ], })); return { tone: "success", text: `Invited ${userHandle(invitation.invitee)}. They have 7 days to accept.` }; }); }, [inviteUsername, run, selfUserId, setDetails, team]); const copyLink = useCallback(async (link: TeamInviteLink) => { try { await rendererHost.copyText(link.url); report({ tone: "success", text: "Invite link copied." }); } catch { report({ tone: "info", text: link.url }); } }, [rendererHost, report]); const newLink = useCallback(() => { if (!team) return; void run("link", async () => { const link = await apiClient.createTeamInviteLink(team.id); setDetails((previous) => ({ ...previous, links: [link, ...previous.links] })); try { await rendererHost.copyText(link.url); return { tone: "success", text: `Link copied: ${link.url}` }; } catch { return { tone: "info", text: link.url }; } }); }, [rendererHost, run, setDetails, team]); const createChannel = useCallback(() => { if (!team) return; const name = normalizeTeamChannelName(channelName); if (!name) return; void run("channel", async () => { const channel = await apiClient.createTeamChannel(team.id, name); setChannelName(""); await chatController.refreshChatState().catch(() => {}); return { tone: "success", text: `#${channel.name} is ready. Open it from the list.` }; }); }, [channelName, run, team]); const acceptInvitation = useCallback((invitation: TeamReceivedInvitation) => run(`accept:${invitation.id}`, async () => { const joined = await apiClient.acceptTeamInvitation(invitation.id); teamStore.removeInvitation(invitation.id); teamStore.upsertTeam(joined); void teamStore.refresh(); void chatController.refreshChatState(); setCreating(false); setTeamId(joined.id); setSection("members"); return { tone: "success", text: `You joined ${joined.name}.` }; }), [run]); const declineInvitation = useCallback((invitation: TeamReceivedInvitation) => run(`decline:${invitation.id}`, async () => { await apiClient.rejectTeamInvitation(invitation.id); teamStore.removeInvitation(invitation.id); return { tone: "info", text: `Declined ${invitation.team.name}.` }; }), [run]); // The team strip is a row of tabs no key reaches (1-4 pick the section), so // [ and ] step through teams, from the create form too. const cycleTeam = useCallback((delta: number) => { const teams = snapshot.teams; if (teams.length === 0) return; const index = showCreate ? -1 : teams.findIndex((entry) => entry.id === team?.id); const next = index < 0 ? teams[delta > 0 ? 0 : teams.length - 1] : teams[(index + delta + teams.length) % teams.length]; if (!next || (next.id === team?.id && !showCreate)) return; setCreating(false); setTeamId(next.id); setMessage(null); }, [showCreate, snapshot.teams, team?.id]); const canCycleTeams = snapshot.teams.length > (showCreate ? 0 : 1); useShortcut((event) => { const consume = () => { event.preventDefault?.(); event.stopPropagation?.(); }; if (event.ctrl && event.name === "s" && !showCreate && section === "settings") { consume(); saveSettings(); return; } if (isPlainKey(event, "escape")) { if (showCreate && snapshot.teams.length > 0) { consume(); setCreating(false); } else if (isTextFieldId(activeField)) { // Leaves the field for the control after it, so letters are keys again. consume(); setActiveFieldState(nextNonTextFieldId(fieldIds, activeField)); } return; } const tab = event.name === "tab" && !event.ctrl && !event.meta && !event.alt; if (tab) { // Tab walks the ring and, past either end, moves on to the next pane as // it does everywhere else, so the pane never traps the keyboard. const index = activeField ? fieldIds.indexOf(activeField) : -1; const next = event.shift ? (index > 0 ? fieldIds[index - 1] : undefined) : fieldIds[index + 1]; if (!next) return; consume(); setActiveFieldState(next); return; } if (!event.targetEditable && isPlainKey(event, "down", "j")) { consume(); setActiveFieldState((current) => nextFieldId(fieldIds, current, 1)); return; } if (!event.targetEditable && isPlainKey(event, "up", "k")) { consume(); setActiveFieldState((current) => nextFieldId(fieldIds, current, -1)); return; } if (activeField === "accent" && isPlainKey(event, "left", "right", "h", "l")) { consume(); const delta = event.name === "left" || event.name === "h" ? -1 : 1; if (showCreate) setCreateDraft((current) => ({ ...current, accentColor: cycleAccent(current.accentColor, delta) })); else setDraft((current) => ({ ...current, accentColor: cycleAccent(current.accentColor, delta) })); return; } if (!event.targetEditable && activeField && isPlainKey(event, "enter", "return", "space")) { const action = actions.current.get(activeField); if (action) { consume(); action(); } return; } if (!event.targetEditable && canCycleTeams && isPlainKey(event, "[", "]")) { consume(); cycleTeam(event.name === "[" ? -1 : 1); return; } if (!event.targetEditable && !showCreate && isPlainKey(event, "1", "2", "3", "4")) { const target = TEAM_PANE_SECTIONS[Number(event.name) - 1]; if (target) { consume(); setSection(target.value); } } // Scoped in "before", so the ring sees Tab ahead of the app's pane cycling. }, { allowEditable: true, phase: "before", scope: "team-pane:ring", enabled: focused }); usePaneMenuItems("team-pane:teams", () => (canCycleTeams ? [ { id: "team-previous", label: "Previous Team", accelerator: "[", onSelect: () => cycleTeam(-1) }, { id: "team-next", label: "Next Team", accelerator: "]", onSelect: () => cycleTeam(1) }, ] : null), [canCycleTeams, cycleTeam]); const hints = useMemo(() => { if (!signedIn) return []; if (showCreate) { return snapshot.teams.length > 0 ? [{ id: "back", key: "Esc", label: "back", onPress: () => setCreating(false) }] : []; } const list: PaneHint[] = [ { id: "new", key: "n", label: "ew team", onPress: () => { setCreating(true); setMessage(null); } }, ]; if (team) { list.push({ id: "chat", key: "c", label: "hat", onPress: () => openChannel(teamChannelId(team.id)) }); if (canInviteToTeam(team)) list.push({ id: "invite", key: "i", label: "nvite", onPress: () => setSection("invites") }); if (section === "settings" && canManageTeam(team.role)) { // Disabled exactly when the Save button is. list.push({ id: "save", key: "Ctrl+S", label: "save", onPress: saveSettings, disabled: !dirty || !!draftProblem(draft) }); } } return list; }, [dirty, draft, openChannel, saveSettings, section, showCreate, signedIn, snapshot.teams.length, team]); const result = message ?? (details.error ? { tone: "error" as const, text: details.error } : null); usePaneFooter(TEAM_PANE_ID, () => ({ info: [ ...(busy ? [{ id: "busy", parts: [{ text: "working", tone: "muted" as const }] }] : []), ...(details.loading && !busy ? [{ id: "loading", parts: [{ text: "syncing", tone: "muted" as const }] }] : []), ...(result && !busy ? [{ id: "result", parts: [{ text: result.text, tone: result.tone === "error" ? "negative" as const : result.tone === "success" ? "positive" as const : "muted" as const, }], } satisfies PaneFooterSegment] : []), ], hints, }), [busy, details.loading, hints, result]); const teamTabs = useMemo(() => [ ...snapshot.teams.map((entry) => ({ label: `${teamPrefix(entry)} ${entry.name}`, value: entry.id, fg: teamAccentHex(entry.accentColor), })), ...(showCreate ? [{ label: "New team", value: "__create" }] : []), ], [showCreate, snapshot.teams]); const selectTeam = useCallback((value: string) => { if (value === "__create") { setCreating(true); } else { setCreating(false); setTeamId(value); } setMessage(null); }, []); const startCreate = useCallback(() => { setCreating(true); setMessage(null); }, []); const tabsInHeader = usePaneHeaderTabs(signedIn ? { tabs: teamTabs, activeValue: showCreate ? "__create" : team?.id ?? null, onSelect: selectTeam, focused, keyboardNavigation: false, addLabel: showCreate ? undefined : "+", onAdd: showCreate ? undefined : startCreate, } : null); if (!signedIn) { return ; } const contentWidth = Math.max(24, width - 2); const banners = snapshot.invitations; const tabRows = tabsInHeader ? 0 : 1; // The section bar or the teams status line; the create form has neither. const sectionRows = showCreate ? 0 : 1; const headerRows = sectionRows + tabRows + (banners.length > 0 ? banners.length + 1 : 0); const bodyHeight = Math.max(3, height - headerRows); return ( {banners.map((invitation) => ( { void acceptInvitation(invitation); }} onDecline={() => { void declineInvitation(invitation); }} /> ))} {banners.length > 0 ? : null} {/* Team switcher: one pill per team in its accent, plus the form. */} {!tabsInHeader && ( )} {showCreate ? null : team ? ( { setSection(value); setMessage(null); }, }]} meta={team.role} /> ) : ( {snapshot.loading ? loadingText("teams") : snapshot.error ?? ""} )} {/* The desktop section bar is taller than a cell, so the body takes what is left. */} {showCreate ? ( { void createTeam(); }} onCancel={snapshot.teams.length > 0 ? () => setCreating(false) : null} onUpgrade={openUpgrade} /> ) : team && section === "members" ? ( { void run(member.id, async () => { const members = await apiClient.updateTeamMemberRole(team.id, member.id, role); setDetails((previous) => ({ ...previous, members })); return { tone: "success", text: `${userHandle(member.user)} is now ${role === "admin" ? "an admin" : "a member"}.` }; }); }} onRemove={(member) => { void run(member.id, async () => { const members = await apiClient.removeTeamMember(team.id, member.id); setDetails((previous) => ({ ...previous, members })); void teamStore.refresh(); return { tone: "info", text: `Removed ${userHandle(member.user)}.` }; }); }} /> ) : team && section === "invites" ? ( { void run(invitation.id, async () => { await apiClient.cancelTeamInvitation(team.id, invitation.id); setDetails((previous) => ({ ...previous, invitations: previous.invitations.filter((entry) => entry.id !== invitation.id) })); return { tone: "info", text: "Invitation canceled." }; }); }} onNewLink={newLink} onCopyLink={(link) => { void copyLink(link); }} onRevokeLink={(link) => { void run(link.token, async () => { await apiClient.deleteTeamInviteLink(team.id, link.token); setDetails((previous) => ({ ...previous, links: previous.links.filter((entry) => entry.token !== link.token) })); return { tone: "info", text: "Link revoked." }; }); }} /> ) : team && section === "channels" ? ( { void run(channel.id, async () => { await apiClient.deleteTeamChannel(team.id, channel.id); await chatController.refreshChatState().catch(() => {}); return { tone: "info", text: `Deleted #${channel.name}.` }; }); }} /> ) : team && section === "settings" ? ( { void run("leave", async () => { await apiClient.leaveTeam(team.id); teamStore.removeTeam(team.id); void teamStore.refresh(); void chatController.refreshChatState(); setTeamId(null); if (teamStore.getSnapshot().teams.length <= 1) close?.(); return { tone: "info", text: `You left ${team.name}.` }; }); }} onDelete={() => { void run("delete", async () => { await apiClient.deleteTeam(team.id); teamStore.removeTeam(team.id); void teamStore.refresh(); void chatController.refreshChatState(); setTeamId(null); if (teamStore.getSnapshot().teams.length <= 1) close?.(); return { tone: "info", text: `Deleted ${team.name}.` }; }); }} /> ) : null} {!showCreate && team && details.loading && details.members.length === 0 ? ( {loadingText()} ) : null} {!showCreate && !team && !snapshot.loading && snapshot.loaded ? ( You are not in a team yet. ) : null} ); }