+ {messages.length} {messages.length === 1 ? "message" : "messages"} + + } + > + + + {messages.length === 0 ? ( + + No chat messages yet. + + ) : ( + + {messages.map((message) => ( + + + + {shortAddress(message.authorAddress)} + + + {formatDateTime(message.createdAt)} + + + + {message.body} + + + ))} + + )} + + + {canPost ? ( + + setBody(event.target.value)} + placeholder="Write a message" + aria-label="Initiative chat message" + rows={2} + className="max-h-40 min-h-20 resize-y" + /> + - - - {shortAddress(message.authorAddress)} - - - {formatDateTime(message.createdAt)} - - - - {message.body} - - - )) - )} + Send + + + ) : null} - - {canPost ? ( - - setBody(event.target.value)} - placeholder="Write a message" - aria-label="Initiative chat message" - /> - - Send - - - ) : null} {error ? {error} : null} ); diff --git a/tests/unit/initiative-chat-layout-contract.test.ts b/tests/unit/initiative-chat-layout-contract.test.ts new file mode 100644 index 0000000..6a78f84 --- /dev/null +++ b/tests/unit/initiative-chat-layout-contract.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "@rstest/core"; + +const chat = readFileSync( + join( + process.cwd(), + "src/pages/initiatives/components/InitiativeChatSection.tsx", + ), + "utf8", +); +const initiative = readFileSync( + join(process.cwd(), "src/pages/initiatives/Initiative.tsx"), + "utf8", +); + +test("Initiative chat is bounded, scroll-aware, and rendered last", () => { + assert.match(chat, /h-\[clamp\(18rem,52vh,30rem\)\]/); + assert.match(chat, /overflow-y-auto/); + assert.match(chat, /overscroll-contain/); + assert.match(chat, /messageViewportRef/); + assert.match(chat, /stickToBottomRef/); + assert.match(chat, / + initiative.lastIndexOf(" + initiative.lastIndexOf(" Date: Wed, 1 Jul 2026 18:28:48 +0400 Subject: [PATCH 9/9] Add moderated Initiative membership --- .../useActionRunner.ts} | 26 +++--- src/lib/api/initiatives.ts | 80 +++++++++++++++++ src/lib/initiativeUi.ts | 31 +++++++ src/pages/factions/Faction.tsx | 4 +- src/pages/initiatives/Initiative.tsx | 88 +++++++++++++++++-- src/pages/initiatives/InitiativeCreate.tsx | 28 ++++++ .../initiatives/components/InitiativeCard.tsx | 5 ++ .../InitiativeJoinRequestsSection.tsx | 71 +++++++++++++++ .../components/InitiativeSettingsSection.tsx | 24 ++++- src/types/api.ts | 16 ++++ tests/api/api-client-runtime.test.js | 52 +++++++++++ ...tiative-membership-layout-contract.test.ts | 27 ++++++ tests/unit/initiative-ui.test.ts | 60 +++++++++++++ tests/unit/workspace-header-contract.test.ts | 5 +- 14 files changed, 493 insertions(+), 24 deletions(-) rename src/{pages/factions/hooks/useFactionActionRunner.ts => hooks/useActionRunner.ts} (51%) create mode 100644 src/pages/initiatives/components/InitiativeJoinRequestsSection.tsx create mode 100644 tests/unit/initiative-membership-layout-contract.test.ts diff --git a/src/pages/factions/hooks/useFactionActionRunner.ts b/src/hooks/useActionRunner.ts similarity index 51% rename from src/pages/factions/hooks/useFactionActionRunner.ts rename to src/hooks/useActionRunner.ts index 173e5b3..1bd6e2e 100644 --- a/src/pages/factions/hooks/useFactionActionRunner.ts +++ b/src/hooks/useActionRunner.ts @@ -1,34 +1,32 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { getApiErrorPayload } from "@/lib/apiClient"; -type UseFactionActionRunnerInput = { +type UseActionRunnerInput = { reload: () => Promise; }; -export function useFactionActionRunner({ - reload, -}: UseFactionActionRunnerInput) { +export function useActionRunner({ reload }: UseActionRunnerInput) { const [actionError, setActionError] = useState(null); const [mutating, setMutating] = useState(false); - - const setCommandError = (error: unknown) => { - const payload = getApiErrorPayload(error); - const message = - payload?.error?.message ?? - (error instanceof Error ? error.message : "Action failed"); - setActionError(message); - }; + const actionInFlight = useRef(false); const runAction = async (fn: () => Promise) => { + if (actionInFlight.current) return; + actionInFlight.current = true; setActionError(null); setMutating(true); try { await fn(); await reload(); } catch (error) { - setCommandError(error); + const payload = getApiErrorPayload(error); + setActionError( + payload?.error?.message ?? + (error instanceof Error ? error.message : "Action failed"), + ); } finally { + actionInFlight.current = false; setMutating(false); } }; diff --git a/src/lib/api/initiatives.ts b/src/lib/api/initiatives.ts index 138a8a4..670d9d4 100644 --- a/src/lib/api/initiatives.ts +++ b/src/lib/api/initiatives.ts @@ -5,6 +5,7 @@ import type { InitiativeRoleDto, InitiativeStatusDto, InitiativeThreadStatusDto, + InitiativeVisibilityDto, } from "@/types/api"; import { apiCommand } from "./command"; import { apiGet } from "./http"; @@ -23,6 +24,7 @@ export async function apiInitiativeCreate(input: { title: string; summary: string; description?: string; + visibility?: InitiativeVisibilityDto; tags?: string[]; idempotencyKey?: string; }): Promise<{ @@ -38,6 +40,7 @@ export async function apiInitiativeCreate(input: { ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), }, idempotencyKey: input.idempotencyKey, @@ -49,6 +52,7 @@ export async function apiInitiativeUpdate(input: { title?: string; summary?: string; description?: string; + visibility?: InitiativeVisibilityDto; status?: InitiativeStatusDto; tags?: string[]; idempotencyKey?: string; @@ -67,6 +71,7 @@ export async function apiInitiativeUpdate(input: { ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), ...(input.status ? { status: input.status } : {}), ...(input.tags ? { tags: input.tags } : {}), }, @@ -74,6 +79,81 @@ export async function apiInitiativeUpdate(input: { }); } +export async function apiInitiativeJoin(input: { + initiativeId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join"; + initiativeId: string; + joined: boolean; + pending: boolean; +}> { + return await apiCommand({ + type: "initiative.join", + payload: { initiativeId: input.initiativeId }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeJoinRequestApprove(input: { + initiativeId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join.request.approve"; + initiativeId: string; + address: string; + accepted: boolean; +}> { + return await apiCommand({ + type: "initiative.join.request.approve", + payload: { + initiativeId: input.initiativeId, + address: input.address, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeJoinRequestDecline(input: { + initiativeId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join.request.decline"; + initiativeId: string; + address: string; + accepted: boolean; +}> { + return await apiCommand({ + type: "initiative.join.request.decline", + payload: { + initiativeId: input.initiativeId, + address: input.address, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeLeave(input: { + initiativeId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.leave"; + initiativeId: string; + left: boolean; +}> { + return await apiCommand({ + type: "initiative.leave", + payload: { initiativeId: input.initiativeId }, + idempotencyKey: input.idempotencyKey, + }); +} + export async function apiInitiativeMemberRoleSet(input: { initiativeId: string; address: string; diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index 3da4668..a51e459 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -17,6 +17,20 @@ type InitiativeManageability = { viewerCanSteward?: boolean; }; +type InitiativeAuthState = { + enabled: boolean; + loading: boolean; + authenticated: boolean; + eligible: boolean; +}; + +type InitiativeViewerState = InitiativeManageability & { + viewerRole?: InitiativeRoleDto | null; + viewerCanAdmin?: boolean; + viewerCanJoin?: boolean; + viewerCanLeave?: boolean; +}; + export const initiativeRoleLabel: Record = { admin: "Admin", steward: "Steward", @@ -98,6 +112,23 @@ export function canManageInitiative( return initiative.status === "active" && Boolean(initiative.viewerCanSteward); } +export function getInitiativeViewerCapabilities( + initiative: InitiativeViewerState, + auth: InitiativeAuthState, +) { + const canAct = + !auth.enabled || (auth.authenticated && auth.eligible && !auth.loading); + const isOperational = initiative.status === "active"; + + return { + canAdmin: canAct && Boolean(initiative.viewerCanAdmin), + canJoin: canAct && Boolean(initiative.viewerCanJoin), + canLeave: canAct && Boolean(initiative.viewerCanLeave), + canManage: canAct && canManageInitiative(initiative), + canParticipate: canAct && isOperational && initiative.viewerRole != null, + }; +} + export function initiativeSummaryPreview(value: string, maxLength = 150) { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) return normalized; diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx index b74b456..b012b05 100644 --- a/src/pages/factions/Faction.tsx +++ b/src/pages/factions/Faction.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/apiClient"; import { formatLoadError } from "@/lib/errorFormatting"; import { getFactionViewerPermissions } from "@/lib/factionUi"; +import { useActionRunner } from "@/hooks/useActionRunner"; import { FactionChannelsSection } from "./components/FactionChannelsSection"; import { FactionEditCard } from "./components/FactionEditCard"; import { FactionHero } from "./components/FactionHero"; @@ -24,7 +25,6 @@ import { FactionInitiativesSection } from "./components/FactionInitiativesSectio import { FactionMembersSection } from "./components/FactionMembersSection"; import { FactionModerationQueues } from "./components/FactionModerationQueues"; import { useFactionChannelDraft } from "./hooks/useFactionChannelDraft"; -import { useFactionActionRunner } from "./hooks/useFactionActionRunner"; import { useFactionEditForm } from "./hooks/useFactionEditForm"; import { useFactionLegacyThreadRedirect } from "./hooks/useFactionLegacyThreadRedirect"; import { useFactionPageData } from "./hooks/useFactionPageData"; @@ -41,7 +41,7 @@ const Faction: React.FC = () => { const channelDraft = useFactionChannelDraft(); const editForm = useFactionEditForm(faction); - const { actionError, mutating, runAction } = useFactionActionRunner({ + const { actionError, mutating, runAction } = useActionRunner({ reload: reloadFaction, }); diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 079e580..90fc0b1 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router"; +import { useAuth } from "@/app/auth/AuthContext"; import { Chip } from "@/components/Chip"; import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; @@ -10,11 +11,18 @@ import { WorkspaceHeader, WorkspaceHeaderAction, } from "@/components/WorkspaceHeader"; +import { useActionRunner } from "@/hooks/useActionRunner"; +import { + apiInitiativeJoin, + apiInitiativeJoinRequestApprove, + apiInitiativeJoinRequestDecline, + apiInitiativeLeave, +} from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { - canManageInitiative, defaultInitiativeBoardColumns, + getInitiativeViewerCapabilities, initiativeBoardCardCreatePath, initiativeDescriptionParagraphs, initiativeDistinctDescription, @@ -25,6 +33,7 @@ import { import { shortAddress } from "@/lib/profileUi"; import { InitiativeBoardSection } from "./components/InitiativeBoardSection"; import { InitiativeChatSection } from "./components/InitiativeChatSection"; +import { InitiativeJoinRequestsSection } from "./components/InitiativeJoinRequestsSection"; import { InitiativeMembersSection } from "./components/InitiativeMembersSection"; import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection"; import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; @@ -34,8 +43,10 @@ import { useInitiativePageData } from "./hooks/useInitiativePageData"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const auth = useAuth(); const { initiative, loadError, reload } = useInitiativePageData(id); const [editing, setEditing] = useState(false); + const { actionError, mutating, runAction } = useActionRunner({ reload }); const boardColumns = useMemo(() => { if (!initiative) return []; @@ -71,9 +82,14 @@ const Initiative: React.FC = () => { const proposals = initiative.proposals ?? []; const chatMessages = initiative.chatMessages ?? []; const isOperational = initiative.status === "active"; - const canManage = canManageInitiative(initiative); - const canAdmin = Boolean(initiative.viewerCanAdmin); - const canParticipate = isOperational && initiative.viewerRole != null; + const { + canAdmin, + canJoin: viewerCanJoin, + canLeave: viewerCanLeave, + canManage, + canParticipate, + } = getInitiativeViewerCapabilities(initiative, auth); + const pendingJoinRequest = initiative.viewerJoinRequest?.status === "pending"; const viewerRole = initiative.viewerRole ? initiativeRoleLabel[initiative.viewerRole] : "Observer"; @@ -90,8 +106,40 @@ const Initiative: React.FC = () => { summary={initiative.summary} details={descriptionParagraphs} actions={ - canAdmin || canManage ? ( + canAdmin || + canManage || + viewerCanJoin || + viewerCanLeave || + pendingJoinRequest ? ( <> + {viewerCanJoin || pendingJoinRequest ? ( + + void runAction(async () => { + await apiInitiativeJoin({ initiativeId: initiative.id }); + }) + } + > + {pendingJoinRequest + ? "Request pending" + : initiative.visibility === "private" + ? "Request to join" + : "Join initiative"} + + ) : null} + {viewerCanLeave ? ( + + void runAction(async () => { + await apiInitiativeLeave({ initiativeId: initiative.id }); + }) + } + > + Leave initiative + + ) : null} {canAdmin ? ( setEditing(true)}> Edit initiative @@ -116,6 +164,11 @@ const Initiative: React.FC = () => { {initiativeStatusLabel[initiative.status]} + + {initiative.visibility === "public" ? "Public" : "Private"} + {viewerRole} {initiative.tags.length > 0 ? ( { } /> + {actionError ? ( + {actionError} + ) : null} + @@ -152,6 +209,27 @@ const Initiative: React.FC = () => { onClose={() => setEditing(false)} /> ) : null} + + void runAction(async () => { + await apiInitiativeJoinRequestApprove({ + initiativeId: initiative.id, + address, + }); + }) + } + onDecline={(address) => + void runAction(async () => { + await apiInitiativeJoinRequestDecline({ + initiativeId: initiative.id, + address, + }); + }) + } + /> { const auth = useAuth(); @@ -19,6 +21,8 @@ const InitiativeCreate: React.FC = () => { const [summary, setSummary] = useState(""); const [description, setDescription] = useState(""); const [tags, setTags] = useState(""); + const [visibility, setVisibility] = + useState("public"); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -36,6 +40,7 @@ const InitiativeCreate: React.FC = () => { summary: trimmedSummary, description: description.trim(), tags: parseInitiativeTags(tags), + visibility, }); navigate(initiativePath(res.initiative)); } catch (error) { @@ -85,6 +90,29 @@ const InitiativeCreate: React.FC = () => { />
+ + + {shortAddress(message.authorAddress)} + + + {formatDateTime(message.createdAt)} + + + + {message.body} + +
+ + + + Requested {formatDateTime(request.requestedAt)} + + + + onApprove(request.address)} + > + Accept + + onDecline(request.address)} + > + Decline + + +