Emoji likes

This commit is contained in:
Tim Zöller 2026-04-07 19:11:29 +02:00
parent 897252f9cd
commit 662363555b
20 changed files with 860 additions and 209 deletions

View file

@ -19,6 +19,7 @@ import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.WeatherService; import net.javahippie.fitpub.service.WeatherService;
import net.javahippie.fitpub.service.FitFileService; import net.javahippie.fitpub.service.FitFileService;
import net.javahippie.fitpub.service.PrivacyZoneService; import net.javahippie.fitpub.service.PrivacyZoneService;
import net.javahippie.fitpub.service.ReactionEnricher;
import net.javahippie.fitpub.service.TrackPrivacyFilter; import net.javahippie.fitpub.service.TrackPrivacyFilter;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -56,6 +57,7 @@ public class ActivityController {
private final PrivacyZoneService privacyZoneService; private final PrivacyZoneService privacyZoneService;
private final TrackPrivacyFilter trackPrivacyFilter; private final TrackPrivacyFilter trackPrivacyFilter;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
private final ReactionEnricher reactionEnricher;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -187,6 +189,7 @@ public class ActivityController {
// Public activities are always accessible, but apply privacy filtering // Public activities are always accessible, but apply privacy filtering
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter);
populatePeaks(dto, id); populatePeaks(dto, id);
reactionEnricher.enrichSingle(dto, requestingUserId);
log.debug("Activity {} - DTO privacy zones: {}", id, log.debug("Activity {} - DTO privacy zones: {}", id,
dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0); dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
@ -208,6 +211,7 @@ public class ActivityController {
// Apply privacy filtering (owner sees full track, others see filtered) // Apply privacy filtering (owner sees full track, others see filtered)
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter); ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter);
populatePeaks(dto, id); populatePeaks(dto, id);
reactionEnricher.enrichSingle(dto, requestingUserId);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@ -247,6 +251,9 @@ public class ActivityController {
// Convert to DTOs // Convert to DTOs
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity); org.springframework.data.domain.Page<ActivityDTO> 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 Spring Page object with all pagination metadata
return ResponseEntity.ok(dtoPage); return ResponseEntity.ok(dtoPage);
} }
@ -376,6 +383,7 @@ public class ActivityController {
.sorted((a, b) -> b.getStartedAt().compareTo(a.getStartedAt())) .sorted((a, b) -> b.getStartedAt().compareTo(a.getStartedAt()))
.map(activity -> ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter)) .map(activity -> ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter))
.toList(); .toList();
reactionEnricher.enrichActivities(dtos, requestingUserId);
org.springframework.data.domain.Pageable peakPageable = org.springframework.data.domain.Pageable peakPageable =
org.springframework.data.domain.PageRequest.of(0, Math.max(dtos.size(), 1)); 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())); 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) ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter)
); );
reactionEnricher.enrichActivities(dtoPage.getContent(), requestingUserId);
return ResponseEntity.ok(dtoPage); return ResponseEntity.ok(dtoPage);
} }

View file

