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:
@@ -4,6 +4,54 @@
|
|||||||
* Uses Indiekit CSS custom properties for automatic dark mode support
|
* 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
|
Tab Navigation
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
featuredTagsAddController,
|
featuredTagsAddController,
|
||||||
featuredTagsRemoveController,
|
featuredTagsRemoveController,
|
||||||
} from "./lib/controllers/featured-tags.js";
|
} from "./lib/controllers/featured-tags.js";
|
||||||
|
import { resolveController } from "./lib/controllers/resolve.js";
|
||||||
import {
|
import {
|
||||||
refollowPauseController,
|
refollowPauseController,
|
||||||
refollowResumeController,
|
refollowResumeController,
|
||||||
@@ -202,6 +203,7 @@ export default class ActivityPubEndpoint {
|
|||||||
router.post("/admin/reader/unlike", unlikeController(mp, this));
|
router.post("/admin/reader/unlike", unlikeController(mp, this));
|
||||||
router.post("/admin/reader/boost", boostController(mp, this));
|
router.post("/admin/reader/boost", boostController(mp, this));
|
||||||
router.post("/admin/reader/unboost", unboostController(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/profile", remoteProfileController(mp, this));
|
||||||
router.get("/admin/reader/post", postDetailController(mp, this));
|
router.get("/admin/reader/post", postDetailController(mp, this));
|
||||||
router.post("/admin/reader/follow", followController(mp, this));
|
router.post("/admin/reader/follow", followController(mp, this));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -191,6 +191,13 @@
|
|||||||
"loadingThread": "Loading thread...",
|
"loadingThread": "Loading thread...",
|
||||||
"threadError": "Could not load full 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": {
|
"linkPreview": {
|
||||||
"label": "Link preview"
|
"label": "Link preview"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% 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 #}
|
{# Tab navigation #}
|
||||||
<nav class="ap-tabs" role="tablist">
|
<nav class="ap-tabs" role="tablist">
|
||||||
|
|||||||
Reference in New Issue
Block a user