Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
141 changes: 141 additions & 0 deletions packages/web/src/components/ActivityLogBox.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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 (
<Show when={data()}>
{(log) => (
<details
onToggle={(event) => setOpen(event.currentTarget.open)}
class={styles.box}
>
<summary class={styles.summary}>
<strong>
{log().type ?? "Unknown activity"}
{log().objectType != undefined && log().objectType !== ""
? `(${log().objectType})`
: ""}
</strong>
<span> · {log().status}</span>
<Show when={log().objectIri}>
{(iri) => <span class={styles.detailLine}>Object: {iri()}</span>}
</Show>
<span class={styles.detailLine}>
From: {from() ?? "Unknown sender"}
</span>
<span class={styles.recipientLine}>
To:{" "}
<For each={to()}>
{(iri, index) => (
<>
{index() > 0 ? ", " : ""}
{iri}
</>
)}
</For>
</span>
<time class={styles.detailLine} dateTime={log().created}>
{log().created}
</time>
<span class={styles.detailLine}>
{open() ? "Hide source" : "View source"}
</span>
</summary>
<Show when={open()}>
<p>
{log().direction === "inbound" && log().payload == undefined
? "Raw received body"
: "JSON-LD"}
{log().direction === "outbound" ? " (before signing)" : ""}
</p>
<Show
when={source() != undefined}
fallback={<p>No source recorded.</p>}
>
<CopyButton value={source() ?? ""} label="activity source" />
<ActivityLogSource
source={source() ?? ""}
json={log().payload != undefined}
/>
</Show>
</Show>
</details>
)}
</Show>
);
}
51 changes: 51 additions & 0 deletions packages/web/src/components/ActivityLogSource.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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<typeof createHighlighterCoreSync> | 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 <div class={styles.source} innerHTML={html()} />;
}
157 changes: 157 additions & 0 deletions packages/web/src/components/ActivityLogTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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 (
<div>
<section
ref={(element) => {
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();
}
}}
>
<Show when={failed()}>
<p role="alert">
Could not load older logs.{" "}
<button onClick={loadOlder}>Retry</button>
</p>
</Show>
<Show when={data.isLoadingNext}>
<output>Loading older logs…</output>
</Show>
<Show
when={entries().length > 0}
fallback={<p>No activity recorded yet.</p>}
>
<ul class={styles.list}>
<For each={entries()}>
{(edge) => (
<li class={styles.row}>
<div
class={[
styles.entry,
edge.node.direction === "inbound"
? styles.incoming
: styles.outgoing,
].join(" ")}
>
<ActivityLogBox $log={edge.node} />
</div>
</li>
)}
</For>
</ul>
</Show>
</section>
</div>
);
}
Loading
Loading