diff --git a/graphql/actor.more.test.ts b/graphql/actor.more.test.ts index c5b0bd015..9e1d39635 100644 --- a/graphql/actor.more.test.ts +++ b/graphql/actor.more.test.ts @@ -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, @@ -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) { @@ -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, { diff --git a/graphql/actor.ts b/graphql/actor.ts index 9760bea4f..2cee782bc 100644 --- a/graphql/actor.ts +++ b/graphql/actor.ts @@ -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"; @@ -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: { diff --git a/graphql/lookup.ts b/graphql/lookup.ts index 9c9b56973..881894f4b 100644 --- a/graphql/lookup.ts +++ b/graphql/lookup.ts @@ -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"; /** @@ -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 { + 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; +} diff --git a/graphql/schema.graphql b/graphql/schema.graphql index fd4704bed..7e1087c3c 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1101,6 +1101,7 @@ type Query { allowLocalHandle: Boolean = false handle: String! ): Actor + actorByUrl(url: URL!): Actor actorByUuid(uuid: UUID!): Actor """ diff --git a/web-next/src/components/ActorHoverCard.tsx b/web-next/src/components/ActorHoverCard.tsx new file mode 100644 index 000000000..b0efb9e55 --- /dev/null +++ b/web-next/src/components/ActorHoverCard.tsx @@ -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 ( + + + {props.children} + + + + + + + + ); +} diff --git a/web-next/src/components/ActorHoverCardLoader.tsx b/web-next/src/components/ActorHoverCardLoader.tsx new file mode 100644 index 000000000..9a9f0c00a --- /dev/null +++ b/web-next/src/components/ActorHoverCardLoader.tsx @@ -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 ( +
+ {t`Could not load profile.`} +
+ ); +} + +function withFallbacks(loaded: () => JSX.Element) { + return ( + }> + {loaded()} + + ); +} + +export interface ActorHoverCardLoaderProps { + handle: string; +} + +export function ActorHoverCardLoader(props: ActorHoverCardLoaderProps) { + const env = useRelayEnvironment(); + const data = createPreloadedQuery( + actorHoverCardLoaderByHandleQuery, + () => + loadQuery(env(), actorHoverCardLoaderByHandleQuery, { + handle: props.handle, + }), + ); + + return withFallbacks(() => ( + }> + {(loaded) => ( + }> + {(actor) => } + + )} + + )); +} + +export interface ActorHoverCardLoaderByUrlProps { + url: string; +} + +export function ActorHoverCardLoaderByUrl( + props: ActorHoverCardLoaderByUrlProps, +) { + const env = useRelayEnvironment(); + const data = createPreloadedQuery( + actorHoverCardLoaderByUrlQuery, + () => loadQuery(env(), actorHoverCardLoaderByUrlQuery, { url: props.url }), + ); + + return withFallbacks(() => ( + }> + {(loaded) => ( + }> + {(actor) => } + + )} + + )); +} diff --git a/web-next/src/components/ActorPreviewCard.tsx b/web-next/src/components/ActorPreviewCard.tsx new file mode 100644 index 000000000..d5efd1c6e --- /dev/null +++ b/web-next/src/components/ActorPreviewCard.tsx @@ -0,0 +1,157 @@ +import { graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { createFragment } from "solid-relay"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "~/components/ui/avatar.tsx"; +import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; +import type { ActorPreviewCard_actor$key } from "./__generated__/ActorPreviewCard_actor.graphql.ts"; +import { FollowButton } from "./FollowButton.tsx"; +import { InternalLink } from "./InternalLink.tsx"; + +export interface ActorPreviewCardProps { + $actor: ActorPreviewCard_actor$key; +} + +export function ActorPreviewCard(props: ActorPreviewCardProps) { + const { t, i18n } = useLingui(); + const actor = createFragment( + graphql` + fragment ActorPreviewCard_actor on Actor { + id + name + username + handle + avatarUrl + avatarInitials + bio + local + url + iri + followsViewer + followeesCount: followees { + totalCount + } + followersCount: followers { + totalCount + } + ...FollowButton_actor + } + `, + () => props.$actor, + ); + + return ( + + {(a) => { + const actorHref = () => a().url ?? a().iri; + const actorInternalHref = () => + a().local ? `/@${a().username}` : `/${a().handle}`; + const followingText = () => + i18n._( + msg`${ + plural(a().followeesCount.totalCount, { + one: "# following", + other: "# following", + }) + }`, + ); + const followersText = () => + i18n._( + msg`${ + plural(a().followersCount.totalCount, { + one: "# follower", + other: "# followers", + }) + }`, + ); + return ( +
+
+ + + + + {a().avatarInitials} + + + +
+ + {a().username} + + } + > + + + + {a().handle} + +
+
+ +
+
+ +
+
+
+ +
+ {followingText()}} + > + + {followingText()} + + + {" · "} + {followersText()}} + > + + {followersText()} + + + + {" · "} + {t`Following you`} + +
+
+ ); + }} +
+ ); +} diff --git a/web-next/src/components/ActorPreviewSkeleton.tsx b/web-next/src/components/ActorPreviewSkeleton.tsx new file mode 100644 index 000000000..83d985a1b --- /dev/null +++ b/web-next/src/components/ActorPreviewSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "~/components/ui/skeleton.tsx"; + +export function ActorPreviewSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + + +
+ +
+ ); +} diff --git a/web-next/src/components/ArticleCard.tsx b/web-next/src/components/ArticleCard.tsx index af44c13a9..354f46330 100644 --- a/web-next/src/components/ArticleCard.tsx +++ b/web-next/src/components/ArticleCard.tsx @@ -7,10 +7,15 @@ import { AvatarImage, } from "~/components/ui/avatar.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; +import { + MentionHoverCardLayer, + useMentionHoverCards, +} from "~/lib/mentionHoverCards.tsx"; import { ArticleCard_article$key, } from "./__generated__/ArticleCard_article.graphql.ts"; import { ArticleCardInternal_article$key } from "./__generated__/ArticleCardInternal_article.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { ArticleControls } from "./ArticleControls.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { PostActionMenu } from "./PostActionMenu.tsx"; @@ -43,9 +48,12 @@ export function ArticleCard(props: ArticleCardProps) { () => props.$article, ); const [hover, setHover] = createSignal(false); + const [articleRef, setArticleRef] = createSignal(); + const mentionState = useMentionHoverCards(articleRef); return (
@@ -86,6 +94,7 @@ export function ArticleCard(props: ArticleCardProps) { )} +
); } @@ -144,21 +153,29 @@ function ArticleCardInternal(props: ArticleCardInternalProps) { {(article) => ( <>
- - - - - {article().actor.avatarInitials} - - - + + + + + + {article().actor.avatarInitials} + + + +
-
+ {article().actor.handle} -
+
props.$note, ); + const [proseRef, setProseRef] = createSignal(); + const mentionState = useMentionHoverCards(proseRef); + return ( {(n) => ( @@ -53,10 +60,12 @@ export function NoteCardInternal(props: NoteCardInternalProps) { onDeleted={props.onDeleted} />
+ diff --git a/web-next/src/components/NoteHeader.tsx b/web-next/src/components/NoteHeader.tsx index d40f983df..c7512848a 100644 --- a/web-next/src/components/NoteHeader.tsx +++ b/web-next/src/components/NoteHeader.tsx @@ -2,6 +2,7 @@ import { graphql } from "relay-runtime"; import { Show } from "solid-js"; import { createFragment } from "solid-relay"; import { NoteHeader_note$key } from "./__generated__/NoteHeader_note.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { PostActionMenu } from "./PostActionMenu.tsx"; import { Timestamp } from "./Timestamp.tsx"; @@ -41,23 +42,27 @@ export function NoteHeader(props: NoteHeaderProps) { {(n) => (
- - - {" "} - - - {n().actor.handle} - + + + + + {n().actor.handle} + + ( {(firstActor) => ( - - - {firstActor().handle} - - } - > - {(name) => ( - - {" "} - - ({firstActor().handle}) + + + + {firstActor().handle} - - )} - - + } + > + {(name) => ( + + {" "} + + ({firstActor().handle}) + + + )} + + + )} )} diff --git a/web-next/src/components/PostAvatar.tsx b/web-next/src/components/PostAvatar.tsx index cc510735f..d628594c6 100644 --- a/web-next/src/components/PostAvatar.tsx +++ b/web-next/src/components/PostAvatar.tsx @@ -2,6 +2,7 @@ import { graphql } from "relay-runtime"; import { Show } from "solid-js"; import { createFragment } from "solid-relay"; import { PostAvatar_actor$key } from "./__generated__/PostAvatar_actor.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar.tsx"; @@ -28,15 +29,17 @@ export function PostAvatar(props: PostAvatarProps) { return ( {(a) => ( - - - - {a().avatarInitials} - - + + + + + {a().avatarInitials} + + + )} ); diff --git a/web-next/src/components/PostSharer.tsx b/web-next/src/components/PostSharer.tsx index 804181afa..ca4582e7d 100644 --- a/web-next/src/components/PostSharer.tsx +++ b/web-next/src/components/PostSharer.tsx @@ -3,6 +3,7 @@ import { Show } from "solid-js"; import { createFragment } from "solid-relay"; import { useLingui } from "~/lib/i18n/macro.d.ts"; import { PostSharer_post$key } from "./__generated__/PostSharer_post.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { Timestamp } from "./Timestamp.tsx"; import { Trans } from "./Trans.tsx"; @@ -36,16 +37,18 @@ export function PostSharer(props: PostSharerProps) { message={t`${"SHARER"} shared ${"RELATIVE_TIME"}`} values={{ SHARER: () => ( - - {post().actor.name} - + + + {post().actor.name} + + ), RELATIVE_TIME: () => , }} diff --git a/web-next/src/components/ProfileCard.tsx b/web-next/src/components/ProfileCard.tsx index 216d519e9..4737238fe 100644 --- a/web-next/src/components/ProfileCard.tsx +++ b/web-next/src/components/ProfileCard.tsx @@ -1,6 +1,6 @@ import { compactUrl } from "@hackerspub/models/url"; import { graphql } from "relay-runtime"; -import { For, Show } from "solid-js"; +import { createSignal, For, Show } from "solid-js"; import { createFragment } from "solid-relay"; import { Avatar, @@ -13,6 +13,10 @@ import { TooltipTrigger, } from "~/components/ui/tooltip.tsx"; import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; +import { + MentionHoverCardLayer, + useMentionHoverCards, +} from "~/lib/mentionHoverCards.tsx"; import type { ProfileCard_actor$key } from "./__generated__/ProfileCard_actor.graphql.ts"; import { FollowButton } from "./FollowButton.tsx"; import { ProfileActionMenu } from "./ProfileActionMenu.tsx"; @@ -25,6 +29,8 @@ export interface ProfileCardProps { export function ProfileCard(props: ProfileCardProps) { const { t, i18n } = useLingui(); + const [bioRef, setBioRef] = createSignal(); + const mentionState = useMentionHoverCards(bioRef); const actor = createFragment( graphql` fragment ProfileCard_actor on Actor { @@ -131,9 +137,11 @@ export function ProfileCard(props: ProfileCardProps) {
+
( - - {q().actor.name} - + + + {q().actor.name} + + ), RELATIVE_TIME: () => , }} @@ -118,6 +125,9 @@ interface QuestionCardContentProps { } function QuestionCardContent(props: QuestionCardContentProps) { + const [proseRef, setProseRef] = createSignal(); + const mentionState = useMentionHoverCards(proseRef); + const question = createFragment( graphql` fragment QuestionCardContent_question on Question { @@ -218,23 +228,27 @@ function QuestionCardContent(props: QuestionCardContentProps) {
- - - {" "} - - - {q().actor.handle} - + + + + + {q().actor.handle} + +
+ {(poll) => ( (); + const mentionState = useMentionHoverCards(proseRef); + const note = createFragment( graphql` fragment QuotedNoteCard_note on Note { @@ -46,18 +54,23 @@ export function QuotedNoteCard(props: QuotedNoteCardProps) {