@ -2,6 +2,7 @@ package net.javahippie.fitpub.controller;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.ReactionEmoji;
import net.javahippie.fitpub.model.dto.LikeDTO; import net.javahippie.fitpub.model.dto.LikeDTO;
import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.Like; import net.javahippie.fitpub.model.entity.Like;
@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; 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 * <p>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).
*
* <p>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 * @param userDetails the authenticated user
* @return the created like * @return the created or updated reaction
*/ */
@PostMapping @PostMapping
@Transactional @Transactional
public ResponseEntity<LikeDTO> likeActivity( public ResponseEntity<LikeDTO> reactToActivity(
@PathVariable UUID activityId, @PathVariable UUID activityId,
@RequestBody(required = false) ReactionRequest request,
@AuthenticationPrincipal UserDetails userDetails @AuthenticationPrincipal UserDetails userDetails
) { ) {
User user = getUser(userDetails); User user = getUser(userDetails);
@ -95,35 +113,70 @@ public class LikeController {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
// Check if already liked // Resolve and validate the emoji. A missing body / null field defaults to
if (likeRepository.existsByActivityIdAndUserId(activityId, user.getId())) { // (backwards compat with the original heart-only "like" client). An explicit
return ResponseEntity.status(HttpStatus.CONFLICT).build(); // 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 // UPSERT: update the existing row if present, otherwise create a new one.
Like like = Like.builder() Optional<Like> existing = likeRepository.findByActivityIdAndUserId(activityId, user.getId());
.activityId(activityId) Like saved;
.userId(user.getId()) boolean created;
.displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()) if (existing.isPresent()) {
.avatarUrl(user.getAvatarUrl()) Like like = existing.get();
.build(); 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); // Send ActivityPub Like activity to followers if activity is public.
// Federation always sends a fresh Like + content; downstream Pleroma/Akkoma will
// Create notification for activity owner // overwrite the previous reaction, vanilla Mastodon will just show another like.
String likerActorUri = user.getActorUri(baseUrl);
notificationService.createActivityLikedNotification(activity, likerActorUri);
// Send ActivityPub Like activity to followers if activity is public
if (activity.getVisibility() == Activity.Visibility.PUBLIC) { if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
String activityUri = baseUrl + "/activities/" + activityId; String activityUri = baseUrl + "/activities/" + activityId;
federationService.sendLikeActivity(activityUri, user); federationService.sendLikeActivity(activityUri, user, emoji);
} }
return ResponseEntity.status(HttpStatus.CREATED) HttpStatus status = created ? HttpStatus.CREATED : HttpStatus.OK;
.body(LikeDTO.fromEntity(saved, baseUrl)); return ResponseEntity.status(status).body(LikeDTO.fromEntity(saved, baseUrl));
} }
/** /**

View file

@ -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.
*
* <p>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<String> 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;
}
}

View file

@ -65,6 +65,10 @@ public class ActivityDTO {
private Long likesCount; private Long likesCount;
private Long commentsCount; private Long commentsCount;
private Boolean likedByCurrentUser; // True if current user has liked this activity private Boolean likedByCurrentUser; // True if current user has liked this activity
/** Per-emoji reaction counts. Only emojis with count &gt; 0 are present. */
private java.util.Map<String, Long> reactionCounts;
/** The current user's own reaction emoji on this activity, or null if none. */
private String currentUserReaction;
// Peaks visited on this activity // Peaks visited on this activity
private List<PeakDTO> peaks; private List<PeakDTO> peaks;

View file

