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/components/ReactionsGroup.vue b/frontend_nuxt/components/ReactionsGroup.vue index dc8fcea22..fefd10287 100644 --- a/frontend_nuxt/components/ReactionsGroup.vue +++ b/frontend_nuxt/components/ReactionsGroup.vue @@ -138,7 +138,9 @@ const toggleReaction = async (type) => { const url = props.contentType === 'post' ? `${API_BASE_URL}/api/posts/${props.contentId}/reactions` - : `${API_BASE_URL}/api/comments/${props.contentId}/reactions` + : props.contentType === 'comment' + ? `${API_BASE_URL}/api/comments/${props.contentId}/reactions` + : `${API_BASE_URL}/api/messages/${props.contentId}/reactions` // optimistic update const existingIdx = reactions.value.findIndex( diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue index fee571f53..d16e1ebf2 100644 --- a/frontend_nuxt/pages/message-box/[id].vue +++ b/frontend_nuxt/pages/message-box/[id].vue @@ -30,9 +30,21 @@ {{ TimeManager.format(item.createdAt) }} +
+
{{ item.replyTo.sender.username }}
+
+
+ +
回复
+
@@ -46,6 +58,11 @@
+
+ 正在回复 {{ replyTo.sender.username }}: + {{ stripMarkdownLength(replyTo.content, 50) }} + +
@@ -65,8 +82,9 @@ import { import { useRoute } from 'vue-router' import { getToken, fetchCurrentUser } from '~/utils/auth' import { toast } from '~/main' -import { renderMarkdown } from '~/utils/markdown' +import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown' import MessageEditor from '~/components/MessageEditor.vue' +import ReactionsGroup from '~/components/ReactionsGroup.vue' import { useWebSocket } from '~/composables/useWebSocket' import { useUnreadCount } from '~/composables/useUnreadCount' import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount' @@ -97,6 +115,7 @@ const loadingMore = ref(false) let scrollInterval = null const conversationName = ref('') const isChannel = ref(false) +const replyTo = ref(null) const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1) @@ -115,6 +134,10 @@ function handleAvatarError(event) { event.target.src = '/default-avatar.svg' } +function setReply(message) { + replyTo.value = message +} + // No changes needed here, as renderMarkdown is now imported. // The old function is removed. @@ -208,7 +231,7 @@ async function sendMessage(content, clearInput) { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ content }), + body: JSON.stringify({ content, replyToId: replyTo.value?.id }), }, ) } else { @@ -226,6 +249,7 @@ async function sendMessage(content, clearInput) { body: JSON.stringify({ recipientId: recipient.id, content: content, + replyToId: replyTo.value?.id, }), }) } @@ -240,6 +264,7 @@ async function sendMessage(content, clearInput) { }, }) clearInput() + replyTo.value = null setTimeout(() => { scrollToBottom() }, 100) @@ -524,4 +549,39 @@ onUnmounted(() => { margin-left: 10px; margin-right: 10px; } + +.reply-preview { + padding: 5px 10px; + border-left: 2px solid var(--primary-color); + margin-bottom: 5px; + font-size: 13px; +} + +.reply-author { + font-weight: bold; + margin-bottom: 2px; +} + +.reply-btn { + cursor: pointer; + padding: 4px; + opacity: 0.6; +} + +.reply-btn:hover { + opacity: 1; +} + +.active-reply { + background-color: var(--bg-color-soft); + padding: 5px 10px; + border-left: 3px solid var(--primary-color); + margin-bottom: 5px; + font-size: 13px; +} + +.close-reply { + margin-left: 8px; + cursor: pointer; +}