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 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("\"", """);
|
.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)
|
// 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("\"", """);
|
.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();
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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