@ -23,6 +23,7 @@ public class LikeDTO {
private String actorUri; // Local user URI or remote actor URI private String actorUri; // Local user URI or remote actor URI
private String displayName; private String displayName;
private String avatarUrl; private String avatarUrl;
private String emoji;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private boolean local; private boolean local;
@ -44,6 +45,7 @@ public class LikeDTO {
.actorUri(actorUri) .actorUri(actorUri)
.displayName(like.getDisplayName()) .displayName(like.getDisplayName())
.avatarUrl(like.getAvatarUrl()) .avatarUrl(like.getAvatarUrl())
.emoji(like.getEmoji())
.createdAt(like.getCreatedAt()) .createdAt(like.getCreatedAt())
.local(like.isLocal()) .local(like.isLocal())
.build(); .build();

View file

@ -28,6 +28,8 @@ public class NotificationDTO {
private String activityTitle; private String activityTitle;
private UUID commentId; private UUID commentId;
private String commentText; private String commentText;
/** For ACTIVITY_LIKED notifications: the emoji used in the reaction. Null otherwise. */
private String reactionEmoji;
private boolean read; private boolean read;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime readAt; private LocalDateTime readAt;
@ -50,6 +52,7 @@ public class NotificationDTO {
.activityTitle(notification.getActivityTitle()) .activityTitle(notification.getActivityTitle())
.commentId(notification.getCommentId()) .commentId(notification.getCommentId())
.commentText(notification.getCommentText()) .commentText(notification.getCommentText())
.reactionEmoji(notification.getReactionEmoji())
.read(notification.isRead()) .read(notification.isRead())
.createdAt(notification.getCreatedAt()) .createdAt(notification.getCreatedAt())
.readAt(notification.getReadAt()) .readAt(notification.getReadAt())

View file

@ -56,6 +56,10 @@ public class TimelineActivityDTO {
private Long likesCount; private Long likesCount;
private Long commentsCount; private Long commentsCount;
private Boolean likedByCurrentUser; private Boolean likedByCurrentUser;
/** Per-emoji reaction counts. Only emojis with count &gt; 0 are present. */
private java.util.Map<String, Long> reactionCounts;
/** The current user's own reaction emoji on this activity, or null if none. */
private String currentUserReaction;
// GPS track availability // GPS track availability
private Boolean hasGpsTrack; // True if activity has GPS data private Boolean hasGpsTrack; // True if activity has GPS data

View file

@ -66,6 +66,15 @@ public class Like {
@Column(name = "activity_pub_id", length = 500) @Column(name = "activity_pub_id", length = 500)
private String activityPubId; 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 @CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View file

@ -93,6 +93,13 @@ public class Notification {
@Column(name = "comment_text", length = 200) @Column(name = "comment_text", length = 200)
private String commentText; 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. * Whether the notification has been read.
*/ */

View file

@ -3,9 +3,12 @@ package net.javahippie.fitpub.repository;
import net.javahippie.fitpub.model.entity.Like; import net.javahippie.fitpub.model.entity.Like;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; 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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@ -100,4 +103,37 @@ public interface LikeRepository extends JpaRepository<Like, UUID> {
@Modifying @Modifying
@Transactional @Transactional
void deleteByRemoteActorUri(String remoteActorUri); 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<Object[]> countByActivityIdsGroupedByEmoji(@Param("activityIds") Collection<UUID> 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<Object[]> findUserReactionsByActivityIds(@Param("userId") UUID userId,
@Param("activityIds") Collection<UUID> activityIds);
} }

View file

@ -333,13 +333,20 @@ public class FederationService {
} }
/** /**
* Send a Like activity. * Send a Like activity carrying an emoji reaction in the {@code content} field.
*
* <p>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 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 @Transactional
public void sendLikeActivity(String objectUri, User sender) { public void sendLikeActivity(String objectUri, User sender, String emoji) {
try { try {
String likeId = baseUrl + "/activities/like/" + UUID.randomUUID(); String likeId = baseUrl + "/activities/like/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + sender.getUsername(); String actorUri = baseUrl + "/users/" + sender.getUsername();
@ -350,6 +357,7 @@ public class FederationService {
likeActivity.put("id", likeId); likeActivity.put("id", likeId);
likeActivity.put("actor", actorUri); likeActivity.put("actor", actorUri);
likeActivity.put("object", objectUri); likeActivity.put("object", objectUri);
likeActivity.put("content", emoji);
likeActivity.put("published", Instant.now().toString()); likeActivity.put("published", Instant.now().toString());
// Send to all follower inboxes // Send to all follower inboxes
@ -358,7 +366,7 @@ public class FederationService {
sendActivity(inbox, likeActivity, sender); 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) { } catch (Exception e) {
log.error("Failed to send Like activity for object: {}", objectUri, e); log.error("Failed to send Like activity for object: {}", objectUri, e);

View file

@ -387,13 +387,24 @@ public class InboxProcessor {
/** /**
* Process a Like activity. * Process a Like activity.
*
* <p>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.
*
* <p>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<String, Object> activity) { private void processLike(String username, Map<String, Object> activity) {
try { try {
String actor = (String) activity.get("actor"); String actor = (String) activity.get("actor");
String objectUri = (String) activity.get("object"); 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 // Extract activity ID from the object URI
// Expected format: https://fitpub.example/activities/{uuid} // Expected format: https://fitpub.example/activities/{uuid}
@ -413,9 +424,24 @@ public class InboxProcessor {
// Fetch remote actor information // Fetch remote actor information
RemoteActor remoteActor = federationService.fetchRemoteActor(actor); RemoteActor remoteActor = federationService.fetchRemoteActor(actor);
// Check if like already exists // UPSERT: if a previous reaction from this actor exists, update the emoji
if (likeRepository.existsByActivityIdAndRemoteActorUri(activityId, actor)) { // in place. Otherwise create a new row and notify the activity owner.
log.debug("Like already exists from {} for activity {}", actor, activityId); java.util.Optional<Like> 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; return;
} }
@ -424,15 +450,16 @@ public class InboxProcessor {
.activityId(activityId) .activityId(activityId)
.userId(null) // Remote actor, not a local user .userId(null) // Remote actor, not a local user
.remoteActorUri(actor) .remoteActorUri(actor)
.emoji(emoji)
.displayName(remoteActor.getDisplayName() != null ? remoteActor.getDisplayName() : remoteActor.getUsername()) .displayName(remoteActor.getDisplayName() != null ? remoteActor.getDisplayName() : remoteActor.getUsername())
.avatarUrl(remoteActor.getAvatarUrl()) .avatarUrl(remoteActor.getAvatarUrl())
.build(); .build();
likeRepository.save(like); 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 // Create notification for activity owner
notificationService.createActivityLikedNotification(localActivity, actor); notificationService.createActivityLikedNotification(localActivity, actor, emoji);
} catch (Exception e) { } catch (Exception e) {
log.error("Error processing Like activity", e); log.error("Error processing Like activity", e);

View file

@ -39,9 +39,10 @@ public class NotificationService {
* *
* @param activity the activity that was liked * @param activity the activity that was liked
* @param likerActorUri the URI of the user who liked the activity * @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 @Transactional
public void createActivityLikedNotification(Activity activity, String likerActorUri) { public void createActivityLikedNotification(Activity activity, String likerActorUri, String emoji) {
// Get the activity owner // Get the activity owner
User activityOwner = userRepository.findById(activity.getUserId()) User activityOwner = userRepository.findById(activity.getUserId())
.orElse(null); .orElse(null);
@ -72,10 +73,12 @@ public class NotificationService {
.actorAvatarUrl(actorInfo.avatarUrl) .actorAvatarUrl(actorInfo.avatarUrl)
.activityId(activity.getId()) .activityId(activity.getId())
.activityTitle(activity.getTitle() != null ? activity.getTitle() : "Untitled Activity") .activityTitle(activity.getTitle() != null ? activity.getTitle() : "Untitled Activity")
.reactionEmoji(net.javahippie.fitpub.model.ReactionEmoji.normalise(emoji))
.build(); .build();
notificationRepository.save(notification); 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);
} }
/** /**

View file

@ -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.
*
* <p>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<TimelineActivityDTO> activities, UUID currentUserId) {
if (activities == null || activities.isEmpty()) {
return;
}
List<UUID> activityIds = activities.stream().map(TimelineActivityDTO::getId).toList();
Map<UUID, Map<String, Long>> countsByActivity = loadCounts(activityIds);
Map<UUID, String> 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<ActivityDTO> activities, UUID currentUserId) {
if (activities == null || activities.isEmpty()) {
return;
}
List<UUID> activityIds = activities.stream().map(ActivityDTO::getId).toList();
Map<UUID, Map<String, Long>> countsByActivity = loadCounts(activityIds);
Map<UUID, String> 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<UUID> activityIds = List.of(activity.getId());
Map<UUID, Map<String, Long>> countsByActivity = loadCounts(activityIds);
Map<UUID, String> userReactions = loadUserReactions(activityIds, currentUserId);
activity.setReactionCounts(countsByActivity.getOrDefault(activity.getId(), Map.of()));
activity.setCurrentUserReaction(userReactions.get(activity.getId()));
}
private Map<UUID, Map<String, Long>> loadCounts(Collection<UUID> activityIds) {
Map<UUID, Map<String, Long>> 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<UUID, String> loadUserReactions(Collection<UUID> activityIds, UUID currentUserId) {
if (currentUserId == null) {
return Map.of();
}
Map<UUID, String> result = new HashMap<>();
for (Object[] row : likeRepository.findUserReactionsByActivityIds(currentUserId, activityIds)) {
result.put((UUID) row[0], (String) row[1]);
}
return result;
}
}

View file

@ -46,6 +46,7 @@ public class TimelineService {
private final LikeRepository likeRepository; private final LikeRepository likeRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final TimelineResultMapper timelineResultMapper; private final TimelineResultMapper timelineResultMapper;
private final ReactionEnricher reactionEnricher;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -166,6 +167,7 @@ public class TimelineService {
log.debug("Fetched {} activities in single optimized query", timelineActivities.size()); log.debug("Fetched {} activities in single optimized query", timelineActivities.size());
reactionEnricher.enrichTimeline(timelineActivities, userId);
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
} }
@ -200,6 +202,7 @@ public class TimelineService {
log.debug("Fetched {} activities in single optimized query", timelineActivities.size()); log.debug("Fetched {} activities in single optimized query", timelineActivities.size());
reactionEnricher.enrichTimeline(timelineActivities, userId);
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
} }
@ -248,6 +251,7 @@ public class TimelineService {
log.debug("Found {} activities matching search criteria", timelineActivities.size()); log.debug("Found {} activities matching search criteria", timelineActivities.size());
reactionEnricher.enrichTimeline(timelineActivities, userId);
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
} }
@ -287,6 +291,7 @@ public class TimelineService {
log.debug("Found {} activities matching search criteria", timelineActivities.size()); log.debug("Found {} activities matching search criteria", timelineActivities.size());
reactionEnricher.enrichTimeline(timelineActivities, userId);
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
} }
@ -337,6 +342,7 @@ public class TimelineService {
log.debug("Found {} activities matching search criteria", timelineActivities.size()); log.debug("Found {} activities matching search criteria", timelineActivities.size());
reactionEnricher.enrichTimeline(timelineActivities, userId);
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements()); return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
} }

View file

@ -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.';

View file

@ -1371,3 +1371,56 @@ h1 {
.hashtag-link:hover { .hashtag-link:hover {
text-decoration: underline; 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;
}

View file

@ -3,6 +3,13 @@
* Handles loading and displaying timeline activities with preview maps * 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 = { const FitPubTimeline = {
currentPage: 0, currentPage: 0,
totalPages: 0, totalPages: 0,
@ -28,9 +35,27 @@ const FitPubTimeline = {
this.setupSearchHandlers(); this.setupSearchHandlers();
this.renderHashtagFilterBadge(); this.renderHashtagFilterBadge();
this.setupReactionPickerDismissal();
this.loadTimeline(0); 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 * Show or hide the active hashtag filter badge
*/ */
@ -242,16 +267,11 @@ const FitPubTimeline = {
<!-- Map or placeholder will be rendered here --> <!-- Map or placeholder will be rendered here -->
</div> </div>
<!-- Reactions (chips + picker) -->
${this.renderReactionsBlock(activity)}
<!-- Activity Actions --> <!-- Activity Actions -->
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<button
class="btn btn-sm ${activity.likedByCurrentUser ? 'btn-danger' : 'btn-outline-danger'} like-btn"
data-activity-id="${activity.id}"
data-liked="${activity.likedByCurrentUser || false}"
>
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
<span class="like-count">${activity.likesCount || 0}</span>
</button>
${activity.isLocal ${activity.isLocal
? `<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary"> ? `<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details <i class="bi bi-eye"></i> View Details
@ -280,81 +300,224 @@ const FitPubTimeline = {
}); });
}, 100); }, 100);
// Setup like button handlers // Setup reaction handlers (chips + picker buttons)
this.setupLikeButtons(); 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() { renderReactionsBlock: function(activity) {
const likeButtons = document.querySelectorAll('.like-btn'); const counts = activity.reactionCounts || {};
const currentReaction = activity.currentUserReaction || null;
likeButtons.forEach(btn => { // Render one chip per emoji that has at least one reaction. Sort by palette
btn.addEventListener('click', async (e) => { // 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 `<button type="button"
class="btn btn-sm reaction-chip ${mine ? 'btn-primary' : 'btn-outline-secondary'}"
data-activity-id="${activity.id}"
data-emoji="${emoji}"
data-mine="${mine}"
title="${mine ? 'Click to remove your reaction' : 'React with ' + emoji}">
<span class="reaction-emoji">${emoji}</span>
<span class="reaction-count">${count}</span>
</button>`;
})
.join('');
// Picker buttons — hidden by default, toggled by the add button.
const pickerButtons = REACTION_PALETTE.map(emoji => `
<button type="button" class="btn btn-sm btn-light reaction-picker-option"
data-activity-id="${activity.id}"
data-emoji="${emoji}"
title="React with ${emoji}">${emoji}</button>
`).join('');
return `
<div class="reactions-block d-flex flex-wrap gap-1 align-items-center mb-2"
data-activity-id="${activity.id}">
${chips}
<button type="button"
class="btn btn-sm btn-outline-secondary reaction-add-btn"
data-activity-id="${activity.id}"
title="Add a reaction">
<i class="bi bi-emoji-smile"></i>
</button>
<div class="reaction-picker d-none gap-1"
data-activity-id="${activity.id}">
${pickerButtons}
</div>
</div>
`;
},
/**
* 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(); e.preventDefault();
// Check if user is authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
const activityId = btn.dataset.activityId; const activityId = btn.dataset.activityId;
const isLiked = btn.dataset.liked === 'true'; const emoji = btn.dataset.emoji;
const icon = btn.querySelector('i'); const mine = btn.dataset.mine === 'true';
const countSpan = btn.querySelector('.like-count'); if (mine) {
this.sendReaction(activityId, null);
try { } else {
// Disable button during request this.sendReaction(activityId, emoji);
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;
} }
}); });
}); });
// 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
});
}, },
/** /**

View file

@ -354,27 +354,19 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-chat-heart"></i> Social <i class="bi bi-chat-heart"></i> Social
</h5> </h5>
<div>
<button id="likeBtn" class="btn btn-sm btn-outline-danger me-2">
<i class="bi bi-heart"></i>
<span id="likeBtnText">Like</span>
(<span id="likeCount">0</span>)
</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Likes Section --> <!-- Reactions Section -->
<div class="mb-4" id="likesSection"> <div class="mb-4" id="reactionsSection">
<h6 class="text-muted mb-3"> <div id="reactionsBlockContainer">
<i class="bi bi-heart-fill text-danger"></i> <!-- Filled in by JS once the activity loads -->
Liked by <span id="likesCountText">0</span> </div>
</h6> <div class="mt-3" id="likesList">
<div id="likesList" class="d-flex flex-wrap gap-2"> <!-- Per-reactor list of who reacted with what -->
<!-- Likes will be populated here -->
</div> </div>
</div> </div>
@ -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() { async function loadLikes() {
try { try {
const response = await fetch(`/api/activities/${activityId}/likes`); const response = await fetch(`/api/activities/${activityId}/likes`);
if (response.ok) { if (response.ok) {
const likes = await response.json(); const likes = await response.json();
renderLikes(likes); renderReactorList(likes);
} }
} catch (error) { } catch (error) {
console.error('Error loading likes:', error); console.error('Error loading likes:', error);
} }
} }
function renderLikes(likes) { function renderReactorList(likes) {
const likesList = document.getElementById('likesList'); 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) { if (likes.length === 0) {
likesList.innerHTML = '<span class="text-muted">No likes yet</span>'; likesList.innerHTML = '<span class="text-muted">No reactions yet</span>';
return; return;
} }
likesList.innerHTML = likes.map(like => { likesList.innerHTML = `<div class="d-flex flex-wrap gap-2">` + likes.map(like => {
const displayName = like.displayName || like.username || 'Unknown'; const displayName = like.displayName || like.username || 'Unknown';
const avatarHtml = like.avatarUrl const avatarHtml = like.avatarUrl
? `<img src="${like.avatarUrl}" alt="${displayName}" class="rounded-circle me-2" width="32" height="32">` ? `<img src="${like.avatarUrl}" alt="${escapeHtml(displayName)}" class="rounded-circle me-2" width="32" height="32">`
: `<i class="bi bi-person-circle me-2" style="font-size: 32px;"></i>`; : `<i class="bi bi-person-circle me-2" style="font-size: 32px;"></i>`;
const emoji = like.emoji || '❤️';
return ` return `
<div class="d-flex align-items-center p-2 border rounded"> <div class="d-flex align-items-center p-2 border rounded">
${avatarHtml} ${avatarHtml}
<span>${displayName}</span> <span>${escapeHtml(displayName)}</span>
<span class="ms-2">${emoji}</span>
</div> </div>
`; `;
}).join(''); }).join('') + `</div>`;
} }
/**
* 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 `<button type="button"
class="btn btn-sm reaction-chip ${mine ? 'btn-primary' : 'btn-outline-secondary'}"
data-activity-id="${activity.id}"
data-emoji="${emoji}"
data-mine="${mine}"
title="${mine ? 'Click to remove your reaction' : 'React with ' + emoji}">
<span class="reaction-emoji">${emoji}</span>
<span class="reaction-count">${count}</span>
</button>`;
})
.join('');
const pickerButtons = REACTION_PALETTE.map(emoji => `
<button type="button" class="btn btn-sm btn-light reaction-picker-option"
data-activity-id="${activity.id}"
data-emoji="${emoji}"
title="React with ${emoji}">${emoji}</button>
`).join('');
return `
<div class="reactions-block d-flex flex-wrap gap-1 align-items-center"
data-activity-id="${activity.id}">
${chips}
<button type="button"
class="btn btn-sm btn-outline-secondary reaction-add-btn"
data-activity-id="${activity.id}"
title="Add a reaction">
<i class="bi bi-emoji-smile"></i>
</button>
<div class="reaction-picker d-none gap-1"
data-activity-id="${activity.id}">
${pickerButtons}
</div>
</div>
`;
}
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() { async function loadComments() {
try { try {
const response = await fetch(`/api/activities/${activityId}/comments`); const response = await fetch(`/api/activities/${activityId}/comments`);
@ -1585,83 +1723,20 @@
function setupSocialInteractions(activity) { function setupSocialInteractions(activity) {
const isAuthenticated = FitPubAuth.isAuthenticated(); 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) { if (isAuthenticated) {
// Show comment form for authenticated users // Show comment form for authenticated users
document.getElementById('commentForm').style.display = 'block'; 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 // Setup comment form submit handler
document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit); document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit);
} else { } else {
// Show login prompt for non-authenticated users // Show login prompt for non-authenticated users
document.getElementById('loginPrompt').style.display = 'block'; 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';
} }
} }

View file

@ -400,7 +400,8 @@
getNotificationMessage(notification) { getNotificationMessage(notification) {
switch (notification.type) { switch (notification.type) {
case 'ACTIVITY_LIKED': case 'ACTIVITY_LIKED':
return `liked your activity <strong>${this.escapeHtml(notification.activityTitle)}</strong>`; const reactionEmoji = notification.reactionEmoji || '❤️';
return `reacted ${reactionEmoji} to your activity <strong>${this.escapeHtml(notification.activityTitle)}</strong>`;
case 'ACTIVITY_COMMENTED': case 'ACTIVITY_COMMENTED':
const preview = notification.commentText const preview = notification.commentText
? `: "${this.escapeHtml(notification.commentText)}"` ? `: "${this.escapeHtml(notification.commentText)}"`