diff --git a/packages/web/package.json b/packages/web/package.json index dc88a13..d53024c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -55,6 +55,7 @@ "nitro": "3.0.260610-beta", "relay-runtime": "^21.0.1", "seroval": "1.5.4", + "shiki": "4.4.1", "solid-js": "^1.9.5", "solid-relay": "1.0.0-beta.29", "valibot": "1.4.2", diff --git a/packages/web/src/components/ActivityLogBox.tsx b/packages/web/src/components/ActivityLogBox.tsx new file mode 100644 index 0000000..2e36e96 --- /dev/null +++ b/packages/web/src/components/ActivityLogBox.tsx @@ -0,0 +1,141 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { graphql } from "relay-runtime"; +import { For, Show, createMemo, createSignal } from "solid-js"; +import { createFragment } from "solid-relay"; + +import type { ActivityLogBox_log$key } from "./__generated__/ActivityLogBox_log.graphql.ts"; +import { ActivityLogSource } from "./ActivityLogSource.tsx"; +import { CopyButton } from "./CopyButton.tsx"; + +import styles from "~/styles/activity-log.module.css"; + +/** + * Displays delivery endpoints and expands to show the recorded JSON-LD. + * @returns A keyboard-accessible activity disclosure. + */ +export function ActivityLogBox(props: { $log: ActivityLogBox_log$key }) { + const data = createFragment( + graphql` + fragment ActivityLogBox_log on ActivityDelivery @throwOnFieldError { + direction + type + objectType + objectIri + actor { + iri + } + remoteActorIri + recipientIris + inboxUrl + status + created + payload + rawBody + } + `, + () => props.$log, + ); + const [open, setOpen] = createSignal(false); + const source = createMemo(() => { + if (!open()) return ""; + const log = data(); + const payload: unknown = log?.payload; + return payload == undefined + ? (log?.rawBody ?? undefined) + : JSON.stringify(payload, undefined, 2); + }); + const from = createMemo(() => { + const log = data(); + if (log?.direction === "inbound") return log.remoteActorIri; + // Keep the sender visible even if its local actor has since been deleted. + const payload: unknown = log?.payload; + const actor: unknown = + payload != undefined && typeof payload === "object" && "actor" in payload + ? payload.actor + : undefined; + return log?.actor?.iri ?? (typeof actor === "string" ? actor : undefined); + }); + const to = createMemo(() => { + const log = data(); + if (!log) return []; + if (log.direction === "inbound") return [log.actor?.iri ?? log.inboxUrl]; + return log.recipientIris.length > 0 ? log.recipientIris : [log.inboxUrl]; + }); + + return ( + + {(log) => ( +
setOpen(event.currentTarget.open)} + class={styles.box} + > + + + {log().type ?? "Unknown activity"} + {log().objectType != undefined && log().objectType !== "" + ? `(${log().objectType})` + : ""} + + · {log().status} + + {(iri) => Object: {iri()}} + + + From: {from() ?? "Unknown sender"} + + + To:{" "} + + {(iri, index) => ( + <> + {index() > 0 ? ", " : ""} + {iri} + + )} + + + + + {open() ? "Hide source" : "View source"} + + + +

+ {log().direction === "inbound" && log().payload == undefined + ? "Raw received body" + : "JSON-LD"} + {log().direction === "outbound" ? " (before signing)" : ""} +

+ No source recorded.

} + > + + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/ActivityLogSource.tsx b/packages/web/src/components/ActivityLogSource.tsx new file mode 100644 index 0000000..007561e --- /dev/null +++ b/packages/web/src/components/ActivityLogSource.tsx @@ -0,0 +1,51 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { createHighlighterCoreSync } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import json from "shiki/langs/json.mjs"; +import githubDark from "shiki/themes/github-dark.mjs"; +import githubLight from "shiki/themes/github-light.mjs"; +import { createMemo } from "solid-js"; + +import styles from "~/styles/activity-log.module.css"; + +let highlighter: ReturnType | undefined; + +/** + * Render recorded source with JSON colours, or plain text for raw bodies. + * @returns Highlighted, escaped source markup. + */ +export function ActivityLogSource(props: { source: string; json: boolean }) { + // oxlint-disable-next-line node/no-sync + highlighter ??= createHighlighterCoreSync({ + langs: [json], + themes: [githubLight, githubDark], + engine: createJavaScriptRegexEngine(), + }); + const renderer = highlighter; + const html = createMemo(() => + renderer.codeToHtml(props.source, { + lang: props.json ? "json" : "text", + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + }), + ); + + // Shiki escapes source text before producing the highlighted markup. + // oxlint-disable-next-line solid/no-innerhtml + return
; +} diff --git a/packages/web/src/components/ActivityLogTimeline.tsx b/packages/web/src/components/ActivityLogTimeline.tsx new file mode 100644 index 0000000..5aa890f --- /dev/null +++ b/packages/web/src/components/ActivityLogTimeline.tsx @@ -0,0 +1,157 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { graphql } from "relay-runtime"; +import { + For, + Show, + createEffect, + createMemo, + createSignal, + onCleanup, +} from "solid-js"; +import { createPaginationFragment } from "solid-relay"; + +import type { ActivityLogTimeline_instance$key } from "./__generated__/ActivityLogTimeline_instance.graphql.ts"; +import { ActivityLogBox } from "./ActivityLogBox.tsx"; + +import styles from "~/styles/activity-log.module.css"; + +/** + * A chronological delivery list that loads older entries when scrolled upward. + * @returns The shared scrollable activity timeline. + */ +export function ActivityLogTimeline(props: { + $instance: ActivityLogTimeline_instance$key; +}) { + const data = createPaginationFragment( + graphql` + fragment ActivityLogTimeline_instance on Instance + @throwOnFieldError + @argumentDefinitions( + count: { type: "Int", defaultValue: 20 } + cursor: { type: "String" } + ) + @refetchable(queryName: "ActivityLogTimelinePaginationQuery") { + activityDeliveries(first: $count, after: $cursor) + @connection(key: "ActivityLogTimeline_activityDeliveries") { + edges { + node { + id + direction + ...ActivityLogBox_log + } + } + } + } + `, + () => props.$instance, + ); + const entries = createMemo(() => + (data()?.activityDeliveries.edges ?? []).toReversed(), + ); + const [failed, setFailed] = createSignal(false); + let viewport: HTMLElement | undefined; + let initialized = false; + let anchor: { height: number } | undefined; + let frame: number | undefined; + + // Older pages are prepended visually; compensate for their height so the + // current reading position stays in place, including scrolling during the request. + createEffect(() => { + entries(); + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + if (!viewport) return; + if (!initialized) { + viewport.scrollTop = viewport.scrollHeight; + initialized = true; + } else if (anchor) { + viewport.scrollTop += viewport.scrollHeight - anchor.height; + anchor = undefined; + } + }); + }); + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame); + }); + + function loadOlder() { + if (!viewport || !data.hasNext || data.isLoadingNext) return; + anchor = { height: viewport.scrollHeight }; + setFailed(false); + data.loadNext(20, { + onComplete(error) { + if (error) { + anchor = undefined; + setFailed(true); + } + }, + }); + } + + return ( +
+
{ + viewport = element; + }} + aria-label="Activity log history" + // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex + tabIndex={0} + class={styles.timeline} + onScroll={() => { + if (initialized && viewport && viewport.scrollTop < 64 && !failed()) { + loadOlder(); + } + }} + > + +

+ Could not load older logs.{" "} + +

+
+ + Loading older logs… + + 0} + fallback={

No activity recorded yet.

} + > +
    + + {(edge) => ( +
  • +
    + +
    +
  • + )} +
    +
