Files
indiekit-blog/js/fediverse-interact.js
svemagie 2e67156bfd fix: resolve AP object URL before authorize_interaction redirect
The "Also on fediverse" widget was passing the blog post URL directly
to authorize_interaction. If a static file server intercepts the request
before Node.js, the remote instance gets HTML instead of AP JSON and
shows "Could not connect to the given address".

Now fetches /activitypub/api/ap-url first to get the Fedify-served AP
object URL (/activitypub/objects/…), which is always routed to Node.js
and reliably returns AP JSON. Falls back to the original URL on error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 15:00:32 +01:00

166 lines
5.0 KiB
JavaScript

/**
* Fediverse interaction & sharing component (Alpine.js)
*
* Two modes:
* - "interact" (default): redirect to authorize_interaction for like/boost/reply/follow
* - "share": redirect to /share?text=... for composing a new post
*
* Stores multiple domains in localStorage with usage tracking.
* Registered via Alpine.data() so the component is available
* regardless of script loading order.
*/
const STORAGE_KEY = "fediverse_domains_v1";
const OLD_STORAGE_KEY = "fediverse_instance";
function loadDomains() {
try {
const json = localStorage.getItem(STORAGE_KEY);
if (json) return JSON.parse(json);
} catch { /* corrupted data */ }
// Migrate from old single-domain key
const old = localStorage.getItem(OLD_STORAGE_KEY);
if (old) {
const domains = [{ domain: old, used: 1, lastUsed: new Date().toISOString() }];
localStorage.setItem(STORAGE_KEY, JSON.stringify(domains));
localStorage.removeItem(OLD_STORAGE_KEY);
return domains;
}
return [];
}
function saveDomains(domains) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(domains));
}
function addDomain(domain) {
const domains = loadDomains();
const existing = domains.find((d) => d.domain === domain);
if (existing) {
existing.used += 1;
existing.lastUsed = new Date().toISOString();
} else {
domains.push({ domain, used: 1, lastUsed: new Date().toISOString() });
}
saveDomains(domains);
return domains;
}
function removeDomain(domain) {
const domains = loadDomains().filter((d) => d.domain !== domain);
saveDomains(domains);
return domains;
}
function isValidDomain(str) {
try {
return new URL(`https://${str}`).hostname === str;
} catch {
return false;
}
}
document.addEventListener("alpine:init", () => {
Alpine.data("fediverseInteract", (targetUrl, mode) => ({
targetUrl,
mode: mode || "interact",
showModal: false,
instance: "",
savedDomains: [],
showInput: false,
error: "",
handleClick(event) {
event.preventDefault();
this.savedDomains = loadDomains().sort((a, b) => b.used - a.used);
if (this.savedDomains.length === 1 && !event.shiftKey) {
addDomain(this.savedDomains[0].domain);
this.redirectToInstance(this.savedDomains[0].domain);
return;
}
if (this.savedDomains.length === 0) {
this.showInput = true;
} else {
this.showInput = false;
}
this.instance = "";
this.error = "";
this.showModal = true;
},
showAddNew() {
this.showInput = true;
this.instance = "";
this.error = "";
this.$nextTick(() => {
const input = this.$refs.instanceInput;
if (input) input.focus();
});
},
confirm() {
let domain = this.instance.trim();
if (!domain) return;
// Strip protocol and trailing slashes
domain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
if (!isValidDomain(domain)) {
this.error = "Please enter a valid domain (e.g. mastodon.social)";
return;
}
this.error = "";
this.savedDomains = addDomain(domain);
this.showModal = false;
this.redirectToInstance(domain);
},
useSaved(domain) {
this.savedDomains = addDomain(domain);
this.showModal = false;
this.redirectToInstance(domain);
},
deleteSaved(domain) {
this.savedDomains = removeDomain(domain);
if (this.savedDomains.length === 0) {
this.showInput = true;
}
},
trapFocus(event) {
const focusable = [...this.$el.querySelectorAll('button, input, a, [tabindex]:not([tabindex="-1"])')].filter(el => !el.closest('[x-show]') || el.closest('[x-show]').style.display !== 'none');
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
},
async redirectToInstance(domain) {
if (this.mode === "share") {
window.location.href = `https://${domain}/share?text=${encodeURIComponent(this.targetUrl)}`;
} else {
// Resolve the blog post URL to its Fedify-served AP object URL.
// Fedify URLs (/activitypub/objects/…) are always routed to Node.js,
// ensuring reliable AP content negotiation when the remote instance
// fetches the URI to process authorize_interaction.
let interactUrl = this.targetUrl;
try {
const resp = await fetch(`/activitypub/api/ap-url?post=${encodeURIComponent(this.targetUrl)}`);
if (resp.ok) {
const data = await resp.json();
if (data.apUrl) interactUrl = data.apUrl;
}
} catch { /* network error — fall back to blog post URL */ }
window.location.href = `https://${domain}/authorize_interaction?uri=${encodeURIComponent(interactUrl)}`;
}
},
}));
});