mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-19 10:27:26 +08:00
Merge pull request #292 from nagisa77/n67uug-codex/add-user-mention-feature
Add mention notification feature
This commit is contained in:
8
open-isle-cli/package-lock.json
generated
8
open-isle-cli/package-lock.json
generated
@@ -12,7 +12,7 @@
|
|||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"ldrs": "^1.1.7",
|
"ldrs": "^1.1.7",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"vditor": "^3.8.7",
|
"vditor": "^3.11.1",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
@@ -11330,9 +11330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vditor": {
|
"node_modules/vditor": {
|
||||||
"version": "3.8.7",
|
"version": "3.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/vditor/-/vditor-3.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/vditor/-/vditor-3.11.1.tgz",
|
||||||
"integrity": "sha512-7loYmcj1TCkJV86qwstxzAy33GweFKdJnpiSzGGPqUzDaOcUAiaiFAjIQjxwJ3/sx7pNZf1NWLhXKabXEN7xzQ==",
|
"integrity": "sha512-7rjNSXYVyZG0mVZpUG2tfxwnoNtkcRCnwdSju+Zvpjf/r72iQa6kLpeThFMIKPuQ5CRnQQv6gnR3eNU6UGbC2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff-match-patch": "^1.0.5"
|
"diff-match-patch": "^1.0.5"
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
"ldrs": "^1.1.7",
|
"ldrs": "^1.1.7",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"vditor": "^3.8.7",
|
"vditor": "^3.11.1",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-router": "^4.5.1",
|
|
||||||
"vue-toastification": "^2.0.0-rc.5",
|
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
"echarts": "^5.6.0",
|
"vue-echarts": "^7.0.3",
|
||||||
"vue-echarts": "^7.0.3"
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.16",
|
"@babel/core": "^7.12.16",
|
||||||
|
|||||||
@@ -3,6 +3,30 @@ import hljs from 'highlight.js'
|
|||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
|
|
||||||
|
function mentionPlugin(md) {
|
||||||
|
const mentionReg = /^@\[([^\]]+)\]/
|
||||||
|
function mention(state, silent) {
|
||||||
|
const pos = state.pos
|
||||||
|
if (state.src.charCodeAt(pos) !== 0x40) return false
|
||||||
|
const match = mentionReg.exec(state.src.slice(pos))
|
||||||
|
if (!match) return false
|
||||||
|
if (!silent) {
|
||||||
|
const tokenOpen = state.push('link_open', 'a', 1)
|
||||||
|
tokenOpen.attrs = [
|
||||||
|
['href', `/users/${match[1]}`],
|
||||||
|
['target', '_blank'],
|
||||||
|
['class', 'mention-link']
|
||||||
|
]
|
||||||
|
const text = state.push('text', '', 0)
|
||||||
|
text.content = `@${match[1]}`
|
||||||
|
state.push('link_close', 'a', -1)
|
||||||
|
}
|
||||||
|
state.pos += match[0].length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
md.inline.ruler.before('emphasis', 'mention', mention)
|
||||||
|
}
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
@@ -18,6 +42,8 @@ const md = new MarkdownIt({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
md.use(mentionPlugin)
|
||||||
|
|
||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
return md.render(text || '')
|
return md.render(text || '')
|
||||||
}
|
}
|
||||||
|
|||||||
30
open-isle-cli/src/utils/user.js
Normal file
30
open-isle-cli/src/utils/user.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
|
||||||
|
export async function fetchFollowings(username) {
|
||||||
|
if (!username) return []
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdmins() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(keyword) {
|
||||||
|
if (!keyword) return []
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import Vditor from 'vditor'
|
import Vditor from 'vditor'
|
||||||
import 'vditor/dist/index.css'
|
import 'vditor/dist/index.css'
|
||||||
import { API_BASE_URL } from '../main'
|
import { API_BASE_URL } from '../main'
|
||||||
import { getToken } from './auth'
|
import { getToken, authState } from './auth'
|
||||||
|
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||||
|
|
||||||
export function getEditorTheme() {
|
export function getEditorTheme() {
|
||||||
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
|
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
|
||||||
@@ -19,11 +20,42 @@ export function createVditor(editorId, options = {}) {
|
|||||||
after
|
after
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
const fetchMentions = async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
const [followings, admins] = await Promise.all([
|
||||||
|
fetchFollowings(authState.username),
|
||||||
|
fetchAdmins()
|
||||||
|
])
|
||||||
|
const combined = [...followings, ...admins]
|
||||||
|
const seen = new Set()
|
||||||
|
return combined.filter(u => {
|
||||||
|
if (seen.has(u.id)) return false
|
||||||
|
seen.add(u.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return searchUsers(value)
|
||||||
|
}
|
||||||
|
|
||||||
return new Vditor(editorId, {
|
return new Vditor(editorId, {
|
||||||
placeholder,
|
placeholder,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
theme: getEditorTheme(),
|
theme: getEditorTheme(),
|
||||||
preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview),
|
preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview),
|
||||||
|
hint: {
|
||||||
|
extend: [
|
||||||
|
{
|
||||||
|
key: '@',
|
||||||
|
hint: async (key) => {
|
||||||
|
const list = await fetchMentions(key)
|
||||||
|
return list.map(u => ({
|
||||||
|
value: `@[${u.username}]`,
|
||||||
|
html: `<img src="${u.avatar}" /> @${u.username}`
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
||||||
toolbar: [
|
toolbar: [
|
||||||
'emoji',
|
'emoji',
|
||||||
|
|||||||
@@ -147,6 +147,29 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'MENTION' && item.comment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<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}#comment-${item.comment.id}`">
|
||||||
|
{{ sanitizeDescription(item.comment.content) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'MENTION'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<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>
|
||||||
|
中提到了你
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
@@ -327,7 +350,8 @@ export default {
|
|||||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||||
ACTIVITY_REDEEM: 'fas fa-coffee'
|
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||||
|
MENTION: 'fas fa-at'
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactionEmojiMap = {
|
const reactionEmojiMap = {
|
||||||
@@ -416,6 +440,17 @@ export default {
|
|||||||
router.push(`/users/${n.comment.author.id}`)
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (n.type === 'MENTION') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.fromUser) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -535,6 +570,8 @@ export default {
|
|||||||
return '有人取消关注你'
|
return '有人取消关注你'
|
||||||
case 'USER_ACTIVITY':
|
case 'USER_ACTIVITY':
|
||||||
return '关注的用户有新动态'
|
return '关注的用户有新动态'
|
||||||
|
case 'MENTION':
|
||||||
|
return '有人提到了你'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class SearchController {
|
|||||||
UserDto dto = new UserDto();
|
UserDto dto = new UserDto();
|
||||||
dto.setId(user.getId());
|
dto.setId(user.getId());
|
||||||
dto.setUsername(user.getUsername());
|
dto.setUsername(user.getUsername());
|
||||||
|
dto.setAvatar(user.getAvatar());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ public class SearchController {
|
|||||||
private static class UserDto {
|
private static class UserDto {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
|
private String avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -196,6 +196,16 @@ public class UserController {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all administrator users.
|
||||||
|
*/
|
||||||
|
@GetMapping("/admins")
|
||||||
|
public java.util.List<UserDto> admins() {
|
||||||
|
return userService.getAdmins().stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/all")
|
@GetMapping("/{identifier}/all")
|
||||||
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
|
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
|
||||||
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
||||||
|
|||||||
@@ -31,5 +31,7 @@ public enum NotificationType {
|
|||||||
/** A user requested registration approval */
|
/** A user requested registration approval */
|
||||||
REGISTER_REQUEST,
|
REGISTER_REQUEST,
|
||||||
/** A user redeemed an activity reward */
|
/** A user redeemed an activity reward */
|
||||||
ACTIVITY_REDEEM
|
ACTIVITY_REDEEM,
|
||||||
|
/** You were mentioned in a post or comment */
|
||||||
|
MENTION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class CommentService {
|
|||||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notificationService.notifyMentions(content, author, post, comment);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ public class CommentService {
|
|||||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.HashSet;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
@@ -28,6 +33,8 @@ public class NotificationService {
|
|||||||
@Value("${app.website-url}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
|
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||||
|
|
||||||
private String buildPayload(String body, String url) {
|
private String buildPayload(String body, String url) {
|
||||||
// try {
|
// try {
|
||||||
// return new ObjectMapper().writeValueAsString(Map.of(
|
// return new ObjectMapper().writeValueAsString(Map.of(
|
||||||
@@ -144,4 +151,22 @@ public class NotificationService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
return notificationRepository.countByUserAndRead(user, false);
|
return notificationRepository.countByUserAndRead(user, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {
|
||||||
|
if (content == null || fromUser == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Matcher matcher = MENTION_PATTERN.matcher(content);
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
while (matcher.find()) {
|
||||||
|
names.add(matcher.group(1));
|
||||||
|
}
|
||||||
|
for (String name : names) {
|
||||||
|
userRepository.findByUsername(name).ifPresent(target -> {
|
||||||
|
if (!target.getId().equals(fromUser.getId())) {
|
||||||
|
createNotification(target, NotificationType.MENTION, post, comment, null, fromUser, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ public class PostService {
|
|||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notificationService.notifyMentions(content, author, post, null);
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +408,7 @@ public class PostService {
|
|||||||
post.setTags(new java.util.HashSet<>(tags));
|
post.setTags(new java.util.HashSet<>(tags));
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
imageUploader.adjustReferences(oldContent, content);
|
imageUploader.adjustReferences(oldContent, content);
|
||||||
|
notificationService.notifyMentions(content, user, updated, null);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,4 +185,11 @@ public class UserService {
|
|||||||
user.setPassword(passwordEncoder.encode(newPassword));
|
user.setPassword(passwordEncoder.encode(newPassword));
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all administrator accounts.
|
||||||
|
*/
|
||||||
|
public java.util.List<User> getAdmins() {
|
||||||
|
return userRepository.findByRole(Role.ADMIN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user