Allow Mastodon to Quote Post

This commit is contained in:
Tim Zöller 2026-04-08 12:48:12 +02:00
parent 07fbcf8064
commit a8b601233b
4 changed files with 243 additions and 103 deletions

View file

@ -426,9 +426,13 @@ public class ActivityPubController {
String actorUri = baseUrl + "/users/" + user.getUsername(); String actorUri = baseUrl + "/users/" + user.getUsername();
String activityUri = baseUrl + "/activities/" + activity.getId(); String activityUri = baseUrl + "/activities/" + activity.getId();
// Build the Note object (same format as used in federation) // Build the Note object (same format as used in federation).
// The extended JSON-LD context declares Mastodon's interaction-policy
// extension fields so the `interactionPolicy` field below is parsed
// by Mastodon receivers (otherwise they fall back to denying quotes
// for cross-server posts).
Map<String, Object> noteObject = new HashMap<>(); Map<String, Object> noteObject = new HashMap<>();
noteObject.put("@context", "https://www.w3.org/ns/activitystreams"); noteObject.put("@context", net.javahippie.fitpub.util.ActivityPubContexts.extendedContext());
noteObject.put("id", activityUri); noteObject.put("id", activityUri);
noteObject.put("type", "Note"); noteObject.put("type", "Note");
noteObject.put("attributedTo", actorUri); noteObject.put("attributedTo", actorUri);
@ -436,10 +440,17 @@ public class ActivityPubController {
noteObject.put("content", formatActivityContent(activity)); noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", activityUri); noteObject.put("url", activityUri);
// Audience // Audience only PUBLIC activities reach this endpoint (the visibility
// check above returned 403 for anything else), so audience is always
// the AS Public collection.
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
noteObject.put("cc", List.of(actorUri + "/followers")); noteObject.put("cc", List.of(actorUri + "/followers"));
// Allow public quotes (matches the audience).
noteObject.put("interactionPolicy",
net.javahippie.fitpub.util.ActivityPubContexts.quotePolicyAllowing(
"https://www.w3.org/ns/activitystreams#Public"));
// Extract hashtags from user text and add as tags // Extract hashtags from user text and add as tags
List<String> hashtags = extractHashtags(activity); List<String> hashtags = extractHashtags(activity);
if (!hashtags.isEmpty()) { if (!hashtags.isEmpty()) {
@ -489,49 +500,74 @@ public class ActivityPubController {
* Format activity content as HTML for ActivityPub. * Format activity content as HTML for ActivityPub.
* Mastodon and most Fediverse software expect HTML in the content field. * Mastodon and most Fediverse software expect HTML in the content field.
*/ */
/**
* Format activity content as HTML for the ActivityPub Note. Output is
* intentionally minimal because the bulk of the activity data is already
* in the share image attachment (and its alt text). The body provides:
* the title (bold), a one-line context summary as a fallback for clients
* that don't render image attachments, and the user-written description
* (with hashtags linkified).
*/
private String formatActivityContent(Activity activity) { private String formatActivityContent(Activity activity) {
StringBuilder content = new StringBuilder(); StringBuilder content = new StringBuilder();
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
content.append("<p><strong>").append(linkifyHashtags(escapeHtml(activity.getTitle()))).append("</strong></p>"); content.append("<p><strong>")
.append(linkifyHashtags(escapeHtml(activity.getTitle())))
.append("</strong></p>");
}
// One-line summary (sport · distance · duration). Fallback for clients
// that don't show the image attachment, and a quick at-a-glance line
// even for clients that do.
String summary = buildSummaryLine(activity);
if (!summary.isEmpty()) {
content.append("<p>").append(summary).append("</p>");
} }
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) { if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
content.append("<p>").append(linkifyHashtags(escapeHtml(activity.getDescription()))).append("</p>"); content.append("<p>")
} .append(linkifyHashtags(escapeHtml(activity.getDescription())))
.append("</p>");
String activityEmoji = getActivityEmoji(activity.getActivityType());
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
content.append("<p>").append(activityEmoji).append(" ").append(escapeHtml(formattedType)).append("</p>");
StringBuilder metrics = new StringBuilder();
if (activity.getTotalDistance() != null) {
metrics.append("📏 ")
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0))
.append("<br>");
}
if (activity.getTotalDurationSeconds() != null) {
long hours = activity.getTotalDurationSeconds() / 3600;
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
long seconds = activity.getTotalDurationSeconds() % 60;
metrics.append("⏱️ ");
if (hours > 0) {
metrics.append(hours).append("h ");
}
metrics.append(minutes).append("m ").append(seconds).append("s").append("<br>");
}
if (activity.getElevationGain() != null) {
metrics.append("⛰️ ")
.append(String.format("%.0f m", activity.getElevationGain().doubleValue()))
.append("<br>");
}
if (metrics.length() > 0) {
content.append("<p>").append(metrics).append("</p>");
} }
return content.toString(); return content.toString();
} }
/**
* Build a one-line plain summary like "Run · 6.52 km · 43:30". Skips any
* field that's null or zero so indoor activities (no distance) just show
* "Indoor cycling · 45:12". Returns "" if absolutely nothing is available.
*/
private String buildSummaryLine(Activity activity) {
java.util.List<String> parts = new java.util.ArrayList<>();
String type = ActivityFormatter.formatActivityType(activity.getActivityType());
if (type != null && !type.isEmpty()) {
parts.add(escapeHtml(type));
}
if (activity.getTotalDistance() != null
&& activity.getTotalDistance().doubleValue() > 0) {
parts.add(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
}
if (activity.getTotalDurationSeconds() != null
&& activity.getTotalDurationSeconds() > 0) {
long s = activity.getTotalDurationSeconds();
long h = s / 3600;
long m = (s % 3600) / 60;
long sec = s % 60;
if (h > 0) {
parts.add(String.format("%d:%02d:%02d", h, m, sec));
} else {
parts.add(String.format("%d:%02d", m, sec));
}
}
return String.join(" · ", parts);
}
private static final java.util.regex.Pattern HASHTAG_PATTERN = private static final java.util.regex.Pattern HASHTAG_PATTERN =
java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS);
@ -567,14 +603,4 @@ public class ActivityPubController {
.replace("\"", "&quot;"); .replace("\"", "&quot;");
} }
private String getActivityEmoji(Activity.ActivityType activityType) {
return switch (activityType) {
case RUN -> "🏃";
case RIDE -> "🚴";
case HIKE -> "🥾";
case WALK -> "🚶";
case SWIM -> "🏊";
default -> "💪";
};
}
} }

View file

@ -219,14 +219,26 @@ public class ActivityPostProcessingService {
} }
// Set visibility (to/cc fields) // Set visibility (to/cc fields)
String quoteAudience;
if (activity.getVisibility() == Activity.Visibility.PUBLIC) { if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
noteObject.put("cc", List.of(actorUri + "/followers")); noteObject.put("cc", List.of(actorUri + "/followers"));
quoteAudience = "https://www.w3.org/ns/activitystreams#Public";
} else { } else {
// FOLLOWERS only // FOLLOWERS only
noteObject.put("to", List.of(actorUri + "/followers")); noteObject.put("to", List.of(actorUri + "/followers"));
quoteAudience = actorUri + "/followers";
} }
// Tell Mastodon (and other servers that implement FEP-5e53) that
// this post can be quoted by the same audience that can see it.
// Without this declaration Mastodon defaults to denying quotes
// for cross-server posts, which surfaces as `quote_approval.current_user
// == "denied"` in its API. The matching extension field declarations
// live on the surrounding Create's `@context` (set by FederationService).
noteObject.put("interactionPolicy",
net.javahippie.fitpub.util.ActivityPubContexts.quotePolicyAllowing(quoteAudience));
// Attach activity image if generated // Attach activity image if generated
if (imageUrl != null) { if (imageUrl != null) {
Map<String, Object> imageAttachment = new HashMap<>(); Map<String, Object> imageAttachment = new HashMap<>();
@ -259,53 +271,73 @@ public class ActivityPostProcessingService {
} }
/** /**
* Format activity content as HTML for ActivityPub Note. * Format activity content as HTML for the ActivityPub Note. Output is
* Mastodon and most Fediverse software expect HTML in the content field. * intentionally minimal because the bulk of the activity data is already
* Hashtags in user text are converted to proper HTML links. * in the share image attachment (and its alt text). The body provides:
* the title (bold), a one-line context summary as a fallback for clients
* that don't render image attachments, and the user-written description
* (with hashtags linkified).
*/ */
private String formatActivityContent(Activity activity) { private String formatActivityContent(Activity activity) {
StringBuilder content = new StringBuilder(); StringBuilder content = new StringBuilder();
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
content.append("<p><strong>").append(linkifyHashtags(escapeHtml(activity.getTitle()))).append("</strong></p>"); content.append("<p><strong>")
.append(linkifyHashtags(escapeHtml(activity.getTitle())))
.append("</strong></p>");
}
// One-line summary (sport · distance · duration). Fallback for clients
// that don't render the image attachment, and a quick at-a-glance line
// even for clients that do.
String summary = buildSummaryLine(activity);
if (!summary.isEmpty()) {
content.append("<p>").append(summary).append("</p>");
} }
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) { if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
content.append("<p>").append(linkifyHashtags(escapeHtml(activity.getDescription()))).append("</p>"); content.append("<p>")
} .append(linkifyHashtags(escapeHtml(activity.getDescription())))
.append("</p>");
String activityEmoji = getActivityEmoji(activity.getActivityType());
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
content.append("<p>").append(activityEmoji).append(" ").append(escapeHtml(formattedType)).append("</p>");
StringBuilder metrics = new StringBuilder();
if (activity.getTotalDistance() != null) {
metrics.append("📏 ")
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0))
.append("<br>");
}
if (activity.getTotalDurationSeconds() != null) {
long hours = activity.getTotalDurationSeconds() / 3600;
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
long seconds = activity.getTotalDurationSeconds() % 60;
metrics.append("⏱️ ");
if (hours > 0) {
metrics.append(hours).append("h ");
}
metrics.append(minutes).append("m ").append(seconds).append("s").append("<br>");
}
if (activity.getElevationGain() != null) {
metrics.append("⛰️ ")
.append(String.format("%.0f m", activity.getElevationGain()))
.append("<br>");
}
if (metrics.length() > 0) {
content.append("<p>").append(metrics).append("</p>");
} }
return content.toString(); return content.toString();
} }
/**
* Build a one-line plain summary like "Run · 6.52 km · 43:30". Skips any
* field that's null or zero so indoor activities (no distance) just show
* "Indoor cycling · 45:12". Returns "" if absolutely nothing is available.
*/
private String buildSummaryLine(Activity activity) {
java.util.List<String> parts = new java.util.ArrayList<>();
String type = ActivityFormatter.formatActivityType(activity.getActivityType());
if (type != null && !type.isEmpty()) {
parts.add(escapeHtml(type));
}
if (activity.getTotalDistance() != null
&& activity.getTotalDistance().doubleValue() > 0) {
parts.add(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
}
if (activity.getTotalDurationSeconds() != null
&& activity.getTotalDurationSeconds() > 0) {
long s = activity.getTotalDurationSeconds();
long h = s / 3600;
long m = (s % 3600) / 60;
long sec = s % 60;
if (h > 0) {
parts.add(String.format("%d:%02d:%02d", h, m, sec));
} else {
parts.add(String.format("%d:%02d", m, sec));
}
}
return String.join(" · ", parts);
}
private static final java.util.regex.Pattern HASHTAG_PATTERN = private static final java.util.regex.Pattern HASHTAG_PATTERN =
java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS);
@ -347,28 +379,4 @@ public class ActivityPostProcessingService {
.replace("\"", "&quot;"); .replace("\"", "&quot;");
} }
/**
* Get an emoji for the activity type.
*
* @param type the activity type
* @return emoji representing the activity type
*/
private String getActivityEmoji(Activity.ActivityType type) {
return switch (type) {
case RUN -> "🏃";
case RIDE -> "🚴";
case HIKE -> "🥾";
case WALK -> "🚶";
case SWIM -> "🏊";
case ALPINE_SKI, BACKCOUNTRY_SKI, NORDIC_SKI -> "⛷️";
case SNOWBOARD -> "🏂";
case ROWING -> "🚣";
case KAYAKING, CANOEING -> "🛶";
case INLINE_SKATING -> "⛸️";
case ROCK_CLIMBING, MOUNTAINEERING -> "🧗";
case YOGA -> "🧘";
case WORKOUT -> "💪";
default -> "🏋️";
};
}
} }

