Emoji likes
This commit is contained in:
parent
897252f9cd
commit
662363555b
20 changed files with 860 additions and 209 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
51
src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java
Normal file
51
src/main/java/net/javahippie/fitpub/model/ReactionEmoji.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 > 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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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 > 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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
30
src/main/resources/db/migration/V29__add_emoji_to_likes.sql
Normal file
30
src/main/resources/db/migration/V29__add_emoji_to_likes.sql
Normal 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.';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue