diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java index bf2b2b1ad..500ad2a05 100644 --- a/backend/src/main/java/com/openisle/controller/MessageController.java +++ b/backend/src/main/java/com/openisle/controller/MessageController.java @@ -5,7 +5,6 @@ import com.openisle.dto.ConversationDto; import com.openisle.dto.CreateConversationRequest; import com.openisle.dto.CreateConversationResponse; import com.openisle.dto.MessageDto; -import com.openisle.dto.UserSummaryDto; import com.openisle.model.Message; import com.openisle.model.MessageConversation; import com.openisle.model.User; @@ -55,16 +54,16 @@ public class MessageController { @PostMapping public ResponseEntity sendMessage(@RequestBody MessageRequest req, Authentication auth) { - Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent()); - return ResponseEntity.ok(toDto(message)); + Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId()); + return ResponseEntity.ok(messageService.toDto(message)); } @PostMapping("/conversations/{conversationId}/messages") public ResponseEntity sendMessageToConversation(@PathVariable Long conversationId, @RequestBody ChannelMessageRequest req, Authentication auth) { - Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent()); - return ResponseEntity.ok(toDto(message)); + Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId()); + return ResponseEntity.ok(messageService.toDto(message)); } @PostMapping("/conversations/{conversationId}/read") @@ -79,23 +78,6 @@ public class MessageController { return ResponseEntity.ok(new CreateConversationResponse(conversation.getId())); } - private MessageDto toDto(Message message) { - MessageDto dto = new MessageDto(); - dto.setId(message.getId()); - dto.setContent(message.getContent()); - dto.setCreatedAt(message.getCreatedAt()); - - dto.setConversationId(message.getConversation().getId()); - - UserSummaryDto senderDto = new UserSummaryDto(); - senderDto.setId(message.getSender().getId()); - senderDto.setUsername(message.getSender().getUsername()); - senderDto.setAvatar(message.getSender().getAvatar()); - dto.setSender(senderDto); - - return dto; - } - @GetMapping("/unread-count") public ResponseEntity getUnreadCount(Authentication auth) { return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth))); @@ -105,6 +87,7 @@ public class MessageController { static class MessageRequest { private Long recipientId; private String content; + private Long replyToId; public Long getRecipientId() { return recipientId; @@ -121,10 +104,19 @@ public class MessageController { public void setContent(String content) { this.content = content; } + + public Long getReplyToId() { + return replyToId; + } + + public void setReplyToId(Long replyToId) { + this.replyToId = replyToId; + } } static class ChannelMessageRequest { private String content; + private Long replyToId; public String getContent() { return content; @@ -133,5 +125,13 @@ public class MessageController { public void setContent(String content) { this.content = content; } + + public Long getReplyToId() { + return replyToId; + } + + public void setReplyToId(Long replyToId) { + this.replyToId = replyToId; + } } } \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/controller/ReactionController.java b/backend/src/main/java/com/openisle/controller/ReactionController.java index 5ef655e1b..ce0966160 100644 --- a/backend/src/main/java/com/openisle/controller/ReactionController.java +++ b/backend/src/main/java/com/openisle/controller/ReactionController.java @@ -57,4 +57,17 @@ public class ReactionController { pointService.awardForReactionOfComment(auth.getName(), commentId); return ResponseEntity.ok(dto); } + + @PostMapping("/messages/{messageId}/reactions") + public ResponseEntity reactToMessage(@PathVariable Long messageId, + @RequestBody ReactionRequest req, + Authentication auth) { + Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType()); + if (reaction == null) { + return ResponseEntity.noContent().build(); + } + ReactionDto dto = reactionMapper.toDto(reaction); + dto.setReward(levelService.awardForReaction(auth.getName())); + return ResponseEntity.ok(dto); + } } diff --git a/backend/src/main/java/com/openisle/dto/MessageDto.java b/backend/src/main/java/com/openisle/dto/MessageDto.java index ff536cf84..956a8a7c4 100644 --- a/backend/src/main/java/com/openisle/dto/MessageDto.java +++ b/backend/src/main/java/com/openisle/dto/MessageDto.java @@ -2,6 +2,7 @@ package com.openisle.dto; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data public class MessageDto { @@ -10,4 +11,6 @@ public class MessageDto { private UserSummaryDto sender; private Long conversationId; private LocalDateTime createdAt; + private MessageDto replyTo; + private List reactions; } \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/ReactionDto.java b/backend/src/main/java/com/openisle/dto/ReactionDto.java index b57a3d333..7266bbfc0 100644 --- a/backend/src/main/java/com/openisle/dto/ReactionDto.java +++ b/backend/src/main/java/com/openisle/dto/ReactionDto.java @@ -4,7 +4,7 @@ import com.openisle.model.ReactionType; import lombok.Data; /** - * DTO representing a reaction on a post or comment. + * DTO representing a reaction on a post, comment or message. */ @Data public class ReactionDto { @@ -13,6 +13,7 @@ public class ReactionDto { private String user; private Long postId; private Long commentId; + private Long messageId; private int reward; } diff --git a/backend/src/main/java/com/openisle/mapper/ReactionMapper.java b/backend/src/main/java/com/openisle/mapper/ReactionMapper.java index d5cca8c90..8e23fb0b5 100644 --- a/backend/src/main/java/com/openisle/mapper/ReactionMapper.java +++ b/backend/src/main/java/com/openisle/mapper/ReactionMapper.java @@ -19,6 +19,9 @@ public class ReactionMapper { if (reaction.getComment() != null) { dto.setCommentId(reaction.getComment().getId()); } + if (reaction.getMessage() != null) { + dto.setMessageId(reaction.getMessage().getId()); + } dto.setReward(0); return dto; } diff --git a/backend/src/main/java/com/openisle/model/Message.java b/backend/src/main/java/com/openisle/model/Message.java index 2cd1d4cca..72edb3e2b 100644 --- a/backend/src/main/java/com/openisle/model/Message.java +++ b/backend/src/main/java/com/openisle/model/Message.java @@ -29,6 +29,10 @@ public class Message { @Column(nullable = false, columnDefinition = "TEXT") private String content; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reply_to_id") + private Message replyTo; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/openisle/model/Reaction.java b/backend/src/main/java/com/openisle/model/Reaction.java index b2610e8df..7b7b7240e 100644 --- a/backend/src/main/java/com/openisle/model/Reaction.java +++ b/backend/src/main/java/com/openisle/model/Reaction.java @@ -7,7 +7,7 @@ import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; /** - * Reaction entity representing a user's reaction to a post or comment. + * Reaction entity representing a user's reaction to a post, comment or message. */ @Entity @Getter @@ -16,7 +16,8 @@ import org.hibernate.annotations.CreationTimestamp; @Table(name = "reactions", uniqueConstraints = { @UniqueConstraint(columnNames = {"user_id", "post_id", "type"}), - @UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}) + @UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}), + @UniqueConstraint(columnNames = {"user_id", "message_id", "type"}) }) public class Reaction { @Id @@ -39,6 +40,10 @@ public class Reaction { @JoinColumn(name = "comment_id") private Comment comment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id") + private Message message; + @CreationTimestamp @Column(nullable = false, updatable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") diff --git a/backend/src/main/java/com/openisle/repository/ReactionRepository.java b/backend/src/main/java/com/openisle/repository/ReactionRepository.java index e66c1f568..4f07eab80 100644 --- a/backend/src/main/java/com/openisle/repository/ReactionRepository.java +++ b/backend/src/main/java/com/openisle/repository/ReactionRepository.java @@ -1,6 +1,7 @@ package com.openisle.repository; import com.openisle.model.Comment; +import com.openisle.model.Message; import com.openisle.model.Post; import com.openisle.model.Reaction; import com.openisle.model.User; @@ -15,8 +16,10 @@ import java.util.Optional; public interface ReactionRepository extends JpaRepository { Optional findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type); Optional findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type); + Optional findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type); List findByPost(Post post); List findByComment(Comment comment); + List findByMessage(Message message); @Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC") List findTopPostIds(@Param("username") String username, Pageable pageable); diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java index f4e1d20cd..9993cd8f3 100644 --- a/backend/src/main/java/com/openisle/service/MessageService.java +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -4,14 +4,18 @@ import com.openisle.model.Message; import com.openisle.model.MessageConversation; import com.openisle.model.MessageParticipant; import com.openisle.model.User; +import com.openisle.model.Reaction; import com.openisle.repository.MessageConversationRepository; import com.openisle.repository.MessageParticipantRepository; import com.openisle.repository.MessageRepository; import com.openisle.repository.UserRepository; +import com.openisle.repository.ReactionRepository; import com.openisle.dto.ConversationDetailDto; import com.openisle.dto.ConversationDto; import com.openisle.dto.MessageDto; +import com.openisle.dto.ReactionDto; import com.openisle.dto.UserSummaryDto; +import com.openisle.mapper.ReactionMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -34,9 +38,11 @@ public class MessageService { private final MessageParticipantRepository participantRepository; private final UserRepository userRepository; private final SimpMessagingTemplate messagingTemplate; + private final ReactionRepository reactionRepository; + private final ReactionMapper reactionMapper; @Transactional - public Message sendMessage(Long senderId, Long recipientId, String content) { + public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) { log.info("Attempting to send message from user {} to user {}", senderId, recipientId); User sender = userRepository.findById(senderId) .orElseThrow(() -> new IllegalArgumentException("Sender not found")); @@ -51,6 +57,11 @@ public class MessageService { message.setConversation(conversation); message.setSender(sender); message.setContent(content); + if (replyToId != null) { + Message replyTo = messageRepository.findById(replyToId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + message.setReplyTo(replyTo); + } message = messageRepository.save(message); log.info("Message saved with ID: {}", message.getId()); @@ -83,7 +94,7 @@ public class MessageService { } @Transactional - public Message sendMessageToConversation(Long senderId, Long conversationId, String content) { + public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) { User sender = userRepository.findById(senderId) .orElseThrow(() -> new IllegalArgumentException("Sender not found")); MessageConversation conversation = conversationRepository.findById(conversationId) @@ -102,6 +113,11 @@ public class MessageService { message.setConversation(conversation); message.setSender(sender); message.setContent(content); + if (replyToId != null) { + Message replyTo = messageRepository.findById(replyToId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + message.setReplyTo(replyTo); + } message = messageRepository.save(message); conversation.setLastMessage(message); @@ -128,7 +144,7 @@ public class MessageService { return message; } - private MessageDto toDto(Message message) { + public MessageDto toDto(Message message) { MessageDto dto = new MessageDto(); dto.setId(message.getId()); dto.setContent(message.getContent()); @@ -141,6 +157,25 @@ public class MessageService { userSummaryDto.setAvatar(message.getSender().getAvatar()); dto.setSender(userSummaryDto); + if (message.getReplyTo() != null) { + Message reply = message.getReplyTo(); + MessageDto replyDto = new MessageDto(); + replyDto.setId(reply.getId()); + replyDto.setContent(reply.getContent()); + UserSummaryDto replySender = new UserSummaryDto(); + replySender.setId(reply.getSender().getId()); + replySender.setUsername(reply.getSender().getUsername()); + replySender.setAvatar(reply.getSender().getAvatar()); + replyDto.setSender(replySender); + dto.setReplyTo(replyDto); + } + + java.util.List reactions = reactionRepository.findByMessage(message); + java.util.List reactionDtos = reactions.stream() + .map(reactionMapper::toDto) + .collect(Collectors.toList()); + dto.setReactions(reactionDtos); + return dto; } diff --git a/backend/src/main/java/com/openisle/service/ReactionService.java b/backend/src/main/java/com/openisle/service/ReactionService.java index b6cda46f5..8df020eaf 100644 --- a/backend/src/main/java/com/openisle/service/ReactionService.java +++ b/backend/src/main/java/com/openisle/service/ReactionService.java @@ -6,10 +6,12 @@ import com.openisle.model.Reaction; import com.openisle.model.ReactionType; import com.openisle.model.User; import com.openisle.model.NotificationType; +import com.openisle.model.Message; import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; +import com.openisle.repository.MessageRepository; import com.openisle.service.NotificationService; import com.openisle.service.EmailSender; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ public class ReactionService { private final UserRepository userRepository; private final PostRepository postRepository; private final CommentRepository commentRepository; + private final MessageRepository messageRepository; private final NotificationService notificationService; private final EmailSender emailSender; @@ -77,6 +80,26 @@ public class ReactionService { return reaction; } + @Transactional + public Reaction reactToMessage(String username, Long messageId, ReactionType type) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Message message = messageRepository.findById(messageId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + java.util.Optional existing = + reactionRepository.findByUserAndMessageAndType(user, message, type); + if (existing.isPresent()) { + reactionRepository.delete(existing.get()); + return null; + } + Reaction reaction = new Reaction(); + reaction.setUser(user); + reaction.setMessage(message); + reaction.setType(type); + reaction = reactionRepository.save(reaction); + return reaction; + } + public java.util.List getReactionsForPost(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); diff --git a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java index 635dad98e..8b1ab96b6 100644 --- a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java @@ -5,6 +5,7 @@ import com.openisle.model.Post; import com.openisle.model.Reaction; import com.openisle.model.ReactionType; import com.openisle.model.User; +import com.openisle.model.Message; import com.openisle.service.ReactionService; import com.openisle.service.LevelService; import com.openisle.mapper.ReactionMapper; @@ -78,6 +79,27 @@ class ReactionControllerTest { .andExpect(jsonPath("$.commentId").value(2)); } + @Test + void reactToMessage() throws Exception { + User user = new User(); + user.setUsername("u3"); + Message message = new Message(); + message.setId(3L); + Reaction reaction = new Reaction(); + reaction.setId(3L); + reaction.setUser(user); + reaction.setMessage(message); + reaction.setType(ReactionType.LIKE); + Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction); + + mockMvc.perform(post("/api/messages/3/reactions") + .contentType("application/json") + .content("{\"type\":\"LIKE\"}") + .principal(new UsernamePasswordAuthenticationToken("u3", "p"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").value(3)); + } + @Test void listReactionTypes() throws Exception { mockMvc.perform(get("/api/reaction-types")) diff --git a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java index 9c4cb5780..0c74a8f36 100644 --- a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java +++ b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java @@ -15,9 +15,10 @@ class ReactionServiceTest { UserRepository userRepo = mock(UserRepository.class); PostRepository postRepo = mock(PostRepository.class); CommentRepository commentRepo = mock(CommentRepository.class); + MessageRepository messageRepo = mock(MessageRepository.class); NotificationService notif = mock(NotificationService.class); EmailSender email = mock(EmailSender.class); - ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email); + ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email); org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); User user = new User(); diff --git a/frontend_nuxt/app.vue b/frontend_nuxt/app.vue index 8eab548cf..6a429d5ae 100644 --- a/frontend_nuxt/app.vue +++ b/frontend_nuxt/app.vue @@ -1,6 +1,6 @@ @@ -30,6 +39,7 @@ import HeaderComponent from '~/components/HeaderComponent.vue' import MenuComponent from '~/components/MenuComponent.vue' import GlobalPopups from '~/components/GlobalPopups.vue' import ConfirmDialog from '~/components/ConfirmDialog.vue' +import MessageFloatWindow from '~/components/MessageFloatWindow.vue' import { useIsMobile } from '~/utils/screen' const isMobile = useIsMobile() @@ -52,6 +62,7 @@ const hideMenu = computed(() => { }) const header = useTemplateRef('header') +const isFloatMode = computed(() => useRoute().query.float !== undefined) onMounted(() => { if (typeof window !== 'undefined') { diff --git a/frontend_nuxt/components/MessageFloatWindow.vue b/frontend_nuxt/components/MessageFloatWindow.vue new file mode 100644 index 000000000..3f2f39182 --- /dev/null +++ b/frontend_nuxt/components/MessageFloatWindow.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend_nuxt/components/ReactionsGroup.vue b/frontend_nuxt/components/ReactionsGroup.vue index dc8fcea22..697cc81e0 100644 --- a/frontend_nuxt/components/ReactionsGroup.vue +++ b/frontend_nuxt/components/ReactionsGroup.vue @@ -19,7 +19,7 @@
- +
@@ -37,7 +37,11 @@
-