+
+
+
+ ); +} diff --git a/packages/web/src/components/InstanceActivityLog.tsx b/packages/web/src/components/InstanceActivityLog.tsx new file mode 100644 index 0000000..e7759b0 --- /dev/null +++ b/packages/web/src/components/InstanceActivityLog.tsx @@ -0,0 +1,44 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { ErrorBoundary } from "solid-js"; + +import type { ActivityLogTimeline_instance$key } from "./__generated__/ActivityLogTimeline_instance.graphql.ts"; +import { ActivityLogTimeline } from "./ActivityLogTimeline.tsx"; + +/** + * Displays the instance's incoming and outgoing delivery logs. + * @returns The activity log section with a shared chronological timeline. + */ +export function InstanceActivityLog(props: { + $instance: ActivityLogTimeline_instance$key; +}) { + return ( +
+

Activity log

+ + Activity logs could not be loaded. Access requires instance + membership or administrator permissions. +

+ } + > + +
+
+ ); +} diff --git a/packages/web/src/routes/instance/[slug].tsx b/packages/web/src/routes/instance/[slug].tsx index 9370cee..099cabe 100644 --- a/packages/web/src/routes/instance/[slug].tsx +++ b/packages/web/src/routes/instance/[slug].tsx @@ -31,6 +31,7 @@ import { import { ActorCard } from "~/components/ActorCard.tsx"; import { CopyButton } from "~/components/CopyButton.tsx"; +import { InstanceActivityLog } from "~/components/InstanceActivityLog.tsx"; import type { InstanceDetailQuery } from "./__generated__/InstanceDetailQuery.graphql.ts"; @@ -41,6 +42,7 @@ const instanceDetailQuery = graphql` query InstanceDetailQuery($slug: String!) { localInstanceBySlug(slug: $slug) { instance { + ...ActivityLogTimeline_instance id host url @@ -181,6 +183,7 @@ export default function InstanceDetailPage(
+ )} diff --git a/packages/web/src/styles/activity-log.module.css b/packages/web/src/styles/activity-log.module.css new file mode 100644 index 0000000..2bb5f35 --- /dev/null +++ b/packages/web/src/styles/activity-log.module.css @@ -0,0 +1,90 @@ +/* +DrFed: A web-based platform for developing and debugging ActivityPub apps +Copyright (C) 2026 DrFed team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +.timeline { + position: relative; + height: 40rem; + overflow: auto; + overflow-anchor: none; + overflow-wrap: anywhere; +} + +.list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.75rem; +} + +.row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; +} + +.entry { + min-width: 0; +} + +.incoming { + grid-column: 1; +} + +.outgoing { + grid-column: 2; +} + +.box { + border: 1px solid var(--line); + border-radius: 0.5rem; + padding: 0.75rem; +} + +.summary { + cursor: pointer; + overflow-wrap: anywhere; +} + +.detailLine { + display: block; + margin-top: 0.5rem; +} + +.recipientLine { + display: block; +} + +.source pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.source pre, +.source span { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); +} + +@media (prefers-color-scheme: dark) { + .source pre, + .source span { + color: var(--shiki-dark); + background-color: var(--shiki-dark-bg); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f310962..17278d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -311,6 +311,9 @@ importers: seroval: specifier: 1.5.4 version: 1.5.4 + shiki: + specifier: 4.4.1 + version: 4.4.1 solid-js: specifier: ^1.9.5 version: 1.9.14