feat: add fediverse URL/handle lookup input to reader

Adds a search box at the top of the reader page where users can paste
any fediverse URL or @user@domain handle. Uses Fedify's lookupObject()
which natively resolves URLs, handles, and acct: URIs, then redirects
to the internal post detail or remote profile view.
This commit is contained in:
Ricardo
2026-02-21 21:33:08 +01:00
parent 0cf49e037c
commit cf284e8633
5 changed files with 173 additions and 1 deletions

View File

@@ -4,6 +4,54 @@
* Uses Indiekit CSS custom properties for automatic dark mode support
*/
/* ==========================================================================
Fediverse Lookup
========================================================================== */
.ap-lookup {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
}
.ap-lookup__input {
flex: 1;
padding: var(--space-s) var(--space-m);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
color: var(--color-on-background);
font-size: var(--font-size-m);
font-family: inherit;
}
.ap-lookup__input::placeholder {
color: var(--color-on-offset);
}
.ap-lookup__input:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: var(--color-primary);
}
.ap-lookup__btn {
padding: var(--space-s) var(--space-m);
border: var(--border-width-thin) solid var(--color-primary);
border-radius: var(--border-radius-small);
background: var(--color-primary);
color: var(--color-on-primary);
font-size: var(--font-size-m);
font-family: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.ap-lookup__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Tab Navigation
========================================================================== */

View File

@@ -57,6 +57,7 @@ import {
featuredTagsAddController,
featuredTagsRemoveController,
} from "./lib/controllers/featured-tags.js";
import { resolveController } from "./lib/controllers/resolve.js";
import {
refollowPauseController,
refollowResumeController,
@@ -202,6 +203,7 @@ export default class ActivityPubEndpoint {
router.post("/admin/reader/unlike", unlikeController(mp, this));
router.post("/admin/reader/boost", boostController(mp, this));
router.post("/admin/reader/unboost", unboostController(mp, this));
router.get("/admin/reader/resolve", resolveController(mp, this));
router.get("/admin/reader/profile", remoteProfileController(mp, this));
router.get("/admin/reader/post", postDetailController(mp, this));
router.post("/admin/reader/follow", followController(mp, this));

109
lib/controllers/resolve.js Normal file
View File

@@ -0,0 +1,109 @@
/**
* Resolve controller — accepts any fediverse URL or handle, resolves it
* via lookupObject(), and redirects to the appropriate internal view.
*/
import {
Article,
Note,
Person,
Service,
Application,
Organization,
Group,
} from "@fedify/fedify";
/**
* GET /admin/reader/resolve?q=<url-or-handle>
* Resolves a fediverse URL or @user@domain handle and redirects to
* the post detail or remote profile view.
*/
export function resolveController(mountPath, plugin) {
return async (request, response, next) => {
try {
const query = (request.query.q || "").trim();
if (!query) {
return response.redirect(`${mountPath}/admin/reader`);
}
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
// Determine if input is a URL or a handle
// lookupObject accepts: URLs, @user@domain, user@domain, acct:user@domain
let lookupInput;
try {
// If it parses as a URL, pass as URL object
const parsed = new URL(query);
lookupInput = parsed;
} catch {
// Not a URL — treat as handle (strip leading @ if present)
lookupInput = query;
}
let object;
try {
object = await ctx.lookupObject(lookupInput, { documentLoader });
} catch (error) {
console.warn(
`[resolve] lookupObject failed for "${query}":`,
error.message,
);
}
if (!object) {
return response.status(404).render("error", {
title: response.locals.__("activitypub.reader.resolve.notFoundTitle"),
content: response.locals.__(
"activitypub.reader.resolve.notFound",
),
});
}
// Determine object type and redirect accordingly
const objectUrl =
object.id?.href || object.url?.href || query;
if (
object instanceof Person ||
object instanceof Service ||
object instanceof Application ||
object instanceof Organization ||
object instanceof Group
) {
return response.redirect(
`${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`,
);
}
if (object instanceof Note || object instanceof Article) {
return response.redirect(
`${mountPath}/admin/reader/post?url=${encodeURIComponent(objectUrl)}`,
);
}
// Unknown type — try post detail as fallback
return response.redirect(
`${mountPath}/admin/reader/post?url=${encodeURIComponent(objectUrl)}`,
);
} catch (error) {
next(error);
}
};
}

View File

@@ -191,6 +191,13 @@
"loadingThread": "Loading thread...",
"threadError": "Could not load full thread"
},
"resolve": {
"placeholder": "Paste a fediverse URL or @user@domain handle…",
"label": "Look up a fediverse post or account",
"button": "Look up",
"notFoundTitle": "Not found",
"notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted."
},
"linkPreview": {
"label": "Link preview"
}

View File

@@ -4,7 +4,13 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
{# Fediverse lookup #}
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
<input type="text" name="q" class="ap-lookup__input"
placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
aria-label="{{ __('activitypub.reader.resolve.label') }}">
<button type="submit" class="ap-lookup__btn">{{ __("activitypub.reader.resolve.button") }}</button>
</form>
{# Tab navigation #}
<nav class="ap-tabs" role="tablist">