diff --git a/.env.example b/.env.example index 79c34f9..f3cd181 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,11 @@ FORGEJO_TOKEN= FORGEJO_OAUTH_CLIENT_ID= FORGEJO_OAUTH_CLIENT_SECRET= FORGEJO_OAUTH_SCOPES=openid profile +FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum +FORGEJO_WEBHOOK_SECRET= FORGEJO_REPO_SCAN_LIMIT=30 -FORGEJO_RECENT_ISSUE_LIMIT=6 +FORGEJO_RECENT_ISSUE_LIMIT=50 +FORGEJO_CACHE_TTL_SECONDS=60.0 FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0 CALENDAR_FEED_URLS= CALENDAR_EVENT_LIMIT=3 diff --git a/AGENTS.md b/AGENTS.md index f4dbff8..597773a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ It is a thin application layer over Forgejo: - `app.py`: FastAPI app and SPA/static serving - `live_prototype.py`: live payload assembly for courses, lessons, discussions, and events +- `prototype_cache.py`: server-side cache for the public Forgejo content payload +- `update_events.py`: in-process SSE broker for content update notifications - `forgejo_client.py`: Forgejo API client - `calendar_feeds.py`: ICS/webcal feed loading and parsing - `settings.py`: env-driven runtime settings @@ -69,6 +71,9 @@ Useful variables: - `FORGEJO_OAUTH_CLIENT_SECRET=...` - `FORGEJO_OAUTH_SCOPES=openid profile` - `FORGEJO_TOKEN=...` +- `FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum` +- `FORGEJO_WEBHOOK_SECRET=...` +- `FORGEJO_CACHE_TTL_SECONDS=60.0` - `CALENDAR_FEED_URLS=webcal://...` - `HOST=0.0.0.0` - `PORT=8800` @@ -76,8 +81,11 @@ Useful variables: Notes: - Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL. -- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks. -- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public. +- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks for writes. +- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback for the public content cache. Browser sessions and API token calls may write issues/comments only after verifying the target repo is public. +- `/api/prototype` uses a server-side cache for public Forgejo content. `FORGEJO_CACHE_TTL_SECONDS=0` disables it; successful discussion replies invalidate it. +- General discussion creation requires `FORGEJO_GENERAL_DISCUSSION_REPO`. Linked discussions are created in the content repo and include canonical app URLs in the Forgejo issue body. +- Forgejo webhooks should POST to `/api/forgejo/webhook`; when `FORGEJO_WEBHOOK_SECRET` is set, the backend validates Forgejo/Gitea-style HMAC headers. - API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`. - `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds. - Do not commit `.env` or `.env.local`. @@ -142,6 +150,7 @@ Run both before pushing: - Each lesson folder is expected to contain one markdown file plus optional assets. - Frontmatter is used when present for `title` and `summary`. - Discussions are loaded from Forgejo issues and comments. +- Issue bodies are scanned for canonical post/lesson URLs and Forgejo file URLs to connect discussions back to content. - Calendar events are loaded from ICS feeds, not managed in-app. ## UI Expectations diff --git a/README.md b/README.md index 8d05bf9..4a6a6e4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ export FORGEJO_BASE_URL="https://aksal.cloud" export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id" export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret" export FORGEJO_OAUTH_SCOPES="openid profile" +export FORGEJO_GENERAL_DISCUSSION_REPO="Robot-U/general_forum" +export FORGEJO_WEBHOOK_SECRET="shared-webhook-secret" +export FORGEJO_CACHE_TTL_SECONDS="60.0" export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics" ``` @@ -55,7 +58,13 @@ http://kacper-dev-pod:8800/api/auth/forgejo/callback `AUTH_SECRET_KEY` is required for Forgejo OAuth sign-in. It encrypts the `HttpOnly` browser session cookie that carries the signed-in user's Forgejo token and identity. Set `AUTH_COOKIE_SECURE=true` when serving over HTTPS. -`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments. +`FORGEJO_TOKEN` is optional. When set, it is a read fallback for the public content cache. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public discussion creation and replies. The backend must verify repositories are public before reading discussion data or writing issues/comments. + +`FORGEJO_CACHE_TTL_SECONDS` controls how long the server reuses the public Forgejo content scan for `/api/prototype`. Set it to `0` to disable caching while debugging discovery behavior. + +`FORGEJO_GENERAL_DISCUSSION_REPO` should point at the public `owner/repo` used for general discussion threads. Post- and lesson-linked discussions are created in the same repo as the content being discussed. + +`FORGEJO_WEBHOOK_SECRET` is optional but recommended. Configure Forgejo webhooks to POST to `/api/forgejo/webhook`; matching webhook events invalidate the public content cache and notify open browsers over `/api/events/stream`. Or put those values in `.env`: diff --git a/app.py b/app.py index 9e24488..27ebcef 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,14 @@ from __future__ import annotations +import hmac +from hashlib import sha256 from pathlib import Path from typing import Any from urllib.parse import urlencode from fastapi import Body, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from auth import ( @@ -19,8 +21,10 @@ from auth import ( resolve_forgejo_token, ) from forgejo_client import ForgejoClient, ForgejoClientError -from live_prototype import build_live_prototype_payload +from live_prototype import discussion_card_from_issue +from prototype_cache import PrototypePayloadCache from settings import Settings, get_settings +from update_events import UpdateBroker, stream_sse_events BASE_DIR = Path(__file__).resolve().parent DIST_DIR = BASE_DIR / "frontend" / "dist" @@ -28,6 +32,8 @@ DIST_DIR = BASE_DIR / "frontend" / "dist" def create_app() -> FastAPI: app = FastAPI(title="Robot U Community Prototype") + prototype_cache = PrototypePayloadCache() + update_broker = UpdateBroker() app.add_middleware( CORSMiddleware, @@ -45,14 +51,14 @@ def create_app() -> FastAPI: settings = get_settings() session_user = current_session_user(request, settings) forgejo_token, auth_source = resolve_forgejo_token(request, settings) - return JSONResponse( - await build_live_prototype_payload( - settings, - forgejo_token=forgejo_token, - auth_source=auth_source, - session_user=session_user, - ), + payload = await prototype_cache.get(settings) + payload["auth"] = await _auth_payload_for_request( + settings, + forgejo_token=forgejo_token, + auth_source=auth_source, + session_user=session_user, ) + return JSONResponse(payload) @app.get("/api/auth/session") async def auth_session(request: Request) -> JSONResponse: @@ -73,6 +79,28 @@ def create_app() -> FastAPI: return JSONResponse(_auth_payload(user, auth_source)) + @app.get("/api/events/stream") + async def events_stream() -> StreamingResponse: + return StreamingResponse( + stream_sse_events(update_broker), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + ) + + @app.post("/api/forgejo/webhook") + async def forgejo_webhook(request: Request) -> JSONResponse: + settings = get_settings() + body = await request.body() + if not _valid_webhook_signature(request, settings, body): + raise HTTPException(status_code=401, detail="Invalid Forgejo webhook signature.") + + prototype_cache.invalidate() + await update_broker.publish("content-updated", {"reason": "forgejo-webhook"}) + return JSONResponse({"status": "accepted"}) + @app.get("/api/auth/forgejo/start") async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse: settings = get_settings() @@ -140,6 +168,31 @@ def create_app() -> FastAPI: clear_login_session(request, response) return response + @app.get("/api/discussions/{owner}/{repo}/{issue_number}") + async def discussion_detail(owner: str, repo: str, issue_number: int) -> JSONResponse: + if issue_number < 1: + raise HTTPException(status_code=400, detail="issue_number must be positive.") + + settings = get_settings() + async with ForgejoClient(settings, forgejo_token=settings.forgejo_token) as client: + try: + repo_payload = await client.fetch_repository(owner, repo) + if repo_payload.get("private"): + raise HTTPException( + status_code=403, + detail="This site only reads public Forgejo repositories.", + ) + issue = await client.fetch_issue(owner, repo, issue_number) + comments = [ + _discussion_reply(comment) + for comment in await client.list_issue_comments(owner, repo, issue_number) + ] + except ForgejoClientError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + + issue["repository"] = _issue_repository_payload(repo_payload, owner, repo) + return JSONResponse(discussion_card_from_issue(issue, comments=comments)) + @app.post("/api/discussions/replies") async def create_discussion_reply( request: Request, @@ -169,8 +222,59 @@ def create_app() -> FastAPI: except ForgejoClientError as error: raise HTTPException(status_code=502, detail=str(error)) from error + prototype_cache.invalidate() + await update_broker.publish( + "content-updated", + {"reason": "discussion-reply", "repo": f"{owner}/{repo}", "number": issue_number}, + ) return JSONResponse(_discussion_reply(comment)) + @app.post("/api/discussions") + async def create_discussion( + request: Request, + payload: dict[str, object] = Body(...), + ) -> JSONResponse: + title = _required_string(payload, "title") + body = _required_string(payload, "body") + owner, repo = _discussion_target(payload, get_settings()) + context_url = _optional_string(payload, "context_url") + context_title = _optional_string(payload, "context_title") + context_path = _optional_string(payload, "context_path") + issue_body = _discussion_issue_body( + body, + context_url=context_url, + context_title=context_title, + context_path=context_path, + ) + + settings = get_settings() + forgejo_token, auth_source = resolve_forgejo_token(request, settings) + if not forgejo_token or auth_source == "server": + raise HTTPException( + status_code=401, + detail="Sign in or send an Authorization token before starting a discussion.", + ) + + async with ForgejoClient(settings, forgejo_token=forgejo_token) as client: + try: + repo_payload = await client.fetch_repository(owner, repo) + if repo_payload.get("private"): + raise HTTPException( + status_code=403, + detail="This site only writes to public Forgejo repositories.", + ) + issue = await client.create_issue(owner, repo, title, issue_body) + except ForgejoClientError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + + issue["repository"] = _issue_repository_payload(repo_payload, owner, repo) + prototype_cache.invalidate() + await update_broker.publish( + "content-updated", + {"reason": "discussion-created", "repo": f"{owner}/{repo}"}, + ) + return JSONResponse(discussion_card_from_issue(issue, comments=[])) + if DIST_DIR.exists(): assets_dir = DIST_DIR / "assets" if assets_dir.exists(): @@ -201,6 +305,16 @@ def _required_string(payload: dict[str, object], key: str) -> str: return value.strip() +def _optional_string(payload: dict[str, object], key: str) -> str | None: + value = payload.get(key) + if value is None: + return None + if not isinstance(value, str): + raise HTTPException(status_code=400, detail=f"{key} must be a string.") + stripped = value.strip() + return stripped or None + + def _required_positive_int(payload: dict[str, object], key: str) -> int: value = payload.get(key) if isinstance(value, bool): @@ -218,6 +332,62 @@ def _required_positive_int(payload: dict[str, object], key: str) -> int: return parsed +def _discussion_target(payload: dict[str, object], settings: Settings) -> tuple[str, str]: + owner = _optional_string(payload, "owner") + repo = _optional_string(payload, "repo") + if owner and repo: + return owner, repo + if owner or repo: + raise HTTPException(status_code=400, detail="owner and repo must be provided together.") + + configured_repo = settings.forgejo_general_discussion_repo + if configured_repo: + parts = [part for part in configured_repo.strip().split("/", 1) if part] + if len(parts) == 2: + return parts[0], parts[1] + + raise HTTPException( + status_code=400, + detail="General discussion repo is not configured.", + ) + + +def _discussion_issue_body( + body: str, + *, + context_url: str | None, + context_title: str | None, + context_path: str | None, +) -> str: + related_lines: list[str] = [] + if context_title: + related_lines.append(f"Related content: {context_title}") + if context_url: + related_lines.append(f"Canonical URL: {context_url}") + if context_path: + related_lines.append(f"Source path: {context_path}") + + if not related_lines: + return body + + return f"{body}\n\n---\n" + "\n".join(related_lines) + + +def _issue_repository_payload( + repo_payload: dict[str, Any], + owner: str, + repo: str, +) -> dict[str, object]: + repo_owner = repo_payload.get("owner") or {} + owner_login = repo_owner.get("login") if isinstance(repo_owner, dict) else owner + return { + "owner": owner_login or owner, + "name": repo_payload.get("name") or repo, + "full_name": repo_payload.get("full_name") or f"{owner}/{repo}", + "private": False, + } + + def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]: author = comment.get("user") or {} body = str(comment.get("body", "") or "").strip() @@ -254,6 +424,42 @@ def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object] } +async def _auth_payload_for_request( + settings: Settings, + *, + forgejo_token: str | None, + auth_source: str, + session_user: dict[str, Any] | None, +) -> dict[str, object]: + if session_user: + return _auth_payload(session_user, "session") + + if not forgejo_token or auth_source == "server": + return _auth_payload(None, "none") + + async with ForgejoClient(settings, forgejo_token=forgejo_token) as client: + try: + user = await client.fetch_current_user() + except ForgejoClientError as error: + raise HTTPException(status_code=401, detail=str(error)) from error + + return _auth_payload(user, auth_source) + + +def _valid_webhook_signature(request: Request, settings: Settings, body: bytes) -> bool: + secret = settings.forgejo_webhook_secret + if not secret: + return True + + expected = hmac.new(secret.encode("utf-8"), body, sha256).hexdigest() + candidates = [ + request.headers.get("x-forgejo-signature", ""), + request.headers.get("x-gitea-signature", ""), + request.headers.get("x-hub-signature-256", "").removeprefix("sha256="), + ] + return any(hmac.compare_digest(expected, candidate.strip()) for candidate in candidates) + + def _oauth_configured(settings: Settings) -> bool: return bool( settings.auth_secret_key diff --git a/docs/community-platform-design-doc.md b/docs/community-platform-design-doc.md index 6c2d2e3..c1c3370 100644 --- a/docs/community-platform-design-doc.md +++ b/docs/community-platform-design-doc.md @@ -80,7 +80,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc - Auto-discovery of eligible public repos - Rendering markdown content from Forgejo repos - Discussion creation and replies from this app, backed by Forgejo issues/comments -- Per-user lesson completion tracking - Team-derived admin/moderator permissions - Webhook-driven sync from Forgejo - SSE updates for open sessions when content/discussions change @@ -92,6 +91,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc - Non-Forgejo authentication providers - Search - Rich public profiles +- Per-user lesson completion tracking - Private repo indexing - Admin-created calendar events in this app - Review or approval workflow before publishing @@ -108,7 +108,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc ### Signed-In Member 1. Signs in with Forgejo -2. Reads lessons and marks them complete +2. Reads lessons 3. Creates general discussion threads 4. Creates post- or lesson-linked discussion threads from content pages 5. Replies, edits, and otherwise interacts through the app UI @@ -131,7 +131,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc | Content assets / downloads | Forgejo repos | | Discussions / comments | Forgejo issues and issue comments | | Events | External ICS feeds | -| Lesson progress | App database | +| Lesson progress | Post-MVP app database | | Sessions | App backend | | Cached index metadata | App database | @@ -147,7 +147,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc ### Authenticated Capabilities -- Mark lesson complete - Create discussion threads - Reply to discussions - Edit supported discussion content through the app @@ -359,9 +358,9 @@ Events should continue to be managed in external calendar tools. ## 15. Progress Tracking -The app should track lesson progress per signed-in user. +Lesson progress is no longer part of the MVP. -MVP state model: +Post-MVP state model: - not started - completed @@ -435,6 +434,7 @@ The following are explicitly out of scope for MVP: - browser-based asset uploads - search - rich user profiles +- per-user lesson completion tracking - non-Forgejo auth providers - private repo support - complex moderation workflows diff --git a/forgejo_client.py b/forgejo_client.py index 5482341..ebe5443 100644 --- a/forgejo_client.py +++ b/forgejo_client.py @@ -83,17 +83,32 @@ class ForgejoClient: return await self._get_json("/api/v1/user", auth_required=True) async def search_repositories(self) -> list[dict[str, Any]]: - payload = await self._get_json( - "/api/v1/repos/search", - params={ - "limit": self._settings.forgejo_repo_scan_limit, - "page": 1, - "private": "false", - "is_private": "false", - }, - ) - data = payload.get("data", []) - return [repo for repo in data if isinstance(repo, dict)] + scan_limit = max(self._settings.forgejo_repo_scan_limit, 1) + page_limit = min(scan_limit, 50) + repos: list[dict[str, Any]] = [] + page = 1 + + while len(repos) < scan_limit: + payload = await self._get_json( + "/api/v1/repos/search", + params={ + "limit": page_limit, + "page": page, + "private": "false", + "is_private": "false", + }, + ) + if not isinstance(payload, dict): + break + + data = payload.get("data", []) + page_repos = [repo for repo in data if isinstance(repo, dict)] + repos.extend(page_repos) + if len(page_repos) < page_limit: + break + page += 1 + + return repos[:scan_limit] async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]: payload = await self._get_json( @@ -124,6 +139,12 @@ class ForgejoClient: return [issue for issue in payload if isinstance(issue, dict)] return [] + async def fetch_issue(self, owner: str, repo: str, issue_number: int) -> dict[str, Any]: + payload = await self._get_json(f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}") + if isinstance(payload, dict): + return payload + raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}#{issue_number}") + async def list_directory(self, owner: str, repo: str, path: str = "") -> list[dict[str, Any]]: endpoint = f"/api/v1/repos/{owner}/{repo}/contents" if path: @@ -166,6 +187,23 @@ class ForgejoClient: return payload raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}") + async def create_issue( + self, + owner: str, + repo: str, + title: str, + body: str, + ) -> dict[str, Any]: + payload = await self._request_json( + "POST", + f"/api/v1/repos/{owner}/{repo}/issues", + json_payload={"title": title, "body": body}, + auth_required=True, + ) + if isinstance(payload, dict): + return payload + raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}") + async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 08ef21a..100c093 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "preact/hooks"; import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent"; import type { AuthState, + ContentAsset, CourseCard, CourseChapter, CourseLesson, @@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | null { return Number.isFinite(issueId) ? issueId : null; } +function parseDiscussionRepoRoute( + pathname: string, +): { owner: string; repo: string; number: number } | null { + const match = normalizePathname(pathname).match(/^\/discussions\/([^/]+)\/([^/]+)\/(\d+)$/); + if (!match) { + return null; + } + + const issueNumber = Number(match[3]); + if (!Number.isFinite(issueNumber)) { + return null; + } + + return { + owner: decodeURIComponent(match[1]), + repo: decodeURIComponent(match[2]), + number: issueNumber, + }; +} + function isSignInRoute(pathname: string): boolean { return normalizePathname(pathname) === "/signin"; } @@ -179,6 +200,17 @@ function findLessonByRoute( return undefined; } +function findDiscussionByRoute( + discussions: DiscussionCard[], + route: { owner: string; repo: string; number: number }, +): DiscussionCard | undefined { + const routeRepo = `${normalizeRouteKey(route.owner)}/${normalizeRouteKey(route.repo)}`; + return discussions.find( + (discussion) => + normalizeRouteKey(discussion.repo) === routeRepo && discussion.number === route.number, + ); +} + function usePathname() { const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname)); @@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) { function DiscussionPreviewItem(props: { discussion: DiscussionCard; - onOpenDiscussion: (id: number) => void; + onOpenDiscussion: (discussion: DiscussionCard) => void; }) { const { discussion, onOpenDiscussion } = props; @@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: { type="button" className="discussion-preview-card" onClick={() => { - onOpenDiscussion(discussion.id); + onOpenDiscussion(discussion); }} >

