Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e78bb58
Add HoverCard solid-ui primitive
dahlia May 2, 2026
e8bc77a
Add ActorHoverCard scaffolding
dahlia May 2, 2026
e0133f3
Wire ActorHoverCard onto avatar and name surfaces
dahlia May 2, 2026
62a9f1c
Add mention hover cards + fix avatar trigger anchor rect
dahlia May 2, 2026
6ef2179
Translate hover-card empty state in all locales
dahlia May 2, 2026
cf8e9b7
Cover article detail page and SmallProfileCard bio
dahlia May 2, 2026
533feac
Support h-card mentions in hover-card hook
dahlia May 2, 2026
aa3d9ac
Fall back to username when display name is empty
dahlia May 2, 2026
da889f9
Add rel="noopener noreferrer" to external profile links
dahlia May 2, 2026
10d647e
Add hover card to NoteHeader handle span
dahlia May 2, 2026
747b1c4
Cover handle spans in the remaining post-card headers
dahlia May 2, 2026
e4dff98
Deduplicate follow-count rendering in ActorPreviewCard
dahlia May 2, 2026
b3a4619
Derive mention username from the URL path first
dahlia May 2, 2026
3fc4f9c
Add actorByUrl GraphQL query
dahlia May 2, 2026
761ee47
Resolve mention hover cards by URL instead of handle
dahlia May 2, 2026
301b400
Use InternalLink in ActorPreviewCard for SPA navigation
dahlia May 2, 2026
d13b786
Disable popover auto-focus on mention hover preview
dahlia May 2, 2026
32a1f23
Derive username from name only via innerHTML in SmallProfileCard
dahlia May 2, 2026
20b4195
Drop window.setTimeout prefix in mentionHoverCards
dahlia May 2, 2026
7648b97
Merge name and handle hover triggers in post headers
dahlia May 2, 2026
7fda5ff
Type mention hover-card timers via ReturnType
dahlia May 2, 2026
e3ae3ce
Batch mention hover-card state transitions
dahlia May 2, 2026
fbfe8d6
Resolve protocol-relative mention hrefs
dahlia May 2, 2026
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
144 changes: 143 additions & 1 deletion graphql/actor.more.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { encodeGlobalID } from "@pothos/plugin-relay";
import { eq } from "drizzle-orm";
import { execute, parse } from "graphql";
import { follow } from "@hackerspub/models/following";
import { pinTable } from "@hackerspub/models/schema";
import { actorTable, pinTable } from "@hackerspub/models/schema";
import { schema } from "./mod.ts";
import {
createFedCtx,
Expand Down Expand Up @@ -35,6 +36,15 @@ const actorByHandleQuery = parse(`
}
`);

const actorByUrlQuery = parse(`
query ActorByUrl($url: URL!) {
actorByUrl(url: $url) {
id
handle
}
}
`);

const actorPinsQuery = parse(`
query ActorPins($handle: String!) {
actorByHandle(handle: $handle, allowLocalHandle: true) {
Expand Down Expand Up @@ -117,6 +127,138 @@ test("actorByUuid and actorByHandle resolve local actors", async () => {
});
});

test("actorByUrl resolves a local actor by IRI", async () => {
await withRollback(async (tx) => {
const actor = await insertAccountWithActor(tx, {
username: "actorbyurllocal",
name: "Actor By URL Local",
email: "actorbyurllocal@example.com",
});

const result = await execute({
schema,
document: actorByUrlQuery,
variableValues: { url: actor.actor.iri },
contextValue: makeGuestContext(tx),
onError: "NO_PROPAGATE",
});
assert.equal(result.errors, undefined);
assert.deepEqual(toPlainJson(result.data), {
actorByUrl: {
id: encodeGlobalID("Actor", actor.actor.id),
handle: "@actorbyurllocal@localhost",
},
});
});
});

test("actorByUrl resolves a remote actor by IRI", async () => {
await withRollback(async (tx) => {
const remote = await insertRemoteActor(tx, {
username: "actorbyurlremote",
name: "Actor By URL Remote",
host: "remote.example",
});

const result = await execute({
schema,
document: actorByUrlQuery,
variableValues: { url: remote.iri },
contextValue: makeGuestContext(tx),
onError: "NO_PROPAGATE",
});
assert.equal(result.errors, undefined);
assert.deepEqual(toPlainJson(result.data), {
actorByUrl: {
id: encodeGlobalID("Actor", remote.id),
handle: "@actorbyurlremote@remote.example",
},
});
});
});

test("actorByUrl resolves a remote actor by its human-facing url", async () => {
await withRollback(async (tx) => {
const remote = await insertRemoteActor(tx, {
username: "actorbyurlhuman",
name: "Actor By URL Human",
host: "remote.example",
});
const profileUrl = `https://remote.example/@actorbyurlhuman`;
await tx.update(actorTable).set({ url: profileUrl }).where(
eq(actorTable.id, remote.id),
);

const result = await execute({
schema,
document: actorByUrlQuery,
variableValues: { url: profileUrl },
contextValue: makeGuestContext(tx),
onError: "NO_PROPAGATE",
});
assert.equal(result.errors, undefined);
assert.deepEqual(toPlainJson(result.data), {
actorByUrl: {
id: encodeGlobalID("Actor", remote.id),
handle: "@actorbyurlhuman@remote.example",
},
});
});
});