View file

@ -369,7 +369,12 @@ public class FederationService {
String actorUri = baseUrl + "/users/" + sender.getUsername(); String actorUri = baseUrl + "/users/" + sender.getUsername();
Map<String, Object> createActivity = new HashMap<>(); Map<String, Object> createActivity = new HashMap<>();
createActivity.put("@context", "https://www.w3.org/ns/activitystreams"); // Use the extended JSON-LD context that declares Mastodon's
// interaction-policy extension fields. This is needed so that
// any inner object carrying an `interactionPolicy` (e.g. Notes
// emitted by ActivityPostProcessingService that allow quotes)
// is correctly understood by Mastodon's parser.
createActivity.put("@context", net.javahippie.fitpub.util.ActivityPubContexts.extendedContext());
createActivity.put("type", "Create"); createActivity.put("type", "Create");
createActivity.put("id", createId); createActivity.put("id", createId);
createActivity.put("actor", actorUri); createActivity.put("actor", actorUri);

View file

@ -0,0 +1,101 @@
package net.javahippie.fitpub.util;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Shared {@code @context} builders for outbound ActivityPub objects.
*
* <p>Centralises the JSON-LD context shape so all the federation code paths
* (Create wrappers in {@code FederationService}, the standalone Note returned
* by {@code ActivityPubController#getActivity}) use the same one.
*
* <p>The "extended" context declares the interaction-policy extension fields
* ({@code interactionPolicy}, {@code canQuote}, {@code automaticApproval},
* {@code manualApproval}) under the {@code gts:} prefix
* ({@code https://gotosocial.org/ns#}). This matches Mastodon's
* {@code app/helpers/context_helper.rb} ("interaction_policies" extension),
* which declares these fields in the GoToSocial namespace rather than
* Mastodon's own {@code toot:} namespace. Without these declarations,
* Mastodon ignores any {@code interactionPolicy} field on the inner object
* and falls back to its default policy (which currently denies quotes for
* cross-server posts).
*
* <p>This file deliberately doesn't try to enumerate every possible Mastodon
* extension only the ones we actively use. Add fields here as we adopt them.
*/
public final class ActivityPubContexts {
/** The well-known ActivityStreams "Public" audience URI. */
public static final String PUBLIC_AUDIENCE = "https://www.w3.org/ns/activitystreams#Public";
private ActivityPubContexts() {
}
/**
* Returns the extended JSON-LD {@code @context} value for outbound objects
* that carry interaction-policy declarations. Shape:
*
* <pre>
* [
* "https://www.w3.org/ns/activitystreams",
* {
* "gts": "https://gotosocial.org/ns#",
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
* }
* ]
* </pre>
*
* <p>The {@code gts:} prefix is the GoToSocial namespace
* ({@code https://gotosocial.org/ns#}). Mastodon emits and consumes
* exactly this shape (see {@code app/helpers/context_helper.rb} in the
* Mastodon source, "interaction_policies" extension), so a Mastodon
* receiver compacting our object with its own context will recognise the
* field names and apply the policy.
*/
public static List<Object> extendedContext() {
Map<String, Object> extensions = new LinkedHashMap<>();
extensions.put("gts", "https://gotosocial.org/ns#");
extensions.put("interactionPolicy", typedRef("gts:interactionPolicy"));
extensions.put("canQuote", typedRef("gts:canQuote"));
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
extensions.put("manualApproval", typedRef("gts:manualApproval"));
return List.of(
"https://www.w3.org/ns/activitystreams",
extensions
);
}
/**
* Build an interaction policy that allows the given audience to quote
* automatically (no manual approval required). The audience should be a
* URI like {@link #PUBLIC_AUDIENCE} or an actor's followers collection.
*
* <p>Shape:
* <pre>
* {
* "canQuote": {
* "automaticApproval": ["https://www.w3.org/ns/activitystreams#Public"]
* }
* }
* </pre>
*/
public static Map<String, Object> quotePolicyAllowing(String audienceUri) {
Map<String, Object> canQuote = new LinkedHashMap<>();
canQuote.put("automaticApproval", List.of(audienceUri));
Map<String, Object> policy = new LinkedHashMap<>();
policy.put("canQuote", canQuote);
return policy;
}
private static Map<String, String> typedRef(String id) {
Map<String, String> m = new LinkedHashMap<>();
m.put("@id", id);
m.put("@type", "@id");
return m;
}
}