feat: remove quick reply, streamline blog reply (v2.7.0)

Remove the quick-reply code path entirely — all replies now go through
Micropub as blog posts. Quick replies created orphan URLs that served
raw JSON-LD to browsers and caused unreadable links in conversations.

- Delete quick-reply controller (note-object.js) and route
- Remove ap_notes collection registration
- Simplify compose form: no mode toggle, no character counter
- Remove quick-reply CSS and locale strings

Confab-Link: http://localhost:8080/sessions/d116ad5b-ef8a-424e-9ebe-76c06bef1df6
This commit is contained in:
Ricardo
2026-03-04 17:33:02 +01:00
parent ec41fec366
commit 7611dba40f
7 changed files with 9 additions and 266 deletions

View File

@@ -976,34 +976,6 @@
gap: var(--space-m);
}
.ap-compose__mode {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-m);
}
.ap-compose__mode legend {
font-weight: 600;
}
.ap-compose__mode-option {
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.ap-compose__mode-hint {
color: var(--color-on-offset);
display: block;
font-size: var(--font-size-s);
margin-left: 1.5em;
width: 100%;
}
.ap-compose__editor {
position: relative;
}
@@ -1027,21 +999,6 @@
outline-offset: -2px;
}
.ap-compose__counter {
font-size: var(--font-size-s);
padding-top: var(--space-xs);
text-align: right;
}
.ap-compose__counter--warn {
color: var(--color-yellow50);
}
.ap-compose__counter--over {
color: var(--color-error);
font-weight: 600;
}
.ap-compose__syndication {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
@@ -2837,11 +2794,6 @@
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.06);
}
/* --- Compose counter warning --- */
.ap-compose__counter--warn {
color: var(--color-yellow90);
}
/* --- Tab badge federated: soften purple --- */
.ap-tab__badge--federated {
color: var(--color-purple90);

View File

@@ -80,7 +80,6 @@ import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.j
import { publicProfileController } from "./lib/controllers/public-profile.js";
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
import { myProfileController } from "./lib/controllers/my-profile.js";
import { noteObjectController } from "./lib/controllers/note-object.js";
import {
refollowPauseController,
refollowResumeController,
@@ -189,10 +188,6 @@ export default class ActivityPubEndpoint {
return self._fedifyMiddleware(req, res, next);
});
// Serve stored quick reply Notes as JSON-LD so remote servers can
// dereference the Note ID during Create activity verification.
router.get("/quick-replies/:id", noteObjectController(self));
// Authorize interaction — remote follow / subscribe endpoint.
// Remote servers redirect users here via the WebFinger subscribe template.
router.get("/authorize_interaction", authorizeInteractionController(self));
@@ -889,7 +884,6 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_muted");
Indiekit.addCollection("ap_blocked");
Indiekit.addCollection("ap_interactions");
Indiekit.addCollection("ap_notes");
Indiekit.addCollection("ap_followed_tags");
// Explore tab collections
Indiekit.addCollection("ap_explore_tabs");
@@ -911,7 +905,6 @@ export default class ActivityPubEndpoint {
ap_muted: indiekitCollections.get("ap_muted"),
ap_blocked: indiekitCollections.get("ap_blocked"),
ap_interactions: indiekitCollections.get("ap_interactions"),
ap_notes: indiekitCollections.get("ap_notes"),
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
// Explore tab collections
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),

View File

@@ -1,11 +1,9 @@
/**
* Compose controllers — reply form via Micropub or direct AP.
* Compose controllers — reply form via Micropub.
*/
import { Temporal } from "@js-temporal/polyfill";
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { resolveAuthor } from "../resolve-author.js";
/**
* Fetch syndication targets from the Micropub config endpoint.
@@ -155,7 +153,7 @@ export function composeController(mountPath, plugin) {
}
/**
* POST /admin/reader/compose — Submit reply via Micropub or direct AP.
* POST /admin/reader/compose — Submit reply via Micropub.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
@@ -170,7 +168,7 @@ export function submitComposeController(mountPath, plugin) {
}
const { application } = request.app.locals;
const { content, mode } = request.body;
const { content } = request.body;
const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-to"];
@@ -181,122 +179,7 @@ export function submitComposeController(mountPath, plugin) {
});
}
// Quick reply — direct AP
if (mode === "quick") {
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const { Create, Note } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const uuid = crypto.randomUUID();
const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
const noteId = `${baseUrl}/activitypub/quick-replies/${uuid}`;
const actorUri = ctx.getActorUri(handle);
const publicAddress = new URL(
"https://www.w3.org/ns/activitystreams#Public",
);
const followersUri = ctx.getFollowersUri(handle);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
// Resolve the original author BEFORE constructing the Note,
// so we can include them in cc (required for threading/notification)
let recipient = null;
if (inReplyTo) {
recipient = await resolveAuthor(
inReplyTo,
ctx,
documentLoader,
application?.collections,
);
}
// Build cc list: always include followers, add original author for replies
const ccList = [followersUri];
if (recipient?.id) {
ccList.push(recipient.id);
}
const note = new Note({
id: new URL(noteId),
attribution: actorUri,
content: content.trim(),
replyTarget: inReplyTo ? new URL(inReplyTo) : undefined,
published: Temporal.Now.instant(),
to: publicAddress,
ccs: ccList,
});
const create = new Create({
id: new URL(`${noteId}#activity`),
actor: actorUri,
object: note,
to: publicAddress,
ccs: ccList,
});
// Store the Note so remote servers can dereference its ID
const ap_notes = application?.collections?.get("ap_notes");
if (ap_notes) {
await ap_notes.insertOne({
_id: uuid,
noteId,
actorUrl: actorUri.href,
content: content.trim(),
inReplyTo: inReplyTo || null,
published: new Date().toISOString(),
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: ccList.map((u) => (u instanceof URL ? u.href : u.href || u)),
});
}
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", create, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: noteId,
});
// Also send directly to the original author's inbox
if (recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
recipient,
create,
{ orderingKey: noteId },
);
console.info(
`[ActivityPub] Sent quick reply directly to ${recipient.id?.href || "author"}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Direct delivery to author failed (quick reply):`,
error.message,
);
}
}
console.info(
`[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
);
return response.redirect(`${mountPath}/admin/reader`);
}
// Micropub path — post as blog reply
// Post as blog reply via Micropub
const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) {

View File

@@ -1,51 +0,0 @@
/**
* Public route handler for serving quick reply Notes as ActivityPub JSON-LD.
*
* Remote servers dereference Note IDs to verify Create activities.
* Without this, quick replies are rejected by servers that validate
* the Note's ID URL (Mastodon with Authorized Fetch, Bonfire, etc.).
*/
/**
* GET /quick-replies/:id — serve a stored Note as JSON-LD.
* @param {object} plugin - ActivityPub plugin instance
*/
export function noteObjectController(plugin) {
return async (request, response) => {
const { id } = request.params;
const { application } = request.app.locals;
const ap_notes = application?.collections?.get("ap_notes");
if (!ap_notes) {
return response.status(404).json({ error: "Not Found" });
}
const note = await ap_notes.findOne({ _id: id });
if (!note) {
return response.status(404).json({ error: "Not Found" });
}
const noteJson = {
"@context": "https://www.w3.org/ns/activitystreams",
id: note.noteId,
type: "Note",
attributedTo: note.actorUrl,
content: note.content,
published: note.published,
to: note.to,
cc: note.cc,
};
if (note.inReplyTo) {
noteJson.inReplyTo = note.inReplyTo;
}
response
.status(200)
.set("Content-Type", "application/activity+json; charset=utf-8")
.set("Cache-Control", "public, max-age=3600")
.json(noteJson);
};
}

View File

@@ -141,15 +141,9 @@
},
"compose": {
"title": "Compose reply",
"modeLabel": "Reply mode",
"modeMicropub": "Post as blog reply",
"modeMicropubHint": "Creates a permanent post on your blog, syndicated to the fediverse",
"modeQuick": "Quick reply",
"modeQuickHint": "Sends a reply directly to the fediverse (no blog post created)",
"placeholder": "Write your reply…",
"syndicateLabel": "Syndicate to",
"submitMicropub": "Post reply",
"submitQuick": "Send reply",
"cancel": "Cancel",
"errorEmpty": "Reply content cannot be empty"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.6.2",
"version": "2.7.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",

View File

@@ -21,50 +21,23 @@
</div>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form"
x-data="{
mode: 'micropub',
content: '',
maxChars: 500,
get remaining() { return this.maxChars - this.content.length; }
}">
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{% if replyTo %}
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
{% endif %}
{# Mode toggle #}
<fieldset class="ap-compose__mode">
<legend>{{ __("activitypub.compose.modeLabel") }}</legend>
<label class="ap-compose__mode-option">
<input type="radio" name="mode" value="micropub" x-model="mode" checked>
{{ __("activitypub.compose.modeMicropub") }}
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeMicropubHint") }}</span>
</label>
<label class="ap-compose__mode-option">
<input type="radio" name="mode" value="quick" x-model="mode">
{{ __("activitypub.compose.modeQuick") }}
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeQuickHint") }}</span>
</label>
</fieldset>
{# Content textarea #}
<div class="ap-compose__editor">
<textarea name="content" class="ap-compose__textarea"
rows="6"
:maxlength="mode === 'quick' ? maxChars : undefined"
x-model="content"
placeholder="{{ __('activitypub.compose.placeholder') }}"
required></textarea>
<div class="ap-compose__counter" x-show="mode === 'quick'" x-cloak>
<span :class="{ 'ap-compose__counter--warn': remaining < 50, 'ap-compose__counter--over': remaining < 0 }"
x-text="remaining"></span>
</div>
</div>
{# Syndication targets (Micropub mode only) #}
{# Syndication targets #}
{% if syndicationTargets.length > 0 %}
<fieldset class="ap-compose__syndication" x-show="mode === 'micropub'">
<fieldset class="ap-compose__syndication">
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
{% for target in syndicationTargets %}
<label class="ap-compose__syndication-target">
@@ -77,8 +50,7 @@
<div class="ap-compose__actions">
<button type="submit" class="ap-compose__submit">
<span x-show="mode === 'micropub'">{{ __("activitypub.compose.submitMicropub") }}</span>
<span x-show="mode === 'quick'">{{ __("activitypub.compose.submitQuick") }}</span>
{{ __("activitypub.compose.submitMicropub") }}
</button>
<a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel">
{{ __("activitypub.compose.cancel") }}