test("actorByUrl prefers an IRI match over a colliding url match", async () => {
await withRollback(async (tx) => {
const intended = await insertRemoteActor(tx, {
username: "actorbyurliri",
name: "Actor By URL IRI",
host: "iri.example",
iri: "https://iri.example/users/intended",
});
const collider = await insertRemoteActor(tx, {
username: "actorbyurlcollider",
name: "Actor By URL Collider",
host: "collider.example",
});
// The collider's `url` is set to the intended actor's IRI. A query for
// that string must return the actor whose `iri` matches, not the actor
// whose `url` matches.
await tx.update(actorTable).set({ url: intended.iri }).where(
eq(actorTable.id, collider.id),
);

const result = await execute({
schema,
document: actorByUrlQuery,
variableValues: { url: intended.iri },
contextValue: makeGuestContext(tx),
onError: "NO_PROPAGATE",
});
assert.equal(result.errors, undefined);
assert.deepEqual(toPlainJson(result.data), {
actorByUrl: {
id: encodeGlobalID("Actor", intended.id),
handle: "@actorbyurliri@iri.example",
},
});
});
});

test("actorByUrl returns null for an unknown URL without federation lookup", async () => {
await withRollback(async (tx) => {
const result = await execute({
schema,
document: actorByUrlQuery,
variableValues: { url: "https://example.invalid/users/nobody" },
contextValue: makeGuestContext(tx),
onError: "NO_PROPAGATE",
});
assert.equal(result.errors, undefined);
assert.deepEqual(toPlainJson(result.data), {
actorByUrl: null,
});
});
});

