From 662363555b6196a59ad936a0a679f1900e9d613d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Tue, 7 Apr 2026 19:11:29 +0200 Subject: [PATCH] Emoji likes --- .../fitpub/controller/ActivityController.java | 9 + .../fitpub/controller/LikeController.java | 103 ++++-- .../fitpub/model/ReactionEmoji.java | 51 +++ .../fitpub/model/dto/ActivityDTO.java | 4 + .../javahippie/fitpub/model/dto/LikeDTO.java | 2 + .../fitpub/model/dto/NotificationDTO.java | 3 + .../fitpub/model/dto/TimelineActivityDTO.java | 4 + .../javahippie/fitpub/model/entity/Like.java | 9 + .../fitpub/model/entity/Notification.java | 7 + .../fitpub/repository/LikeRepository.java | 36 ++ .../fitpub/service/FederationService.java | 16 +- .../fitpub/service/InboxProcessor.java | 39 ++- .../fitpub/service/NotificationService.java | 7 +- .../fitpub/service/ReactionEnricher.java | 107 ++++++ .../fitpub/service/TimelineService.java | 6 + .../db/migration/V29__add_emoji_to_likes.sql | 30 ++ src/main/resources/static/css/fitpub.css | 53 +++ src/main/resources/static/js/timeline.js | 309 +++++++++++++----- .../templates/activities/detail.html | 271 +++++++++------ .../resources/templates/notifications.html | 3 +- 20 files changed, 860 insertions(+), 209 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java create mode 100644 src/main/java/net/javahippie/fitpub/service/ReactionEnricher.java create mode 100644 src/main/resources/db/migration/V29__add_emoji_to_likes.sql diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java index 54b1bb7..ca5cab1 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -19,6 +19,7 @@ import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.WeatherService; import net.javahippie.fitpub.service.FitFileService; import net.javahippie.fitpub.service.PrivacyZoneService; +import net.javahippie.fitpub.service.ReactionEnricher; import net.javahippie.fitpub.service.TrackPrivacyFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -56,6 +57,7 @@ public class ActivityController { private final PrivacyZoneService privacyZoneService; private final TrackPrivacyFilter trackPrivacyFilter; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; + private final ReactionEnricher reactionEnricher; @Value("${fitpub.base-url}") private String baseUrl; @@ -187,6 +189,7 @@ public class ActivityController { // Public activities are always accessible, but apply privacy filtering ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); populatePeaks(dto, id); + reactionEnricher.enrichSingle(dto, requestingUserId); log.debug("Activity {} - DTO privacy zones: {}", id, dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0); return ResponseEntity.ok(dto); @@ -208,6 +211,7 @@ public class ActivityController { // Apply privacy filtering (owner sees full track, others see filtered) ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter); populatePeaks(dto, id); + reactionEnricher.enrichSingle(dto, requestingUserId); return ResponseEntity.ok(dto); } @@ -247,6 +251,9 @@ public class ActivityController { // Convert to DTOs org.springframework.data.domain.Page dtoPage = activityPage.map(ActivityDTO::fromEntity); + // Populate per-emoji reaction counts and the current user's reactions + reactionEnricher.enrichActivities(dtoPage.getContent(), userId); + // Return Spring Page object with all pagination metadata return ResponseEntity.ok(dtoPage); } @@ -376,6 +383,7 @@ public class ActivityController { .sorted((a, b) -> b.getStartedAt().compareTo(a.getStartedAt())) .map(activity -> ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter)) .toList(); + reactionEnricher.enrichActivities(dtos, requestingUserId); org.springframework.data.domain.Pageable peakPageable = org.springframework.data.domain.PageRequest.of(0, Math.max(dtos.size(), 1)); return ResponseEntity.ok(new org.springframework.data.domain.PageImpl<>(dtos, peakPageable, dtos.size())); @@ -394,6 +402,7 @@ public class ActivityController { ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter) ); + reactionEnricher.enrichActivities(dtoPage.getContent(), requestingUserId); return ResponseEntity.ok(dtoPage); } diff --git a/src/main/java/net/javahippie/fitpub/controller/LikeController.java b/src/main/java/net/javahippie/fitpub/controller/LikeController.java index d28967b..7b50f4a 100644 --- a/src/main/java/net/javahippie/fitpub/controller/LikeController.java +++ b/src/main/java/net/javahippie/fitpub/controller/LikeController.java @@ -2,6 +2,7 @@ package net.javahippie.fitpub.controller; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.ReactionEmoji; import net.javahippie.fitpub.model.dto.LikeDTO; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.Like; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; @@ -74,16 +76,32 @@ public class LikeController { } /** - * Like an activity. + * Request body for {@link #reactToActivity}. Both fields are optional; an empty body + * is treated as a heart reaction for backwards compatibility with the old "like" UX. + */ + public record ReactionRequest(String emoji) {} + + /** + * React to an activity with an emoji from the fixed palette. * - * @param activityId the activity ID + *

This endpoint has UPSERT semantics: if the user has not yet reacted, a new row + * is created (HTTP 201); if they already reacted with a different emoji, the existing + * row is updated to the new emoji (HTTP 200); if they re-submit the same emoji, the + * existing row is returned unchanged (HTTP 200). + * + *

Validation: the emoji must be in {@link ReactionEmoji#PALETTE}. A missing or null + * emoji defaults to {@link ReactionEmoji#DEFAULT}; an unknown emoji is rejected with 400. + * + * @param activityId the activity to react to + * @param request the reaction request body (optional) * @param userDetails the authenticated user - * @return the created like + * @return the created or updated reaction */ @PostMapping @Transactional - public ResponseEntity likeActivity( + public ResponseEntity reactToActivity( @PathVariable UUID activityId, + @RequestBody(required = false) ReactionRequest request, @AuthenticationPrincipal UserDetails userDetails ) { User user = getUser(userDetails); @@ -95,35 +113,70 @@ public class LikeController { return ResponseEntity.notFound().build(); } - // Check if already liked - if (likeRepository.existsByActivityIdAndUserId(activityId, user.getId())) { - return ResponseEntity.status(HttpStatus.CONFLICT).build(); + // Resolve and validate the emoji. A missing body / null field defaults to ❤️ + // (backwards compat with the original heart-only "like" client). An explicit + // value that isn't in the palette is rejected — we don't silently downgrade + // requests from local clients the way we do for federation receive paths. + String requestedEmoji = (request == null) ? null : request.emoji(); + String emoji; + if (requestedEmoji == null || requestedEmoji.isBlank()) { + emoji = ReactionEmoji.DEFAULT; + } else if (ReactionEmoji.isAllowed(requestedEmoji)) { + emoji = requestedEmoji; + } else { + log.warn("User {} attempted to react with unsupported emoji {} on activity {}", + user.getUsername(), requestedEmoji, activityId); + return ResponseEntity.badRequest().build(); } - // Create like - Like like = Like.builder() - .activityId(activityId) - .userId(user.getId()) - .displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()) - .avatarUrl(user.getAvatarUrl()) - .build(); + // UPSERT: update the existing row if present, otherwise create a new one. + Optional existing = likeRepository.findByActivityIdAndUserId(activityId, user.getId()); + Like saved; + boolean created; + if (existing.isPresent()) { + Like like = existing.get(); + boolean changed = !emoji.equals(like.getEmoji()); + like.setEmoji(emoji); + // Refresh display name and avatar in case the user changed them since + // their last reaction. + like.setDisplayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); + like.setAvatarUrl(user.getAvatarUrl()); + saved = likeRepository.save(like); + created = false; + if (changed) { + log.info("User {} switched reaction on activity {} to {}", user.getUsername(), activityId, emoji); + } + } else { + Like like = Like.builder() + .activityId(activityId) + .userId(user.getId()) + .emoji(emoji) + .displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()) + .avatarUrl(user.getAvatarUrl()) + .build(); + saved = likeRepository.save(like); + created = true; + log.info("User {} reacted to activity {} with {}", user.getUsername(), activityId, emoji); + } - Like saved = likeRepository.save(like); + // Create notification for activity owner — only on the initial reaction so + // that switching emojis doesn't spam them. The notification carries the + // current emoji so the recipient sees what was actually used. + if (created) { + String likerActorUri = user.getActorUri(baseUrl); + notificationService.createActivityLikedNotification(activity, likerActorUri, emoji); + } - log.info("User {} liked activity {}", user.getUsername(), activityId); - - // Create notification for activity owner - String likerActorUri = user.getActorUri(baseUrl); - notificationService.createActivityLikedNotification(activity, likerActorUri); - - // Send ActivityPub Like activity to followers if activity is public + // Send ActivityPub Like activity to followers if activity is public. + // Federation always sends a fresh Like + content; downstream Pleroma/Akkoma will + // overwrite the previous reaction, vanilla Mastodon will just show another like. if (activity.getVisibility() == Activity.Visibility.PUBLIC) { String activityUri = baseUrl + "/activities/" + activityId; - federationService.sendLikeActivity(activityUri, user); + federationService.sendLikeActivity(activityUri, user, emoji); } - return ResponseEntity.status(HttpStatus.CREATED) - .body(LikeDTO.fromEntity(saved, baseUrl)); + HttpStatus status = created ? HttpStatus.CREATED : HttpStatus.OK; + return ResponseEntity.status(status).body(LikeDTO.fromEntity(saved, baseUrl)); } /** diff --git a/src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java b/src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java new file mode 100644 index 0000000..ec4eee8 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java @@ -0,0 +1,51 @@ +package net.javahippie.fitpub.model; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * The fixed palette of emoji reactions a user may apply to an activity. + * + *

The set is intentionally small and curated. The same palette is enforced by + * a CHECK constraint on the {@code likes.emoji} column. Any caller that accepts + * an emoji from outside (HTTP request body, federation payload) MUST normalise + * it through {@link #normalise(String)} before persistence so that we never end + * up with a value the database would reject. + */ +public final class ReactionEmoji { + + /** The default reaction used when a client or federation peer sends none / sends an unknown one. */ + public static final String DEFAULT = "❤️"; + + /** + * The fixed palette of allowed reaction emoji, in display order. + * The order here drives the UI picker order. + */ + public static final Set PALETTE = Set.copyOf(new LinkedHashSet<>(java.util.List.of( + "❤️", + "🔥", + "💪", + "🏔️", + "🤯", + "🥲" + ))); + + private ReactionEmoji() { + } + + /** + * Returns true if the given string is one of the allowed reaction emojis. + */ + public static boolean isAllowed(String emoji) { + return emoji != null && PALETTE.contains(emoji); + } + + /** + * Returns the given emoji if it is in the palette, otherwise the {@link #DEFAULT}. + * Use this for federation receive paths where rejecting unknown values would be + * unfriendly — we degrade gracefully to a heart instead. + */ + public static String normalise(String emoji) { + return isAllowed(emoji) ? emoji : DEFAULT; + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java index 27a1de5..5c910c0 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java @@ -65,6 +65,10 @@ public class ActivityDTO { private Long likesCount; private Long commentsCount; private Boolean likedByCurrentUser; // True if current user has liked this activity + /** Per-emoji reaction counts. Only emojis with count > 0 are present. */ + private java.util.Map reactionCounts; + /** The current user's own reaction emoji on this activity, or null if none. */ + private String currentUserReaction; // Peaks visited on this activity private List peaks; diff --git a/src/main/java/net/javahippie/fitpub/model/dto/LikeDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/LikeDTO.java index 7e0eee8..5da6d48 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/LikeDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/LikeDTO.java @@ -23,6 +23,7 @@ public class LikeDTO { private String actorUri; // Local user URI or remote actor URI private String displayName; private String avatarUrl; + private String emoji; private LocalDateTime createdAt; private boolean local; @@ -44,6 +45,7 @@ public class LikeDTO { .actorUri(actorUri) .displayName(like.getDisplayName()) .avatarUrl(like.getAvatarUrl()) + .emoji(like.getEmoji()) .createdAt(like.getCreatedAt()) .local(like.isLocal()) .build(); diff --git a/src/main/java/net/javahippie/fitpub/model/dto/NotificationDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/NotificationDTO.java index 3b173da..588c11d 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/NotificationDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/NotificationDTO.java @@ -28,6 +28,8 @@ public class NotificationDTO { private String activityTitle; private UUID commentId; private String commentText; + /** For ACTIVITY_LIKED notifications: the emoji used in the reaction. Null otherwise. */ + private String reactionEmoji; private boolean read; private LocalDateTime createdAt; private LocalDateTime readAt; @@ -50,6 +52,7 @@ public class NotificationDTO { .activityTitle(notification.getActivityTitle()) .commentId(notification.getCommentId()) .commentText(notification.getCommentText()) + .reactionEmoji(notification.getReactionEmoji()) .read(notification.isRead()) .createdAt(notification.getCreatedAt()) .readAt(notification.getReadAt()) diff --git a/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java index dcbb894..85d3a65 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java @@ -56,6 +56,10 @@ public class TimelineActivityDTO { private Long likesCount; private Long commentsCount; private Boolean likedByCurrentUser; + /** Per-emoji reaction counts. Only emojis with count > 0 are present. */ + private java.util.Map reactionCounts; + /** The current user's own reaction emoji on this activity, or null if none. */ + private String currentUserReaction; // GPS track availability private Boolean hasGpsTrack; // True if activity has GPS data diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Like.java b/src/main/java/net/javahippie/fitpub/model/entity/Like.java index 212500f..ec15cb9 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Like.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Like.java @@ -66,6 +66,15 @@ public class Like { @Column(name = "activity_pub_id", length = 500) private String activityPubId; + /** + * Emoji reaction. One of the fixed palette enforced by a DB CHECK constraint and + * by {@link net.javahippie.fitpub.model.ReactionEmoji}. Defaults to ❤️ for backwards + * compatibility with the original heart-only "like" behaviour. + */ + @Column(name = "emoji", nullable = false, length = 16) + @Builder.Default + private String emoji = "❤️"; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Notification.java b/src/main/java/net/javahippie/fitpub/model/entity/Notification.java index 0ddc61b..d4b846b 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Notification.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Notification.java @@ -93,6 +93,13 @@ public class Notification { @Column(name = "comment_text", length = 200) private String commentText; + /** + * For ACTIVITY_LIKED notifications: the emoji used in the reaction. + * Null for other notification types. Existing rows backfilled to ❤️ in V29. + */ + @Column(name = "reaction_emoji", length = 16) + private String reactionEmoji; + /** * Whether the notification has been read. */ diff --git a/src/main/java/net/javahippie/fitpub/repository/LikeRepository.java b/src/main/java/net/javahippie/fitpub/repository/LikeRepository.java index ba25fdd..9ea97f1 100644 --- a/src/main/java/net/javahippie/fitpub/repository/LikeRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/LikeRepository.java @@ -3,9 +3,12 @@ package net.javahippie.fitpub.repository; import net.javahippie.fitpub.model.entity.Like; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -100,4 +103,37 @@ public interface LikeRepository extends JpaRepository { @Modifying @Transactional void deleteByRemoteActorUri(String remoteActorUri); + + /** + * Per-emoji reaction counts for a batch of activities. Returns one row per + * (activityId, emoji) tuple. Used by the timeline / activity loaders to + * populate {@code reactionCounts} on each activity DTO without N+1 queries. + * + * @param activityIds the activity IDs to aggregate + * @return list of {@code [activityId UUID, emoji String, count Long]} rows + */ + @Query(""" + SELECT l.activityId, l.emoji, COUNT(l) + FROM Like l + WHERE l.activityId IN :activityIds + GROUP BY l.activityId, l.emoji + """) + List countByActivityIdsGroupedByEmoji(@Param("activityIds") Collection activityIds); + + /** + * The current user's own reaction (if any) on each activity in a batch. + * Used together with {@link #countByActivityIdsGroupedByEmoji} to populate + * the per-DTO {@code currentUserReaction} field. + * + * @param userId the current user ID + * @param activityIds the activity IDs to look up + * @return list of {@code [activityId UUID, emoji String]} rows for the user's reactions + */ + @Query(""" + SELECT l.activityId, l.emoji + FROM Like l + WHERE l.userId = :userId AND l.activityId IN :activityIds + """) + List findUserReactionsByActivityIds(@Param("userId") UUID userId, + @Param("activityIds") Collection activityIds); } diff --git a/src/main/java/net/javahippie/fitpub/service/FederationService.java b/src/main/java/net/javahippie/fitpub/service/FederationService.java index 9829066..c6c797f 100644 --- a/src/main/java/net/javahippie/fitpub/service/FederationService.java +++ b/src/main/java/net/javahippie/fitpub/service/FederationService.java @@ -333,13 +333,20 @@ public class FederationService { } /** - * Send a Like activity. + * Send a Like activity carrying an emoji reaction in the {@code content} field. + * + *

This follows the Pleroma/Akkoma convention for emoji reactions: a regular + * ActivityPub {@code Like} activity with a non-empty {@code content} field whose + * value is the emoji. Pleroma/Akkoma render this as an emoji reaction; vanilla + * Mastodon ignores the content and shows it as a regular like — graceful + * degradation in both directions. * * @param objectUri the URI of the object being liked - * @param sender the local user liking the object + * @param sender the local user reacting to the object + * @param emoji the reaction emoji (must be from {@link net.javahippie.fitpub.model.ReactionEmoji#PALETTE}) */ @Transactional - public void sendLikeActivity(String objectUri, User sender) { + public void sendLikeActivity(String objectUri, User sender, String emoji) { try { String likeId = baseUrl + "/activities/like/" + UUID.randomUUID(); String actorUri = baseUrl + "/users/" + sender.getUsername(); @@ -350,6 +357,7 @@ public class FederationService { likeActivity.put("id", likeId); likeActivity.put("actor", actorUri); likeActivity.put("object", objectUri); + likeActivity.put("content", emoji); likeActivity.put("published", Instant.now().toString()); // Send to all follower inboxes @@ -358,7 +366,7 @@ public class FederationService { sendActivity(inbox, likeActivity, sender); } - log.info("Sent Like activity for object: {} to {} inboxes", objectUri, inboxes.size()); + log.info("Sent Like activity ({}) for object: {} to {} inboxes", emoji, objectUri, inboxes.size()); } catch (Exception e) { log.error("Failed to send Like activity for object: {}", objectUri, e); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index c7a5edf..95e3262 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -387,13 +387,24 @@ public class InboxProcessor { /** * Process a Like activity. + * + *

Pleroma/Akkoma carry an emoji in the {@code content} field; vanilla Mastodon + * doesn't set it. We normalise via {@link net.javahippie.fitpub.model.ReactionEmoji#normalise} + * so unknown / missing values gracefully degrade to ❤️ rather than being rejected. + * + *

If the same remote actor has already reacted to this activity, we update the + * existing row in place — this matches the local UPSERT semantics so a remote actor + * can switch their reaction without us seeing it as a "new" like (and without + * generating a duplicate notification). */ private void processLike(String username, Map activity) { try { String actor = (String) activity.get("actor"); String objectUri = (String) activity.get("object"); + String content = (String) activity.get("content"); + String emoji = net.javahippie.fitpub.model.ReactionEmoji.normalise(content); - log.debug("Received Like from {} for object {}", actor, objectUri); + log.debug("Received Like ({}) from {} for object {}", emoji, actor, objectUri); // Extract activity ID from the object URI // Expected format: https://fitpub.example/activities/{uuid} @@ -413,9 +424,24 @@ public class InboxProcessor { // Fetch remote actor information RemoteActor remoteActor = federationService.fetchRemoteActor(actor); - // Check if like already exists - if (likeRepository.existsByActivityIdAndRemoteActorUri(activityId, actor)) { - log.debug("Like already exists from {} for activity {}", actor, activityId); + // UPSERT: if a previous reaction from this actor exists, update the emoji + // in place. Otherwise create a new row and notify the activity owner. + java.util.Optional existing = + likeRepository.findByActivityIdAndRemoteActorUri(activityId, actor); + if (existing.isPresent()) { + Like like = existing.get(); + if (!emoji.equals(like.getEmoji())) { + like.setEmoji(emoji); + like.setDisplayName(remoteActor.getDisplayName() != null + ? remoteActor.getDisplayName() : remoteActor.getUsername()); + like.setAvatarUrl(remoteActor.getAvatarUrl()); + likeRepository.save(like); + log.info("Switched remote reaction from {} on activity {} to {}", + actor, activityId, emoji); + } else { + log.debug("Like ({}) already recorded from {} for activity {}", + emoji, actor, activityId); + } return; } @@ -424,15 +450,16 @@ public class InboxProcessor { .activityId(activityId) .userId(null) // Remote actor, not a local user .remoteActorUri(actor) + .emoji(emoji) .displayName(remoteActor.getDisplayName() != null ? remoteActor.getDisplayName() : remoteActor.getUsername()) .avatarUrl(remoteActor.getAvatarUrl()) .build(); likeRepository.save(like); - log.info("Processed Like from {} for activity {}", actor, activityId); + log.info("Processed Like ({}) from {} for activity {}", emoji, actor, activityId); // Create notification for activity owner - notificationService.createActivityLikedNotification(localActivity, actor); + notificationService.createActivityLikedNotification(localActivity, actor, emoji); } catch (Exception e) { log.error("Error processing Like activity", e); diff --git a/src/main/java/net/javahippie/fitpub/service/NotificationService.java b/src/main/java/net/javahippie/fitpub/service/NotificationService.java index 2f8de00..d25c7f3 100644 --- a/src/main/java/net/javahippie/fitpub/service/NotificationService.java +++ b/src/main/java/net/javahippie/fitpub/service/NotificationService.java @@ -39,9 +39,10 @@ public class NotificationService { * * @param activity the activity that was liked * @param likerActorUri the URI of the user who liked the activity + * @param emoji the reaction emoji (one of {@link net.javahippie.fitpub.model.ReactionEmoji#PALETTE}) */ @Transactional - public void createActivityLikedNotification(Activity activity, String likerActorUri) { + public void createActivityLikedNotification(Activity activity, String likerActorUri, String emoji) { // Get the activity owner User activityOwner = userRepository.findById(activity.getUserId()) .orElse(null); @@ -72,10 +73,12 @@ public class NotificationService { .actorAvatarUrl(actorInfo.avatarUrl) .activityId(activity.getId()) .activityTitle(activity.getTitle() != null ? activity.getTitle() : "Untitled Activity") + .reactionEmoji(net.javahippie.fitpub.model.ReactionEmoji.normalise(emoji)) .build(); notificationRepository.save(notification); - log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activityOwner.getUsername(), actorInfo.username); + log.debug("Created ACTIVITY_LIKED notification ({}) for user {} from {}", + notification.getReactionEmoji(), activityOwner.getUsername(), actorInfo.username); } /** diff --git a/src/main/java/net/javahippie/fitpub/service/ReactionEnricher.java b/src/main/java/net/javahippie/fitpub/service/ReactionEnricher.java new file mode 100644 index 0000000..eec4cde --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/ReactionEnricher.java @@ -0,0 +1,107 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.model.dto.ActivityDTO; +import net.javahippie.fitpub.model.dto.TimelineActivityDTO; +import net.javahippie.fitpub.repository.LikeRepository; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Populates per-emoji reaction counts and the current user's reaction onto a batch + * of activity DTOs in two queries (one for the aggregate counts, one for the + * current user's reactions). Used by the timeline pagers and the single-activity + * loader so the same shape is presented everywhere without N+1 queries. + * + *

Activities with no reactions get an empty map (never null) so the frontend + * can iterate without null checks. + */ +@Service +@RequiredArgsConstructor +public class ReactionEnricher { + + private final LikeRepository likeRepository; + + /** + * Populates {@code reactionCounts} and {@code currentUserReaction} on every + * DTO in the given list. Safe to call with an empty list. + * + * @param activities the timeline DTOs to enrich (mutated in place) + * @param currentUserId the viewing user, or null for unauthenticated requests + */ + public void enrichTimeline(List activities, UUID currentUserId) { + if (activities == null || activities.isEmpty()) { + return; + } + List activityIds = activities.stream().map(TimelineActivityDTO::getId).toList(); + Map> countsByActivity = loadCounts(activityIds); + Map userReactions = loadUserReactions(activityIds, currentUserId); + + for (TimelineActivityDTO dto : activities) { + dto.setReactionCounts(countsByActivity.getOrDefault(dto.getId(), Map.of())); + dto.setCurrentUserReaction(userReactions.get(dto.getId())); + } + } + + /** + * Same as {@link #enrichTimeline} but for {@link ActivityDTO}, used by the + * activity listing endpoints (my activities, user public activities, peak filter). + */ + public void enrichActivities(List activities, UUID currentUserId) { + if (activities == null || activities.isEmpty()) { + return; + } + List activityIds = activities.stream().map(ActivityDTO::getId).toList(); + Map> countsByActivity = loadCounts(activityIds); + Map userReactions = loadUserReactions(activityIds, currentUserId); + + for (ActivityDTO dto : activities) { + dto.setReactionCounts(countsByActivity.getOrDefault(dto.getId(), Map.of())); + dto.setCurrentUserReaction(userReactions.get(dto.getId())); + } + } + + /** + * Populates {@code reactionCounts} and {@code currentUserReaction} on a single + * activity DTO. Cheaper variant of {@link #enrichTimeline} for the + * single-activity endpoints. + */ + public void enrichSingle(ActivityDTO activity, UUID currentUserId) { + if (activity == null || activity.getId() == null) { + return; + } + List activityIds = List.of(activity.getId()); + Map> countsByActivity = loadCounts(activityIds); + Map userReactions = loadUserReactions(activityIds, currentUserId); + + activity.setReactionCounts(countsByActivity.getOrDefault(activity.getId(), Map.of())); + activity.setCurrentUserReaction(userReactions.get(activity.getId())); + } + + private Map> loadCounts(Collection activityIds) { + Map> result = new HashMap<>(); + for (Object[] row : likeRepository.countByActivityIdsGroupedByEmoji(activityIds)) { + UUID activityId = (UUID) row[0]; + String emoji = (String) row[1]; + Long count = (Long) row[2]; + result.computeIfAbsent(activityId, k -> new java.util.LinkedHashMap<>()).put(emoji, count); + } + return result; + } + + private Map loadUserReactions(Collection activityIds, UUID currentUserId) { + if (currentUserId == null) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Object[] row : likeRepository.findUserReactionsByActivityIds(currentUserId, activityIds)) { + result.put((UUID) row[0], (String) row[1]); + } + return result; + } +} diff --git a/src/main/java/net/javahippie/fitpub/service/TimelineService.java b/src/main/java/net/javahippie/fitpub/service/TimelineService.java index b5234da..d976c68 100644 --- a/src/main/java/net/javahippie/fitpub/service/TimelineService.java +++ b/src/main/java/net/javahippie/fitpub/service/TimelineService.java @@ -46,6 +46,7 @@ public class TimelineService { private final LikeRepository likeRepository; private final CommentRepository commentRepository; private final TimelineResultMapper timelineResultMapper; + private final ReactionEnricher reactionEnricher; @Value("${fitpub.base-url}") private String baseUrl; @@ -166,6 +167,7 @@ public class TimelineService { log.debug("Fetched {} activities in single optimized query", timelineActivities.size()); + reactionEnricher.enrichTimeline(timelineActivities, userId); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); } @@ -200,6 +202,7 @@ public class TimelineService { log.debug("Fetched {} activities in single optimized query", timelineActivities.size()); + reactionEnricher.enrichTimeline(timelineActivities, userId); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); } @@ -248,6 +251,7 @@ public class TimelineService { log.debug("Found {} activities matching search criteria", timelineActivities.size()); + reactionEnricher.enrichTimeline(timelineActivities, userId); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); } @@ -287,6 +291,7 @@ public class TimelineService { log.debug("Found {} activities matching search criteria", timelineActivities.size()); + reactionEnricher.enrichTimeline(timelineActivities, userId); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); } @@ -337,6 +342,7 @@ public class TimelineService { log.debug("Found {} activities matching search criteria", timelineActivities.size()); + reactionEnricher.enrichTimeline(timelineActivities, userId); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); } diff --git a/src/main/resources/db/migration/V29__add_emoji_to_likes.sql b/src/main/resources/db/migration/V29__add_emoji_to_likes.sql new file mode 100644 index 0000000..95bbe1c --- /dev/null +++ b/src/main/resources/db/migration/V29__add_emoji_to_likes.sql @@ -0,0 +1,30 @@ +-- Migration V29: Convert likes into emoji reactions +-- +-- Adds an `emoji` column to the likes table so a "like" can be one of a fixed +-- palette of emoji reactions instead of just a heart. Existing rows are +-- backfilled to ❤️ to preserve current behaviour, then the column is made NOT +-- NULL with a default and a CHECK constraint pinning it to the supported +-- palette. The unique index on (activity_id, user_id) is unchanged: a user +-- still gets exactly one reaction per activity, and switching reactions is an +-- UPDATE rather than INSERT+DELETE. + +ALTER TABLE likes ADD COLUMN emoji VARCHAR(16); + +UPDATE likes SET emoji = '❤️' WHERE emoji IS NULL; + +ALTER TABLE likes ALTER COLUMN emoji SET NOT NULL; +ALTER TABLE likes ALTER COLUMN emoji SET DEFAULT '❤️'; + +ALTER TABLE likes ADD CONSTRAINT chk_likes_emoji_palette + CHECK (emoji IN ('❤️', '🔥', '💪', '🏔️', '🤯', '🥲')); + +COMMENT ON COLUMN likes.emoji IS + 'Emoji reaction (one of the fixed palette). NULL is not allowed; existing rows backfilled to ❤️.'; + +-- Notifications also carry the reaction emoji so the recipient sees which +-- reaction was applied without an extra lookup. Existing ACTIVITY_LIKED rows +-- are backfilled to ❤️ for the same reason. +ALTER TABLE notifications ADD COLUMN reaction_emoji VARCHAR(16); +UPDATE notifications SET reaction_emoji = '❤️' WHERE type = 'ACTIVITY_LIKED' AND reaction_emoji IS NULL; +COMMENT ON COLUMN notifications.reaction_emoji IS + 'For ACTIVITY_LIKED notifications: the emoji used in the reaction. Null for other notification types.'; diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css index 67d6242..6e49492 100644 --- a/src/main/resources/static/css/fitpub.css +++ b/src/main/resources/static/css/fitpub.css @@ -1371,3 +1371,56 @@ h1 { .hashtag-link:hover { text-decoration: underline; } + +/* Reaction chips and picker on activity cards / detail pages */ +.reactions-block { + position: relative; +} + +.reaction-chip { + padding: 0.15rem 0.55rem; + line-height: 1.2; + border-radius: 999px; + font-size: 0.9rem; +} + +.reaction-chip .reaction-emoji { + margin-right: 0.25rem; +} + +.reaction-add-btn { + padding: 0.15rem 0.55rem; + line-height: 1.2; + border-radius: 999px; +} + +.reaction-picker { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25rem; + padding: 0.35rem 0.5rem; + background: #ffffff; + border: 1px solid #dee2e6; + border-radius: 0.5rem; + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); + z-index: 10; + display: flex; + gap: 0.25rem; +} + +.reaction-picker.d-none { + display: none !important; +} + +.reaction-picker-option { + padding: 0.25rem 0.5rem; + font-size: 1.15rem; + line-height: 1; + border: none; + background: transparent; +} + +.reaction-picker-option:hover { + background: #f1f3f5; +} diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index d635ee2..a2a5e37 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -3,6 +3,13 @@ * Handles loading and displaying timeline activities with preview maps */ +/** + * The fixed palette of reaction emojis. Mirrors the backend + * `net.javahippie.fitpub.model.ReactionEmoji.PALETTE` and the V29 DB CHECK + * constraint. If you change this list, update both sides. + */ +const REACTION_PALETTE = ['❤️', '🔥', '💪', '🏔️', '🤯', '🥲']; + const FitPubTimeline = { currentPage: 0, totalPages: 0, @@ -28,9 +35,27 @@ const FitPubTimeline = { this.setupSearchHandlers(); this.renderHashtagFilterBadge(); + this.setupReactionPickerDismissal(); this.loadTimeline(0); }, + /** + * One document-level click handler that closes any open reaction picker + * when the user clicks outside of it. Installed once per page. + */ + setupReactionPickerDismissal: function() { + if (this._reactionPickerDismissalInstalled) return; + this._reactionPickerDismissalInstalled = true; + document.addEventListener('click', (e) => { + const insidePicker = e.target.closest('.reaction-picker, .reaction-add-btn'); + if (insidePicker) return; + document.querySelectorAll('.reaction-picker').forEach(p => { + p.classList.add('d-none'); + p.classList.remove('d-flex'); + }); + }); + }, + /** * Show or hide the active hashtag filter badge */ @@ -242,16 +267,11 @@ const FitPubTimeline = { + + ${this.renderReactionsBlock(activity)} +

- ${activity.isLocal ? ` View Details @@ -280,81 +300,224 @@ const FitPubTimeline = { }); }, 100); - // Setup like button handlers - this.setupLikeButtons(); + // Setup reaction handlers (chips + picker buttons) + this.setupReactionHandlers(); }, /** - * Setup like button click handlers + * Build the reactions block (existing reaction chips + add-reaction button + picker) + * for a single activity. Designed to be re-rendered in place after a state change + * via {@link FitPubTimeline.replaceReactionsBlock}. + * + * @param {Object} activity - Activity object with reactionCounts and currentUserReaction + * @returns {string} HTML for the reactions block */ - setupLikeButtons: function() { - const likeButtons = document.querySelectorAll('.like-btn'); + renderReactionsBlock: function(activity) { + const counts = activity.reactionCounts || {}; + const currentReaction = activity.currentUserReaction || null; - likeButtons.forEach(btn => { - btn.addEventListener('click', async (e) => { + // Render one chip per emoji that has at least one reaction. Sort by palette + // order so the layout is stable as reactions come and go. + const chips = REACTION_PALETTE + .filter(emoji => (counts[emoji] || 0) > 0) + .map(emoji => { + const count = counts[emoji]; + const mine = emoji === currentReaction; + return ``; + }) + .join(''); + + // Picker buttons — hidden by default, toggled by the add button. + const pickerButtons = REACTION_PALETTE.map(emoji => ` + + `).join(''); + + return ` +
+ ${chips} + +
+ ${pickerButtons} +
+
+ `; + }, + + /** + * Replace the reactions block for one activity in the DOM, after a successful + * POST/DELETE. Recalculates counts and the current user's reaction from the + * delta and rerenders the block in place. + */ + replaceReactionsBlock: function(activity) { + const block = document.querySelector(`.reactions-block[data-activity-id="${activity.id}"]`); + if (!block) return; + // Render into a wrapper, then move the children into the existing parent so + // any siblings (action buttons) stay put. + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.renderReactionsBlock(activity).trim(); + const newBlock = wrapper.firstElementChild; + block.replaceWith(newBlock); + // The new block needs its handlers attached. + this.attachReactionHandlersWithin(newBlock); + }, + + /** + * Wire up click handlers on reaction chips, picker options, and add buttons + * across the entire timeline list. + */ + setupReactionHandlers: function() { + document.querySelectorAll('.reactions-block').forEach(block => { + this.attachReactionHandlersWithin(block); + }); + }, + + /** + * Wire up click handlers on a single reactions-block element. Used both at + * timeline render time and after replacing a single block. + */ + attachReactionHandlersWithin: function(block) { + // Existing reaction chip: click toggles the reaction (if it's mine, remove + // it; otherwise switch my reaction to this emoji). + block.querySelectorAll('.reaction-chip').forEach(btn => { + btn.addEventListener('click', (e) => { e.preventDefault(); - - // Check if user is authenticated - if (!FitPubAuth.isAuthenticated()) { - window.location.href = '/login'; - return; - } - const activityId = btn.dataset.activityId; - const isLiked = btn.dataset.liked === 'true'; - const icon = btn.querySelector('i'); - const countSpan = btn.querySelector('.like-count'); - - try { - // Disable button during request - btn.disabled = true; - - if (isLiked) { - // Unlike - const response = await FitPubAuth.authenticatedFetch( - `/api/activities/${activityId}/likes`, - { method: 'DELETE' } - ); - - if (response.ok) { - // Update UI - btn.classList.remove('btn-danger'); - btn.classList.add('btn-outline-danger'); - icon.classList.remove('bi-heart-fill'); - icon.classList.add('bi-heart'); - btn.dataset.liked = 'false'; - - // Update count - const currentCount = parseInt(countSpan.textContent) || 0; - countSpan.textContent = Math.max(0, currentCount - 1); - } - } else { - // Like - const response = await FitPubAuth.authenticatedFetch( - `/api/activities/${activityId}/likes`, - { method: 'POST' } - ); - - if (response.ok) { - // Update UI - btn.classList.remove('btn-outline-danger'); - btn.classList.add('btn-danger'); - icon.classList.remove('bi-heart'); - icon.classList.add('bi-heart-fill'); - btn.dataset.liked = 'true'; - - // Update count - const currentCount = parseInt(countSpan.textContent) || 0; - countSpan.textContent = currentCount + 1; - } - } - } catch (error) { - console.error('Error toggling like:', error); - } finally { - btn.disabled = false; + const emoji = btn.dataset.emoji; + const mine = btn.dataset.mine === 'true'; + if (mine) { + this.sendReaction(activityId, null); + } else { + this.sendReaction(activityId, emoji); } }); }); + + // Add-reaction button: toggles the picker. + const addBtn = block.querySelector('.reaction-add-btn'); + const picker = block.querySelector('.reaction-picker'); + if (addBtn && picker) { + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const willOpen = picker.classList.contains('d-none'); + // Close any other open pickers first + document.querySelectorAll('.reaction-picker').forEach(p => { + p.classList.add('d-none'); + p.classList.remove('d-flex'); + }); + if (willOpen) { + picker.classList.remove('d-none'); + picker.classList.add('d-flex'); + } + }); + } + + // Picker options: react with the chosen emoji. + block.querySelectorAll('.reaction-picker-option').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const activityId = btn.dataset.activityId; + const emoji = btn.dataset.emoji; + if (picker) { + picker.classList.add('d-none'); + picker.classList.remove('d-flex'); + } + this.sendReaction(activityId, emoji); + }); + }); + }, + + /** + * POST or DELETE the user's reaction to a given activity, then re-render the + * reactions block. {@code emoji} of {@code null} means "remove my reaction". + */ + sendReaction: async function(activityId, emoji) { + // Anonymous users can't react — bounce them to login. + if (!FitPubAuth.isAuthenticated()) { + window.location.href = '/login'; + return; + } + + try { + let response; + if (emoji === null) { + response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'DELETE' } + ); + } else { + response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'POST', body: { emoji: emoji } } + ); + } + if (!response.ok) { + console.error('Reaction request failed:', response.status); + return; + } + // Apply the change locally to avoid a full timeline reload. + this.applyReactionChange(activityId, emoji); + } catch (err) { + console.error('Reaction request errored:', err); + } + }, + + /** + * Update the in-memory state of one activity card after a successful reaction + * change and re-render its block. Reads the current state from the DOM (counts + * and the user's existing reaction), applies the delta, and replaces the block. + */ + applyReactionChange: function(activityId, newEmoji) { + const block = document.querySelector(`.reactions-block[data-activity-id="${activityId}"]`); + if (!block) return; + + // Read current state out of the DOM + const counts = {}; + block.querySelectorAll('.reaction-chip').forEach(chip => { + counts[chip.dataset.emoji] = parseInt(chip.querySelector('.reaction-count').textContent, 10) || 0; + }); + let currentReaction = null; + const mineChip = block.querySelector('.reaction-chip[data-mine="true"]'); + if (mineChip) { + currentReaction = mineChip.dataset.emoji; + } + + // Apply the delta locally + if (currentReaction) { + counts[currentReaction] = Math.max(0, (counts[currentReaction] || 0) - 1); + if (counts[currentReaction] === 0) { + delete counts[currentReaction]; + } + } + if (newEmoji) { + counts[newEmoji] = (counts[newEmoji] || 0) + 1; + } + + // Re-render with the synthesised activity object + this.replaceReactionsBlock({ + id: activityId, + reactionCounts: counts, + currentUserReaction: newEmoji + }); }, /** diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 4fbdbdb..d07626e 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -354,27 +354,19 @@
-
+
Social
-
- -
- -
-
- - Liked by 0 -
-
- + +
+
+ +
+
+
@@ -1476,47 +1468,193 @@ } } - // Social interactions functionality + // The fixed reaction palette. Mirrors the backend ReactionEmoji.PALETTE + // and the V29 DB CHECK constraint. Keep both sides in sync. + const REACTION_PALETTE = ['❤️', '🔥', '💪', '🏔️', '🤯', '🥲']; + + // Social interactions: load reactions for the activity, render the + // reactions block, and render the per-reactor list. async function loadLikes() { try { const response = await fetch(`/api/activities/${activityId}/likes`); if (response.ok) { const likes = await response.json(); - renderLikes(likes); + renderReactorList(likes); } } catch (error) { console.error('Error loading likes:', error); } } - function renderLikes(likes) { + function renderReactorList(likes) { const likesList = document.getElementById('likesList'); - const likeCount = document.getElementById('likeCount'); - const likesCountText = document.getElementById('likesCountText'); - - likeCount.textContent = likes.length; - likesCountText.textContent = likes.length; - if (likes.length === 0) { - likesList.innerHTML = 'No likes yet'; + likesList.innerHTML = 'No reactions yet'; return; } - likesList.innerHTML = likes.map(like => { + likesList.innerHTML = `
` + likes.map(like => { const displayName = like.displayName || like.username || 'Unknown'; const avatarHtml = like.avatarUrl - ? `${displayName}` + ? `${escapeHtml(displayName)}` : ``; + const emoji = like.emoji || '❤️'; return `
${avatarHtml} - ${displayName} + ${escapeHtml(displayName)} + ${emoji}
`; - }).join(''); + }).join('') + `
`; } + /** + * Build the reactions block HTML for the detail page from the activity DTO. + * Identical structure to FitPubTimeline.renderReactionsBlock so the same CSS + * classes/handlers work — but inlined here so the detail page doesn't depend + * on timeline.js being loaded. + */ + function renderReactionsBlockHtml(activity) { + const counts = activity.reactionCounts || {}; + const currentReaction = activity.currentUserReaction || null; + + const chips = REACTION_PALETTE + .filter(emoji => (counts[emoji] || 0) > 0) + .map(emoji => { + const count = counts[emoji]; + const mine = emoji === currentReaction; + return ``; + }) + .join(''); + + const pickerButtons = REACTION_PALETTE.map(emoji => ` + + `).join(''); + + return ` +
+ ${chips} + +
+ ${pickerButtons} +
+
+ `; + } + + function renderReactionsBlock(activity) { + const container = document.getElementById('reactionsBlockContainer'); + container.innerHTML = renderReactionsBlockHtml(activity); + attachReactionHandlers(container.querySelector('.reactions-block')); + } + + function attachReactionHandlers(block) { + if (!block) return; + block.querySelectorAll('.reaction-chip').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const emoji = btn.dataset.emoji; + const mine = btn.dataset.mine === 'true'; + sendReaction(mine ? null : emoji); + }); + }); + + const addBtn = block.querySelector('.reaction-add-btn'); + const picker = block.querySelector('.reaction-picker'); + if (addBtn && picker) { + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const willOpen = picker.classList.contains('d-none'); + document.querySelectorAll('.reaction-picker').forEach(p => { + p.classList.add('d-none'); + p.classList.remove('d-flex'); + }); + if (willOpen) { + picker.classList.remove('d-none'); + picker.classList.add('d-flex'); + } + }); + } + + block.querySelectorAll('.reaction-picker-option').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (picker) { + picker.classList.add('d-none'); + picker.classList.remove('d-flex'); + } + sendReaction(btn.dataset.emoji); + }); + }); + } + + async function sendReaction(emoji) { + if (!FitPubAuth.isAuthenticated()) { + window.location.href = '/login'; + return; + } + try { + let response; + if (emoji === null) { + response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'DELETE' } + ); + } else { + response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'POST', body: { emoji: emoji } } + ); + } + if (!response.ok) { + console.error('Reaction request failed:', response.status); + return; + } + // Reload the full state from the server so the per-reactor list is fresh too + const activityResponse = await fetch(`/api/activities/${activityId}`); + if (activityResponse.ok) { + const updatedActivity = await activityResponse.json(); + renderReactionsBlock(updatedActivity); + } + loadLikes(); + } catch (err) { + console.error('Reaction request errored:', err); + FitPub.showAlert('Failed to update reaction. Please try again.', 'danger'); + } + } + + // One document-level click handler closes any open picker on outside click. + document.addEventListener('click', (e) => { + const insidePicker = e.target.closest('.reaction-picker, .reaction-add-btn'); + if (insidePicker) return; + document.querySelectorAll('.reaction-picker').forEach(p => { + p.classList.add('d-none'); + p.classList.remove('d-flex'); + }); + }); + async function loadComments() { try { const response = await fetch(`/api/activities/${activityId}/comments`); @@ -1585,83 +1723,20 @@ function setupSocialInteractions(activity) { const isAuthenticated = FitPubAuth.isAuthenticated(); + // Render the reactions block from the activity DTO regardless of auth. + // Anonymous users see the chips and counts; clicking one bounces them to + // login (handled inside sendReaction). + renderReactionsBlock(activity); + if (isAuthenticated) { // Show comment form for authenticated users document.getElementById('commentForm').style.display = 'block'; - // Update like button based on activity data - if (activity.likedByCurrentUser) { - updateLikeButton(true); - } - - // Setup like button click handler - document.getElementById('likeBtn').addEventListener('click', handleLikeClick); - - // Show comment form for authenticated users - document.getElementById('commentForm').style.display = 'block'; - // Setup comment form submit handler document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit); } else { // Show login prompt for non-authenticated users document.getElementById('loginPrompt').style.display = 'block'; - // Hide like button for non-authenticated users - document.getElementById('likeBtn').style.display = 'none'; - } - } - - async function handleLikeClick(event) { - event.preventDefault(); - const btn = event.currentTarget; - const isLiked = btn.classList.contains('btn-danger'); - - try { - if (isLiked) { - // Unlike - const response = await FitPubAuth.authenticatedFetch( - `/api/activities/${activityId}/likes`, - { method: 'DELETE' } - ); - - if (response.ok) { - updateLikeButton(false); - loadLikes(); // Reload likes list - } - } else { - // Like - const response = await FitPubAuth.authenticatedFetch( - `/api/activities/${activityId}/likes`, - { method: 'POST' } - ); - - if (response.ok) { - updateLikeButton(true); - loadLikes(); // Reload likes list - } - } - } catch (error) { - console.error('Error toggling like:', error); - FitPub.showAlert('Failed to update like. Please try again.', 'danger'); - } - } - - function updateLikeButton(isLiked) { - const btn = document.getElementById('likeBtn'); - const btnText = document.getElementById('likeBtnText'); - const icon = btn.querySelector('i'); - - if (isLiked) { - btn.classList.remove('btn-outline-danger'); - btn.classList.add('btn-danger'); - icon.classList.remove('bi-heart'); - icon.classList.add('bi-heart-fill'); - btnText.textContent = 'Liked'; - } else { - btn.classList.remove('btn-danger'); - btn.classList.add('btn-outline-danger'); - icon.classList.remove('bi-heart-fill'); - icon.classList.add('bi-heart'); - btnText.textContent = 'Like'; } } diff --git a/src/main/resources/templates/notifications.html b/src/main/resources/templates/notifications.html index e567eb5..bea4a05 100644 --- a/src/main/resources/templates/notifications.html +++ b/src/main/resources/templates/notifications.html @@ -400,7 +400,8 @@ getNotificationMessage(notification) { switch (notification.type) { case 'ACTIVITY_LIKED': - return `liked your activity ${this.escapeHtml(notification.activityTitle)}`; + const reactionEmoji = notification.reactionEmoji || '❤️'; + return `reacted ${reactionEmoji} to your activity ${this.escapeHtml(notification.activityTitle)}`; case 'ACTIVITY_COMMENTED': const preview = notification.commentText ? `: "${this.escapeHtml(notification.commentText)}"`