From a8b601233b812d7ee1e234662647b9b204eeaf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Wed, 8 Apr 2026 12:48:12 +0200 Subject: [PATCH] Allow Mastodon to Quote Post --- .../controller/ActivityPubController.java | 114 +++++++++------- .../ActivityPostProcessingService.java | 124 ++++++++++-------- .../fitpub/service/FederationService.java | 7 +- .../fitpub/util/ActivityPubContexts.java | 101 ++++++++++++++ 4 files changed, 243 insertions(+), 103 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 72312fe..4cf3717 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -426,9 +426,13 @@ public class ActivityPubController { String actorUri = baseUrl + "/users/" + user.getUsername(); 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 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("type", "Note"); noteObject.put("attributedTo", actorUri); @@ -436,10 +440,17 @@ public class ActivityPubController { noteObject.put("content", formatActivityContent(activity)); 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("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 List hashtags = extractHashtags(activity); if (!hashtags.isEmpty()) { @@ -489,49 +500,74 @@ public class ActivityPubController { * Format activity content as HTML for ActivityPub. * 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) { StringBuilder content = new StringBuilder(); if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { - content.append("

").append(linkifyHashtags(escapeHtml(activity.getTitle()))).append("

"); + content.append("

") + .append(linkifyHashtags(escapeHtml(activity.getTitle()))) + .append("

"); + } + + // 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("

").append(summary).append("

"); } if (activity.getDescription() != null && !activity.getDescription().isEmpty()) { - content.append("

").append(linkifyHashtags(escapeHtml(activity.getDescription()))).append("

"); - } - - String activityEmoji = getActivityEmoji(activity.getActivityType()); - String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()); - content.append("

").append(activityEmoji).append(" ").append(escapeHtml(formattedType)).append("

"); - - StringBuilder metrics = new StringBuilder(); - if (activity.getTotalDistance() != null) { - metrics.append("πŸ“ ") - .append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0)) - .append("
"); - } - 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("
"); - } - if (activity.getElevationGain() != null) { - metrics.append("⛰️ ") - .append(String.format("%.0f m", activity.getElevationGain().doubleValue())) - .append("
"); - } - if (metrics.length() > 0) { - content.append("

").append(metrics).append("

"); + content.append("

") + .append(linkifyHashtags(escapeHtml(activity.getDescription()))) + .append("

"); } 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 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 = java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); @@ -567,14 +603,4 @@ public class ActivityPubController { .replace("\"", """); } - private String getActivityEmoji(Activity.ActivityType activityType) { - return switch (activityType) { - case RUN -> "πŸƒ"; - case RIDE -> "🚴"; - case HIKE -> "πŸ₯Ύ"; - case WALK -> "🚢"; - case SWIM -> "🏊"; - default -> "πŸ’ͺ"; - }; - } } diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index 3e7f3f3..8a582bc 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -219,14 +219,26 @@ public class ActivityPostProcessingService { } // Set visibility (to/cc fields) + String quoteAudience; if (activity.getVisibility() == Activity.Visibility.PUBLIC) { noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); noteObject.put("cc", List.of(actorUri + "/followers")); + quoteAudience = "https://www.w3.org/ns/activitystreams#Public"; } else { // FOLLOWERS only 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 if (imageUrl != null) { Map imageAttachment = new HashMap<>(); @@ -259,53 +271,73 @@ public class ActivityPostProcessingService { } /** - * Format activity content as HTML for ActivityPub Note. - * Mastodon and most Fediverse software expect HTML in the content field. - * Hashtags in user text are converted to proper HTML links. + * 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) { StringBuilder content = new StringBuilder(); if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { - content.append("

").append(linkifyHashtags(escapeHtml(activity.getTitle()))).append("

"); + content.append("

") + .append(linkifyHashtags(escapeHtml(activity.getTitle()))) + .append("

"); + } + + // 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("

").append(summary).append("

"); } if (activity.getDescription() != null && !activity.getDescription().isEmpty()) { - content.append("

").append(linkifyHashtags(escapeHtml(activity.getDescription()))).append("

"); - } - - String activityEmoji = getActivityEmoji(activity.getActivityType()); - String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()); - content.append("

").append(activityEmoji).append(" ").append(escapeHtml(formattedType)).append("

"); - - StringBuilder metrics = new StringBuilder(); - if (activity.getTotalDistance() != null) { - metrics.append("πŸ“ ") - .append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0)) - .append("
"); - } - 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("
"); - } - if (activity.getElevationGain() != null) { - metrics.append("⛰️ ") - .append(String.format("%.0f m", activity.getElevationGain())) - .append("
"); - } - if (metrics.length() > 0) { - content.append("

").append(metrics).append("

"); + content.append("

") + .append(linkifyHashtags(escapeHtml(activity.getDescription()))) + .append("

"); } 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 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 = java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); @@ -347,28 +379,4 @@ public class ActivityPostProcessingService { .replace("\"", """); } - /** - * 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 -> "πŸ‹οΈ"; - }; - } } diff --git a/src/main/java/net/javahippie/fitpub/service/FederationService.java b/src/main/java/net/javahippie/fitpub/service/FederationService.java index 8a1efea..4da6a52 100644 --- a/src/main/java/net/javahippie/fitpub/service/FederationService.java +++ b/src/main/java/net/javahippie/fitpub/service/FederationService.java @@ -369,7 +369,12 @@ public class FederationService { String actorUri = baseUrl + "/users/" + sender.getUsername(); Map 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("id", createId); createActivity.put("actor", actorUri); diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java new file mode 100644 index 0000000..84581bd --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java @@ -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. + * + *

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. + * + *

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). + * + *

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: + * + *

+     * [
+     *   "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" }
+     *   }
+     * ]
+     * 
+ * + *

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 extendedContext() { + Map 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. + * + *

Shape: + *

+     * {
+     *   "canQuote": {
+     *     "automaticApproval": ["https://www.w3.org/ns/activitystreams#Public"]
+     *   }
+     * }
+     * 
+ */ + public static Map quotePolicyAllowing(String audienceUri) { + Map canQuote = new LinkedHashMap<>(); + canQuote.put("automaticApproval", List.of(audienceUri)); + Map policy = new LinkedHashMap<>(); + policy.put("canQuote", canQuote); + return policy; + } + + private static Map typedRef(String id) { + Map m = new LinkedHashMap<>(); + m.put("@id", id); + m.put("@type", "@id"); + return m; + } +}