{discussion.title}

@@ -500,6 +532,42 @@ async function postDiscussionReply( return (await response.json()) as DiscussionReply; } +interface DiscussionCreateContext { + owner?: string; + repo?: string; + contextPath?: string; + contextUrl?: string; + contextTitle?: string; +} + +async function postDiscussion( + title: string, + body: string, + context?: DiscussionCreateContext, +): Promise { + const response = await fetch("/api/discussions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title, + body, + owner: context?.owner, + repo: context?.repo, + context_path: context?.contextPath, + context_url: context?.contextUrl, + context_title: context?.contextTitle, + }), + }); + + if (!response.ok) { + throw await responseError(response, `Discussion creation failed with ${response.status}`); + } + + return (await response.json()) as DiscussionCard; +} + function ComposeBox(props: { discussion: DiscussionCard; onReplyCreated: (discussionId: number, reply: DiscussionReply) => void; @@ -578,6 +646,193 @@ function ComposeBox(props: { ); } +function DiscussionCreateBox(props: { + auth: AuthState; + context?: DiscussionCreateContext; + titlePlaceholder: string; + bodyPlaceholder: string; + submitLabel: string; + onGoSignIn: () => void; + onDiscussionCreated: (discussion: DiscussionCard) => void; +}) { + const { + auth, + context, + titlePlaceholder, + bodyPlaceholder, + submitLabel, + onGoSignIn, + onDiscussionCreated, + } = props; + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const trimmedTitle = title.trim(); + const trimmedBody = body.trim(); + const canCreate = canUseInteractiveAuth(auth); + + async function submitDiscussion(event: SubmitEvent) { + event.preventDefault(); + if (!canCreate) { + setError("Sign in before starting a discussion."); + return; + } + if (!trimmedTitle || !trimmedBody || isSubmitting) { + return; + } + + setError(null); + setIsSubmitting(true); + try { + const discussion = await postDiscussion(trimmedTitle, trimmedBody, context); + setTitle(""); + setBody(""); + onDiscussionCreated(discussion); + } catch (createError) { + const message = + createError instanceof Error ? createError.message : "Discussion could not be created."; + setError(message); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ {!canCreate ? ( +
+

+ {auth.authenticated + ? "Discussion creation is unavailable for this session." + : "Sign in before starting a discussion."} +

+ {!auth.authenticated ? ( + + ) : null} +
+ ) : null} + { + setTitle(event.currentTarget.value); + }} + /> +