diff --git a/assets/reader.css b/assets/reader.css index 0a67157..435e8ff 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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 ========================================================================== */ diff --git a/index.js b/index.js index 7227baa..3878a88 100644 --- a/index.js +++ b/index.js @@ -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)); diff --git a/lib/controllers/resolve.js b/lib/controllers/resolve.js new file mode 100644 index 0000000..8702ef7 --- /dev/null +++ b/lib/controllers/resolve.js @@ -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= + * 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); + } + }; +} diff --git a/locales/en.json b/locales/en.json index 6d3fede..d3307ec 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" } diff --git a/views/activitypub-reader.njk b/views/activitypub-reader.njk index bd56d7c..1bdbcd0 100644 --- a/views/activitypub-reader.njk +++ b/views/activitypub-reader.njk @@ -4,7 +4,13 @@ {% from "prose/macro.njk" import prose with context %} {% block readercontent %} - {{ heading({ text: __("activitypub.reader.title"), level: 1 }) }} + {# Fediverse lookup #} +
+ + +
{# Tab navigation #}