Allow Mastodon to Quote Post
This commit is contained in:
parent
07fbcf8064
commit
a8b601233b
4 changed files with 243 additions and 103 deletions
|
|
@ -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<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("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<String> 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("<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()) {
|
||||
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>");
|
||||
content.append("<p>")
|
||||
.append(linkifyHashtags(escapeHtml(activity.getDescription())))
|
||||
.append("</p>");
|
||||
}
|
||||
|
||||
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 =
|
||||
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 -> "💪";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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("<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()) {
|
||||
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>");
|
||||
content.append("<p>")
|
||||
.append(linkifyHashtags(escapeHtml(activity.getDescription())))
|
||||
.append("</p>");
|
||||
}
|
||||
|
||||
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 =
|
||||
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 -> "🏋️";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -369,7 +369,12 @@ public class FederationService {
|
|||
String actorUri = baseUrl + "/users/" + sender.getUsername();
|
||||
|
||||
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("id", createId);
|
||||
createActivity.put("actor", actorUri);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue