Follow Users on the instance

This commit is contained in:
Tim Zöller 2025-12-15 10:25:40 +01:00
parent cc59701337
commit fe05e2ffa4
4 changed files with 262 additions and 2 deletions

View file

@ -123,6 +123,9 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check
.requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user
.requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user
// All other requests require authentication
.anyRequest().authenticated()

View file

@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
@ -271,4 +273,159 @@ public class UserController {
log.debug("Found {} following for user {}", actorDTOs.size(), username);
return ResponseEntity.ok(actorDTOs);
}
/**
* Follow a user.
*
* @param username the username to follow
* @param userDetails the authenticated user
* @return success response
*/
@PostMapping("/{username}/follow")
public ResponseEntity<Map<String, Object>> followUser(
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} attempting to follow {}", userDetails.getUsername(), username);
// Get the current user
User currentUser = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
// Get the user to follow
User userToFollow = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// Cannot follow yourself
if (currentUser.getId().equals(userToFollow.getId())) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Cannot follow yourself"));
}
String followingActorUri = userToFollow.getActorUri(baseUrl);
// Check if already following
Optional<Follow> existingFollow = followRepository.findByFollowerIdAndFollowingActorUri(
currentUser.getId(), followingActorUri
);
if (existingFollow.isPresent()) {
if (existingFollow.get().getStatus() == Follow.FollowStatus.ACCEPTED) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Already following this user"));
} else {
// Update existing pending follow to accepted
Follow follow = existingFollow.get();
follow.setStatus(Follow.FollowStatus.ACCEPTED);
followRepository.save(follow);
log.info("Updated pending follow to accepted: {} -> {}", currentUser.getUsername(), username);
}
} else {
// Create new follow relationship
String activityId = baseUrl + "/activities/follow/" + UUID.randomUUID();
Follow follow = Follow.builder()
.followerId(currentUser.getId())
.followingActorUri(followingActorUri)
.status(Follow.FollowStatus.ACCEPTED) // Auto-accept for local-to-local follows
.activityId(activityId)
.build();
followRepository.save(follow);
log.info("Created new follow: {} -> {}", currentUser.getUsername(), username);
}
// Get updated follower count
long followersCount = followRepository.countAcceptedFollowersByActorUri(followingActorUri);
return ResponseEntity.ok(Map.of(
"message", "Successfully followed " + username,
"followersCount", followersCount
));
}
/**
* Unfollow a user.
*
* @param username the username to unfollow
* @param userDetails the authenticated user
* @return success response
*/
@DeleteMapping("/{username}/follow")
public ResponseEntity<Map<String, Object>> unfollowUser(
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} attempting to unfollow {}", userDetails.getUsername(), username);
// Get the current user
User currentUser = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
// Get the user to unfollow
User userToUnfollow = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
String followingActorUri = userToUnfollow.getActorUri(baseUrl);
// Find the follow relationship
Optional<Follow> follow = followRepository.findByFollowerIdAndFollowingActorUri(
currentUser.getId(), followingActorUri
);
if (follow.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Not following this user"));
}
// Delete the follow relationship
followRepository.delete(follow.get());
log.info("Deleted follow: {} -> {}", currentUser.getUsername(), username);
// Get updated follower count
long followersCount = followRepository.countAcceptedFollowersByActorUri(followingActorUri);
return ResponseEntity.ok(Map.of(
"message", "Successfully unfollowed " + username,
"followersCount", followersCount
));
}
/**
* Check if the current user is following a specific user.
*
* @param username the username to check
* @param userDetails the authenticated user
* @return follow status
*/
@GetMapping("/{username}/follow-status")
public ResponseEntity<Map<String, Boolean>> getFollowStatus(
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
if (userDetails == null) {
return ResponseEntity.ok(Map.of("isFollowing", false));
}
User currentUser = userRepository.findByUsername(userDetails.getUsername())
.orElse(null);
if (currentUser == null) {
return ResponseEntity.ok(Map.of("isFollowing", false));
}
User targetUser = userRepository.findByUsername(username)
.orElse(null);
if (targetUser == null) {
return ResponseEntity.ok(Map.of("isFollowing", false));
}
String followingActorUri = targetUser.getActorUri(baseUrl);
Optional<Follow> follow = followRepository.findByFollowerIdAndFollowingActorUri(
currentUser.getId(), followingActorUri
);
boolean isFollowing = follow.isPresent() && follow.get().getStatus() == Follow.FollowStatus.ACCEPTED;
return ResponseEntity.ok(Map.of("isFollowing", isFollowing));
}
}

View file

@ -32,6 +32,9 @@ public class UserDTO {
private Long followersCount;
private Long followingCount;
// Follow status (populated separately, indicates if the current user is following this user)
private Boolean isFollowing;
/**
* Creates a DTO from a User entity.
* Note: email should only be included when user is viewing their own profile.

View file

@ -50,7 +50,8 @@
</div>
<div id="followButtonContainer" class="d-none">
<button class="btn btn-primary" id="followBtn">
<i class="bi bi-person-plus"></i> Follow
<span id="followBtnIcon"><i class="bi bi-person-plus"></i></span>
<span id="followBtnText">Follow</span>
</button>
</div>
</div>
@ -196,7 +197,103 @@
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Show follow button if viewing another user's profile
// TODO: implement follow functionality
checkAndShowFollowButton();
}
async function checkAndShowFollowButton() {
// Check if user is authenticated
if (!FitPubAuth.isAuthenticated()) {
return;
}
// Get current user's username
const currentUsername = FitPubAuth.getUsername();
// If viewing own profile, don't show follow button
if (!currentUsername || currentUsername === targetUsername) {
return;
}
// Show the follow button container
document.getElementById('followButtonContainer').classList.remove('d-none');
// Add click event listener to follow button
const followBtn = document.getElementById('followBtn');
followBtn.addEventListener('click', handleFollowToggle);
// Check follow status
await updateFollowButtonState();
}
async function updateFollowButtonState() {
try {
const response = await fetch(`/api/users/${targetUsername}/follow-status`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
});
if (response.ok) {
const data = await response.json();
const isFollowing = data.isFollowing;
const followBtn = document.getElementById('followBtn');
const followBtnIcon = document.getElementById('followBtnIcon');
const followBtnText = document.getElementById('followBtnText');
if (isFollowing) {
followBtn.className = 'btn btn-outline-danger';
followBtnIcon.innerHTML = '<i class="bi bi-person-dash"></i>';
followBtnText.textContent = 'Unfollow';
followBtn.dataset.following = 'true';
} else {
followBtn.className = 'btn btn-primary';
followBtnIcon.innerHTML = '<i class="bi bi-person-plus"></i>';
followBtnText.textContent = 'Follow';
followBtn.dataset.following = 'false';
}
}
} catch (error) {
console.error('Error checking follow status:', error);
}
}
async function handleFollowToggle() {
const followBtn = document.getElementById('followBtn');
const isFollowing = followBtn.dataset.following === 'true';
// Disable button during request
followBtn.disabled = true;
try {
const method = isFollowing ? 'DELETE' : 'POST';
const response = await FitPubAuth.authenticatedFetch(`/api/users/${targetUsername}/follow`, {
method: method
});
if (response.ok) {
const data = await response.json();
// Update follower count
if (data.followersCount !== undefined) {
document.getElementById('followersCount').textContent = data.followersCount;
}
// Update button state
await updateFollowButtonState();
// Show success message
FitPub.showAlert(data.message, 'success');
} else {
const errorData = await response.json();
FitPub.showAlert(errorData.error || 'Failed to update follow status', 'danger');
}
} catch (error) {
console.error('Error toggling follow:', error);
FitPub.showAlert('An error occurred. Please try again.', 'danger');
} finally {
followBtn.disabled = false;
}
}
let currentPage = 0;