mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-28 13:37:36 +08:00
feat: implement post subscription notifications
This commit is contained in:
@@ -95,6 +95,28 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
||||||
|
<div class="notif-content-container">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
订阅了你的文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ sanitizeDescription(item.post.title) }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
||||||
|
<div class="notif-content-container">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
取消订阅了你的文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ sanitizeDescription(item.post.title) }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="notif-content-container">
|
<div class="notif-content-container">
|
||||||
{{ formatType(item.type) }}
|
{{ formatType(item.type) }}
|
||||||
@@ -146,7 +168,9 @@ export default {
|
|||||||
USER_ACTIVITY: 'fas fa-user',
|
USER_ACTIVITY: 'fas fa-user',
|
||||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||||
USER_FOLLOWED: 'fas fa-user-plus',
|
USER_FOLLOWED: 'fas fa-user-plus',
|
||||||
USER_UNFOLLOWED: 'fas fa-user-minus'
|
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||||
|
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||||
|
POST_UNSUBSCRIBED: 'fas fa-bookmark'
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactionEmojiMap = {
|
const reactionEmojiMap = {
|
||||||
@@ -223,6 +247,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -249,6 +284,10 @@ export default {
|
|||||||
return '关注的帖子有新评论'
|
return '关注的帖子有新评论'
|
||||||
case 'FOLLOWED_POST':
|
case 'FOLLOWED_POST':
|
||||||
return '关注的用户发布了新文章'
|
return '关注的用户发布了新文章'
|
||||||
|
case 'POST_SUBSCRIBED':
|
||||||
|
return '有人订阅了你的文章'
|
||||||
|
case 'POST_UNSUBSCRIBED':
|
||||||
|
return '有人取消订阅你的文章'
|
||||||
case 'USER_FOLLOWED':
|
case 'USER_FOLLOWED':
|
||||||
return '有人关注了你'
|
return '有人关注了你'
|
||||||
case 'USER_UNFOLLOWED':
|
case 'USER_UNFOLLOWED':
|
||||||
|
|||||||
@@ -19,11 +19,19 @@
|
|||||||
<div class="article-block-button">
|
<div class="article-block-button">
|
||||||
BLOCK
|
BLOCK
|
||||||
</div>
|
</div>
|
||||||
<div class="article-subscribe-button">
|
<div
|
||||||
|
v-if="loggedIn && !isAuthor && !subscribed"
|
||||||
|
class="article-subscribe-button"
|
||||||
|
@click="subscribePost"
|
||||||
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
订阅文章
|
订阅文章
|
||||||
</div>
|
</div>
|
||||||
<div class="article-unsubscribe-button">
|
<div
|
||||||
|
v-if="loggedIn && !isAuthor && subscribed"
|
||||||
|
class="article-unsubscribe-button"
|
||||||
|
@click="unsubscribePost"
|
||||||
|
>
|
||||||
<i class="fas fa-user-minus"></i>
|
<i class="fas fa-user-minus"></i>
|
||||||
取消订阅
|
取消订阅
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +114,7 @@ import ReactionsGroup from '../components/ReactionsGroup.vue'
|
|||||||
import DropdownMenu from '../components/DropdownMenu.vue'
|
import DropdownMenu from '../components/DropdownMenu.vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '../main'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken, authState } from '../utils/auth'
|
||||||
import TimeManager from '../utils/time'
|
import TimeManager from '../utils/time'
|
||||||
import { hatch } from 'ldrs'
|
import { hatch } from 'ldrs'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -133,6 +141,9 @@ export default {
|
|||||||
const postItems = ref([])
|
const postItems = ref([])
|
||||||
const mainContainer = ref(null)
|
const mainContainer = ref(null)
|
||||||
const currentIndex = ref(1)
|
const currentIndex = ref(1)
|
||||||
|
const subscribed = ref(false)
|
||||||
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const isAuthor = computed(() => authState.username === author.value.username)
|
||||||
const reviewMenuItems = [
|
const reviewMenuItems = [
|
||||||
{ text: '通过审核', onClick: () => {} },
|
{ text: '通过审核', onClick: () => {} },
|
||||||
{ text: '驳回', color: 'red', onClick: () => {} }
|
{ text: '驳回', color: 'red', onClick: () => {} }
|
||||||
@@ -211,6 +222,7 @@ export default {
|
|||||||
tags.value = data.tags || []
|
tags.value = data.tags || []
|
||||||
postReactions.value = data.reactions || []
|
postReactions.value = data.reactions || []
|
||||||
comments.value = (data.comments || []).map(mapComment)
|
comments.value = (data.comments || []).map(mapComment)
|
||||||
|
subscribed.value = !!data.subscribed
|
||||||
postTime.value = TimeManager.format(data.createdAt)
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
gatherPostItems()
|
gatherPostItems()
|
||||||
@@ -296,6 +308,42 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscribePost = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = true
|
||||||
|
toast.success('已订阅')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribePost = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = false
|
||||||
|
toast.success('已取消订阅')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const jumpToHashComment = async () => {
|
const jumpToHashComment = async () => {
|
||||||
const hash = location.hash
|
const hash = location.hash
|
||||||
if (hash.startsWith('#comment-')) {
|
if (hash.startsWith('#comment-')) {
|
||||||
@@ -344,10 +392,15 @@ export default {
|
|||||||
onSliderInput,
|
onSliderInput,
|
||||||
onScroll: updateCurrentIndex,
|
onScroll: updateCurrentIndex,
|
||||||
copyPostLink,
|
copyPostLink,
|
||||||
|
subscribePost,
|
||||||
|
unsubscribePost,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
isWaitingFetchingPost,
|
isWaitingFetchingPost,
|
||||||
isWaitingPostingComment,
|
isWaitingPostingComment,
|
||||||
gotoProfile
|
gotoProfile,
|
||||||
|
subscribed,
|
||||||
|
loggedIn,
|
||||||
|
isAuthor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.openisle.service.PostService;
|
|||||||
import com.openisle.service.ReactionService;
|
import com.openisle.service.ReactionService;
|
||||||
import com.openisle.service.CaptchaService;
|
import com.openisle.service.CaptchaService;
|
||||||
import com.openisle.service.DraftService;
|
import com.openisle.service.DraftService;
|
||||||
|
import com.openisle.service.SubscriptionService;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -26,6 +27,7 @@ public class PostController {
|
|||||||
private final PostService postService;
|
private final PostService postService;
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final ReactionService reactionService;
|
private final ReactionService reactionService;
|
||||||
|
private final SubscriptionService subscriptionService;
|
||||||
private final CaptchaService captchaService;
|
private final CaptchaService captchaService;
|
||||||
private final DraftService draftService;
|
private final DraftService draftService;
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ public class PostController {
|
|||||||
public ResponseEntity<PostDto> getPost(@PathVariable Long id, Authentication auth) {
|
public ResponseEntity<PostDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||||
String viewer = auth != null ? auth.getName() : null;
|
String viewer = auth != null ? auth.getName() : null;
|
||||||
Post post = postService.viewPost(id, viewer);
|
Post post = postService.viewPost(id, viewer);
|
||||||
return ResponseEntity.ok(toDto(post));
|
return ResponseEntity.ok(toDto(post, viewer));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -134,6 +136,16 @@ public class PostController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PostDto toDto(Post post, String viewer) {
|
||||||
|
PostDto dto = toDto(post);
|
||||||
|
if (viewer != null) {
|
||||||
|
dto.setSubscribed(subscriptionService.isPostSubscribed(viewer, post.getId()));
|
||||||
|
} else {
|
||||||
|
dto.setSubscribed(false);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
private CommentDto toCommentDtoWithReplies(Comment comment) {
|
private CommentDto toCommentDtoWithReplies(Comment comment) {
|
||||||
CommentDto dto = toCommentDto(comment);
|
CommentDto dto = toCommentDto(comment);
|
||||||
List<CommentDto> replies = commentService.getReplies(comment.getId()).stream()
|
List<CommentDto> replies = commentService.getReplies(comment.getId()).stream()
|
||||||
@@ -223,6 +235,7 @@ public class PostController {
|
|||||||
private List<CommentDto> comments;
|
private List<CommentDto> comments;
|
||||||
private List<ReactionDto> reactions;
|
private List<ReactionDto> reactions;
|
||||||
private java.util.List<AuthorDto> participants;
|
private java.util.List<AuthorDto> participants;
|
||||||
|
private boolean subscribed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public enum NotificationType {
|
|||||||
POST_REVIEWED,
|
POST_REVIEWED,
|
||||||
/** A subscribed post received a new comment */
|
/** A subscribed post received a new comment */
|
||||||
POST_UPDATED,
|
POST_UPDATED,
|
||||||
|
/** Someone subscribed to your post */
|
||||||
|
POST_SUBSCRIBED,
|
||||||
|
/** Someone unsubscribed from your post */
|
||||||
|
POST_UNSUBSCRIBED,
|
||||||
/** Someone you follow published a new post */
|
/** Someone you follow published a new post */
|
||||||
FOLLOWED_POST,
|
FOLLOWED_POST,
|
||||||
/** Someone started following you */
|
/** Someone started following you */
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ public class SubscriptionService {
|
|||||||
PostSubscription ps = new PostSubscription();
|
PostSubscription ps = new PostSubscription();
|
||||||
ps.setUser(user);
|
ps.setUser(user);
|
||||||
ps.setPost(post);
|
ps.setPost(post);
|
||||||
|
if (!user.getId().equals(post.getAuthor().getId())) {
|
||||||
|
notificationService.createNotification(post.getAuthor(),
|
||||||
|
NotificationType.POST_SUBSCRIBED, post, null, null, user, null);
|
||||||
|
}
|
||||||
return postSubRepo.save(ps);
|
return postSubRepo.save(ps);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,7 +37,13 @@ public class SubscriptionService {
|
|||||||
public void unsubscribePost(String username, Long postId) {
|
public void unsubscribePost(String username, Long postId) {
|
||||||
User user = userRepo.findByUsername(username).orElseThrow();
|
User user = userRepo.findByUsername(username).orElseThrow();
|
||||||
Post post = postRepo.findById(postId).orElseThrow();
|
Post post = postRepo.findById(postId).orElseThrow();
|
||||||
postSubRepo.findByUserAndPost(user, post).ifPresent(postSubRepo::delete);
|
postSubRepo.findByUserAndPost(user, post).ifPresent(ps -> {
|
||||||
|
postSubRepo.delete(ps);
|
||||||
|
if (!user.getId().equals(post.getAuthor().getId())) {
|
||||||
|
notificationService.createNotification(post.getAuthor(),
|
||||||
|
NotificationType.POST_UNSUBSCRIBED, post, null, null, user, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void subscribeComment(String username, Long commentId) {
|
public void subscribeComment(String username, Long commentId) {
|
||||||
@@ -117,6 +127,15 @@ public class SubscriptionService {
|
|||||||
return userSubRepo.findBySubscriberAndTarget(subscriber, target).isPresent();
|
return userSubRepo.findBySubscriberAndTarget(subscriber, target).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPostSubscribed(String username, Long postId) {
|
||||||
|
if (username == null || postId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
User user = userRepo.findByUsername(username).orElseThrow();
|
||||||
|
Post post = postRepo.findById(postId).orElseThrow();
|
||||||
|
return postSubRepo.findByUserAndPost(user, post).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
private Optional<User> findUser(String identifier) {
|
private Optional<User> findUser(String identifier) {
|
||||||
if (identifier.matches("\\d+")) {
|
if (identifier.matches("\\d+")) {
|
||||||
return userRepo.findById(Long.parseLong(identifier));
|
return userRepo.findById(Long.parseLong(identifier));
|
||||||
|
|||||||
Reference in New Issue
Block a user