test("actor pins hide posts that are not visible to the viewer", async () => {
await withRollback(async (tx) => {
const author = await insertAccountWithActor(tx, {
Expand Down
19 changes: 19 additions & 0 deletions graphql/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { escape } from "@std/html/entities";
import xss from "xss";
import { builder, type UserContext } from "./builder.ts";
import { InvalidInputError } from "./error.ts";
import { lookupActorByUrl, parseHttpUrl } from "./lookup.ts";
import { Article, Note, Post, Question } from "./post.ts";
import { NotAuthenticatedError } from "./session.ts";

Expand Down Expand Up @@ -655,6 +656,24 @@ builder.queryFields((t) => ({
return await persistActor(ctx.fedCtx, actorObject, { documentLoader });
},
}),
actorByUrl: t.drizzleField({
type: Actor,
args: {
url: t.arg({ type: "URL", required: true }),
},
nullable: true,
async resolve(query, _, { url }, ctx) {
const parsed = parseHttpUrl(url.toString());
if (parsed == null) return null;
const looked = await lookupActorByUrl(ctx, parsed);
if (looked == null) return null;
// Re-fetch through Pothos's drizzle query so selection-driven
// relations on Actor are loaded.
return await ctx.db.query.actorTable.findFirst(
query({ where: { id: looked.id } }),
);
},
}),
instanceByHost: t.drizzleField({
type: Instance,
args: {
Expand Down
46 changes: 45 additions & 1 deletion graphql/lookup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { isActor } from "@fedify/vocab";
import { persistActor } from "@hackerspub/models/actor";
import { isPostObject, persistPost } from "@hackerspub/models/post";
import type { Post } from "@hackerspub/models/schema";
import type { Actor, Post } from "@hackerspub/models/schema";
import type { UserContext } from "./builder.ts";

/**
Expand Down Expand Up @@ -54,3 +56,45 @@ export async function lookupPostByUrl(

return persisted ?? null;
}

/**
* Look up an actor by URL. Tries the local database first, matching the URL
* against the actor's canonical `iri` and falling back to the human-facing
* `url` (the latter is nullable and non-unique on the actor table, so the
* `iri` match takes precedence). Falls back to a federation lookup if
* nothing matches locally; returns the persisted actor row, or `null` when
* the URL doesn't resolve to a fediverse actor.
*/
export async function lookupActorByUrl(
ctx: UserContext,
parsed: URL,
): Promise<Actor | null> {
const url = parsed.href;

const byIri = await ctx.db.query.actorTable.findFirst({
where: { iri: url },
});
if (byIri != null) return byIri;

const byUrl = await ctx.db.query.actorTable.findFirst({
where: { url },
});
if (byUrl != null) return byUrl;

const documentLoader = ctx.account == null
? ctx.fedCtx.documentLoader
: await ctx.fedCtx.getDocumentLoader({
identifier: ctx.account.id,
});

let object;
try {
object = await ctx.fedCtx.lookupObject(url, { documentLoader });
} catch {
return null;
}

if (!isActor(object)) return null;

return (await persistActor(ctx.fedCtx, object, { documentLoader })) ?? null;
}
1 change: 1 addition & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ type Query {
allowLocalHandle: Boolean = false
handle: String!
): Actor
actorByUrl(url: URL!): Actor
actorByUuid(uuid: UUID!): Actor

"""
Expand Down
41 changes: 41 additions & 0 deletions web-next/src/components/ActorHoverCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createSignal, type JSX, Show } from "solid-js";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "~/components/ui/hover-card.tsx";
import { cn } from "~/lib/utils.ts";
import { ActorHoverCardLoader } from "./ActorHoverCardLoader.tsx";

export interface ActorHoverCardProps {
/** Canonical fediverse handle (e.g., `@user@host`). */
handle: string;
/**
* Extra classes for the trigger wrapper. Append `shrink-0` when wrapping a
* fixed-size avatar in a flex container so the wrapper itself does not
* collapse.
*/
class?: string;
children: JSX.Element;
}

export function ActorHoverCard(props: ActorHoverCardProps) {
const [open, setOpen] = createSignal(false);
return (
<HoverCard open={open()} onOpenChange={setOpen}>
<HoverCardTrigger
as="span"
class={cn("inline-flex self-start", props.class)}
role="presentation"
tabIndex={-1}
>
{props.children}
</HoverCardTrigger>
<HoverCardContent>
<Show when={open()}>
<ActorHoverCardLoader handle={props.handle} />
</Show>
</HoverCardContent>
</HoverCard>
);
}
94 changes: 94 additions & 0 deletions web-next/src/components/ActorHoverCardLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { graphql } from "relay-runtime";
import { ErrorBoundary, type JSX, Show } from "solid-js";
import {
createPreloadedQuery,
loadQuery,
useRelayEnvironment,
} from "solid-relay";
import { useLingui } from "~/lib/i18n/macro.d.ts";
import type { ActorHoverCardLoaderByHandleQuery } from "./__generated__/ActorHoverCardLoaderByHandleQuery.graphql.ts";
import type { ActorHoverCardLoaderByUrlQuery } from "./__generated__/ActorHoverCardLoaderByUrlQuery.graphql.ts";
import { ActorPreviewCard } from "./ActorPreviewCard.tsx";
import { ActorPreviewSkeleton } from "./ActorPreviewSkeleton.tsx";

const actorHoverCardLoaderByHandleQuery = graphql`
query ActorHoverCardLoaderByHandleQuery($handle: String!) {
actorByHandle(handle: $handle, allowLocalHandle: true) {
...ActorPreviewCard_actor
}
}
`;

const actorHoverCardLoaderByUrlQuery = graphql`
query ActorHoverCardLoaderByUrlQuery($url: URL!) {
actorByUrl(url: $url) {
...ActorPreviewCard_actor
}
}
`;

function Unavailable() {
const { t } = useLingui();
return (
<div class="p-4 text-sm text-muted-foreground">
{t`Could not load profile.`}
</div>
);
}

function withFallbacks(loaded: () => JSX.Element) {
return (
<ErrorBoundary fallback={() => <Unavailable />}>
{loaded()}
</ErrorBoundary>
);
}

export interface ActorHoverCardLoaderProps {
handle: string;
}

export function ActorHoverCardLoader(props: ActorHoverCardLoaderProps) {
const env = useRelayEnvironment();
const data = createPreloadedQuery<ActorHoverCardLoaderByHandleQuery>(
actorHoverCardLoaderByHandleQuery,
() =>
loadQuery(env(), actorHoverCardLoaderByHandleQuery, {
handle: props.handle,
}),
);

return withFallbacks(() => (
<Show when={data()} fallback={<ActorPreviewSkeleton />}>
{(loaded) => (
<Show when={loaded().actorByHandle} fallback={<Unavailable />}>
{(actor) => <ActorPreviewCard $actor={actor()} />}
</Show>
)}
</Show>
));
}
Comment thread
dahlia marked this conversation as resolved.

export interface ActorHoverCardLoaderByUrlProps {
url: string;
}

export function ActorHoverCardLoaderByUrl(
props: ActorHoverCardLoaderByUrlProps,
) {
const env = useRelayEnvironment();
const data = createPreloadedQuery<ActorHoverCardLoaderByUrlQuery>(
actorHoverCardLoaderByUrlQuery,
() => loadQuery(env(), actorHoverCardLoaderByUrlQuery, { url: props.url }),
);

return withFallbacks(() => (
<Show when={data()} fallback={<ActorPreviewSkeleton />}>
{(loaded) => (
<Show when={loaded().actorByUrl} fallback={<Unavailable />}>
{(actor) => <ActorPreviewCard $actor={actor()} />}
</Show>
)}
</Show>
));
}
Loading
Loading