diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 861b3c8..617505e 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -265,6 +265,21 @@ public class ActivityPubController { noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); noteObject.put("cc", List.of(actorUri + "/followers")); + // Extract hashtags from user text and add as tags + List hashtags = extractHashtags(activity); + if (!hashtags.isEmpty()) { + List> tags = hashtags.stream() + .map(ht -> { + Map tag = new HashMap<>(); + tag.put("type", "Hashtag"); + tag.put("href", baseUrl + "/tags/" + ht.toLowerCase()); + tag.put("name", "#" + ht); + return tag; + }) + .toList(); + noteObject.put("tag", tags); + } + // Add conversation/context for threading noteObject.put("conversation", activityUri); @@ -297,22 +312,18 @@ public class ActivityPubController { private String formatActivityContent(Activity activity) { StringBuilder content = new StringBuilder(); - // Title if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { - content.append("

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

"); + content.append("

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

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

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

"); + content.append("

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

"); } - // Activity type with emoji String activityEmoji = getActivityEmoji(activity.getActivityType()); String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()); content.append("

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

"); - // Metrics StringBuilder metrics = new StringBuilder(); if (activity.getTotalDistance() != null) { metrics.append("📏 ") @@ -341,6 +352,33 @@ public class ActivityPubController { return content.toString(); } + private static final java.util.regex.Pattern HASHTAG_PATTERN = + java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); + + private List extractHashtags(Activity activity) { + List hashtags = new java.util.ArrayList<>(); + for (String text : List.of( + activity.getTitle() != null ? activity.getTitle() : "", + activity.getDescription() != null ? activity.getDescription() : "")) { + var matcher = HASHTAG_PATTERN.matcher(text); + while (matcher.find()) { + String tag = matcher.group(1); + if (hashtags.stream().noneMatch(t -> t.equalsIgnoreCase(tag))) { + hashtags.add(tag); + } + } + } + return hashtags; + } + + private String linkifyHashtags(String escapedHtml) { + return HASHTAG_PATTERN.matcher(escapedHtml).replaceAll(match -> { + String tag = match.group(1); + return "#" + tag + ""; + }); + } + private static String escapeHtml(String text) { if (text == null) return ""; return text.replace("&", "&") @@ -349,9 +387,6 @@ public class ActivityPubController { .replace("\"", """); } - /** - * Get emoji for activity type. - */ private String getActivityEmoji(Activity.ActivityType activityType) { return switch (activityType) { case RUN -> "🏃"; diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index 8960b5b..c60b1b2 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -179,6 +179,21 @@ public class ActivityPostProcessingService { noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", baseUrl + "/activities/" + activity.getId()); + // Extract hashtags from user text and add as tags + List hashtags = extractHashtags(activity); + if (!hashtags.isEmpty()) { + List> tags = hashtags.stream() + .map(ht -> { + Map tag = new HashMap<>(); + tag.put("type", "Hashtag"); + tag.put("href", baseUrl + "/tags/" + ht.toLowerCase()); + tag.put("name", "#" + ht); + return tag; + }) + .toList(); + noteObject.put("tag", tags); + } + // Set visibility (to/cc fields) if (activity.getVisibility() == Activity.Visibility.PUBLIC) { noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); @@ -218,19 +233,17 @@ public class ActivityPostProcessingService { /** * Format activity content as HTML for ActivityPub Note. * Mastodon and most Fediverse software expect HTML in the content field. - * - * @param activity the activity to format - * @return formatted HTML content string + * Hashtags in user text are converted to proper HTML links. */ private String formatActivityContent(Activity activity) { StringBuilder content = new StringBuilder(); if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { - content.append("

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

"); + content.append("

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

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

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

"); + content.append("

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

"); } String activityEmoji = getActivityEmoji(activity.getActivityType()); @@ -265,6 +278,39 @@ public class ActivityPostProcessingService { return content.toString(); } + private static final java.util.regex.Pattern HASHTAG_PATTERN = + java.util.regex.Pattern.compile("(?<=^|\\s)#(\\w+)", java.util.regex.Pattern.UNICODE_CHARACTER_CLASS); + + /** + * Extract all hashtags from user-provided title and description. + */ + private List extractHashtags(Activity activity) { + List hashtags = new java.util.ArrayList<>(); + for (String text : List.of( + activity.getTitle() != null ? activity.getTitle() : "", + activity.getDescription() != null ? activity.getDescription() : "")) { + var matcher = HASHTAG_PATTERN.matcher(text); + while (matcher.find()) { + String tag = matcher.group(1); + if (hashtags.stream().noneMatch(t -> t.equalsIgnoreCase(tag))) { + hashtags.add(tag); + } + } + } + return hashtags; + } + + /** + * Convert #hashtag occurrences in already-escaped HTML text to ActivityPub hashtag links. + */ + private String linkifyHashtags(String escapedHtml) { + return HASHTAG_PATTERN.matcher(escapedHtml).replaceAll(match -> { + String tag = match.group(1); + return "#" + tag + ""; + }); + } + private static String escapeHtml(String text) { if (text == null) return ""; return text.replace("&", "&") diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html index 145cf1f..b45774f 100644 --- a/src/main/resources/templates/activities/upload.html +++ b/src/main/resources/templates/activities/upload.html @@ -138,9 +138,9 @@ Visibility *