Skip to content

Commit

Permalink
可以在留言@提及用戶
Browse files Browse the repository at this point in the history
  • Loading branch information
MROS committed Mar 19, 2023
1 parent 4f3f625 commit dc46a3e
Show file tree
Hide file tree
Showing 13 changed files with 211 additions and 63 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE notification_kind ADD VALUE 'mentioned_in_comment';
6 changes: 4 additions & 2 deletions api-service/src/api/api_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,10 @@ impl api_trait::ArticleQueryRouter for ArticleQueryRouter {
anonymous: bool,
) -> Fallible<i64> {
let author_id = context.get_id_strict().await?;
let comment_id = db::comment::create(author_id, article_id, content, anonymous).await?;
service::notification::handle_comment(author_id, article_id, anonymous).await?;
let (comment_id, mentioned_ids) =
db::comment::create(author_id, article_id, content, anonymous).await?;
service::notification::handle_comment(author_id, article_id, anonymous, mentioned_ids)
.await?;
Ok(comment_id)
}
async fn query_bonder(
Expand Down
2 changes: 2 additions & 0 deletions api-service/src/api/model/forum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@ pub mod forum_model_root {
CommentReplied, // 發表的文章被回覆
#[strum(serialize = "other_comment_replied")]
OtherCommentReplied, // 你在 A 文章留言,其他人也在 A 文章留言時,會收到該通知
#[strum(serialize = "mentioned_in_comment")]
MentionedInComment, // 在留言中被提及
}
#[derive(Serialize, Deserialize, TypeScriptify, Clone, Debug)]
pub struct Notification {
Expand Down
65 changes: 61 additions & 4 deletions api-service/src/db/comment.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use super::get_pool;
use crate::api::model::forum::{Author, Comment};
use crate::custom_error::Fallible;
use crate::custom_error::{DataType, ErrorCode, Fallible};

pub async fn get_by_article_id(article_id: i64, viewer_id: Option<i64>) -> Fallible<Vec<Comment>> {
let pool = get_pool();
Expand Down Expand Up @@ -51,7 +53,7 @@ enum Node {
},
Mention {
kind: String,
account: String,
username: String,
children: Vec<Node>, // children 無用途,僅爲了滿足 slate 的型別
},
Paragraph {
Expand All @@ -60,15 +62,70 @@ enum Node {
},
}

fn get_mentioned_users(nodes: Vec<Node>) -> Vec<String> {
let mut usernames: Vec<String> = Vec::new();
for node in nodes {
match node {
Node::Mention {
kind: _,
username,
children: _,
} => {
usernames.push(username);
}
Node::Paragraph { kind: _, children } => {
let mut accounts_in_children = get_mentioned_users(children);
usernames.append(&mut accounts_in_children);
}
_ => {}
}
}
return usernames;
}

struct User {
id: i64,
user_name: String,
}

// 回傳 (留言ID, 提及的帳號)
pub async fn create(
author_id: i64,
article_id: i64,
content: String,
anonymous: bool,
) -> Fallible<i64> {
) -> Fallible<(i64, Vec<i64>)> {
let pool = get_pool();

let rich_text_comment: Vec<Node> = serde_json::from_str(&content)?;
let mentioned_users = get_mentioned_users(rich_text_comment);

let users: HashMap<String, i64> = sqlx::query_as!(
User,
"
SELECT id, user_name from users
WHERE user_name = ANY($1)
",
&mentioned_users
)
.fetch_all(pool)
.await?
.into_iter()
.map(|r| (r.user_name, r.id))
.collect();

let mut mentioned_ids: Vec<i64> = Vec::new();

for name in &mentioned_users {
match users.get(name) {
Some(id) => {
mentioned_ids.push(*id);
}
None => {
return Err(ErrorCode::NotFound(DataType::User, name.to_owned()).into());
}
}
}

let comment_id = sqlx::query!(
"
Expand All @@ -84,5 +141,5 @@ pub async fn create(
.fetch_one(pool)
.await?
.id;
Ok(comment_id)
Ok((comment_id, mentioned_ids))
}
23 changes: 23 additions & 0 deletions api-service/src/db/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,29 @@ pub async fn get_by_user(user_id: i64, all: bool) -> Fallible<Vec<Notification>>
.collect()
}

pub async fn notify_mentioned_id(
author_id: i64,
article_id: i64,
board_id: i64,
anonymous: bool,
mentioned_ids: Vec<i64>,
) -> Fallible {
// TODO: 用類似 Promise.all 的方式平行化
for mentioned_id in mentioned_ids {
create(
mentioned_id,
NotificationKind::MentionedInComment,
None,
if anonymous { None } else { Some(author_id) },
Some(board_id),
Some(article_id),
None,
)
.await?;
}
Ok(())
}

pub async fn read(ids: &[i64], user_id: i64) -> Fallible {
let pool = get_pool();
sqlx::query!(
Expand Down
16 changes: 15 additions & 1 deletion api-service/src/service/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ fn quality(kind: NotificationKind) -> Option<bool> {
NotificationKind::ArticleBadReplied => Some(false),
NotificationKind::CommentReplied => None,
NotificationKind::OtherCommentReplied => None,
NotificationKind::MentionedInComment => None,
}
}

Expand Down Expand Up @@ -89,7 +90,12 @@ pub async fn handle_article(
Ok(())
}

pub async fn handle_comment(author_id: i64, article_id: i64, anonymous: bool) -> Fallible {
pub async fn handle_comment(
author_id: i64,
article_id: i64,
anonymous: bool,
mentioned_ids: Vec<i64>,
) -> Fallible {
let board_id = get_meta_by_id(article_id, None).await?.board_id;
handle_reply(
author_id,
Expand All @@ -100,5 +106,13 @@ pub async fn handle_comment(author_id: i64, article_id: i64, anonymous: bool) ->
NotificationKind::CommentReplied,
)
.await?;
db::notification::notify_mentioned_id(
author_id,
article_id,
board_id,
anonymous,
mentioned_ids,
)
.await?;
db::notification::notify_all_commenter(author_id, article_id, board_id, anonymous).await
}
18 changes: 12 additions & 6 deletions frontend/app/web/src/tsx/article_card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function CommentCard(props: {comment: Comment}): JSX.Element {
<span>{relativeDate(new Date(props.comment.create_time))}</span>
</div>
<div className={style.commentContent}>
<ShowComment text={JSON.parse(props.comment.content)} />
<ShowComment descendents={JSON.parse(props.comment.content)} />
</div>
</div>;
}
Expand All @@ -148,13 +148,18 @@ export function CommentCards(props: { article_id: number }): JSX.Element {
toastErr(err);
});
}, [props.article_id]);
const RenderedComments = React.useMemo(() => {
return <>
{
comments.map((comment) => {
return <CommentCard comment={comment} key={comment.id} />;
})
}
</>;
}, [comments]);

return <div className={style.commentCards}>
{
comments.map((comment) => {
return <CommentCard comment={comment} key={comment.id} />;
})
}
{RenderedComments}
<label>
<input type="checkbox"
checked={anonymous}
Expand All @@ -163,6 +168,7 @@ export function CommentCards(props: { article_id: number }): JSX.Element {
</label>
<CommentEditor setValue={setNewComment} setEditor={setEditor} />
<button onClick={() => {
// console.log(JSON.stringify(newComment, null, 2));
API_FETCHER.articleQuery.createComment(
props.article_id,
JSON.stringify(newComment),
Expand Down
67 changes: 45 additions & 22 deletions frontend/app/web/src/tsx/components/comment_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { API_FETCHER, unwrap_or } from 'carbonbond-api/api_utils';
import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useDebounce } from 'react-use';
import { Editor, Transforms, Range, createEditor, Descendant, BaseEditor } from 'slate';
import { Editor, Transforms, Range, createEditor, Descendant, BaseEditor, Node } from 'slate';
import { HistoryEditor, withHistory } from 'slate-history';
import {
Slate,
Expand All @@ -28,7 +28,7 @@ type CustomText = {

type MentionElement = {
kind: 'Mention'
account: string
username: string
children: CustomText[]
};

Expand Down Expand Up @@ -111,6 +111,7 @@ export const CommentEditor = (props: EditorProps): JSX.Element => {
[candidates, editor, index, target]
);


useEffect(() => {
props.setEditor(editor);
if (target && candidates.length > 0) {
Expand Down Expand Up @@ -159,7 +160,6 @@ export const CommentEditor = (props: EditorProps): JSX.Element => {
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={onKeyDown}
autoFocus={true}
placeholder="我來留言"
/>
{target && candidates.length > 0 && (
Expand Down Expand Up @@ -215,11 +215,11 @@ const withMentions = (editor: Editor): Editor => {
return editor;
};

const insertMention = (editor: Editor, account: string): void => {
const insertMention = (editor: Editor, username: string): void => {
const mention: MentionElement = {
kind: 'Mention',
account: account,
children: [{ text: '' }], // TODO: children 爲空的話 slate 會報錯
username: username,
children: [{ text: `@${username}` }], // TODO: children 爲空的話 slate 會報錯
};
Transforms.insertNodes(editor, mention);
Transforms.move(editor);
Expand Down Expand Up @@ -248,10 +248,10 @@ const Mention = ({ attributes, children, element }: RenderElementProps): JSX.Ele
<span
{...attributes}
contentEditable={false}
data-cy={`mention-${element.account.replace(' ', '-')}`}
data-cy={`mention-${element.username.replace(' ', '-')}`}
className={style.mention}
>
{children}@{element.account}
{children}@{element.username}
</span>
);
};
Expand All @@ -263,25 +263,48 @@ const initialValue: Descendant[] = [
{
text: '',
},
{
text: '', // XXX: 如果只有空字串,會導致編輯器要多次點擊才能聚焦
},
],
},
];

export function ShowComment(props: { text: Descendant[]; }): JSX.Element {
return <div>
function ShowDescendents(props: { descendents: Descendant[]; }): JSX.Element {
if (props.descendents.map(Node.string).join('').length == 0) {
return <br />;
}
return <>
{
props.text.map((descendant, index)=> {
if ('kind' in descendant) {
switch (descendant.kind) {
case 'Paragraph':
return <div key={index}><ShowComment text={descendant.children} /></div>;
case 'Mention':
return <span key={index} className={style.mention}>@{descendant.account}</span>;
}
} else {
return <span key={index}>{descendant.text}</span>;
}
props.descendents.map((descendant, index)=> {
return <ShowDescendent key={index} descendent={descendant} />;
})
}
</div>;
</>;
}

function ShowDescendent(props: { descendent: Descendant; }): JSX.Element {
const descendant = props.descendent;
if ('kind' in descendant) {
switch (descendant.kind) {
case 'Paragraph':
return <p>
<ShowDescendents descendents={descendant.children} />
</p>;
case 'Mention':
return <span className={style.mention}>
@{descendant.username}
</span>;
default:
throw new Error('未知的 slate 元素');
}
} else {
return <span>{descendant.text}</span>;
}
}

export function ShowComment(props: { descendents: Descendant[]; }): JSX.Element {
return <div>
<ShowDescendents {...props} />
</div>;
}
4 changes: 2 additions & 2 deletions frontend/app/web/src/tsx/components/tab_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function TabPanel(props: {children: React.ReactElement<TabPanelItemProps>
return <div className={style.works}>
<div className={style.navigateBar}>
{props.children.map((tab_item, index) => (
<div className={style.navigateTabWrapper}>
<div key={index} className={(tab_item.props.is_disable ? style.navigateTabDisable : style.navigateTab) +
<div key={index} className={style.navigateTabWrapper}>
<div className={(tab_item.props.is_disable ? style.navigateTabDisable : style.navigateTab) +
((!tab_item.props.is_disable && selectTab == index) ? ` ${style.navigateTabActive}` : '')
}
onClick={() => { if (!tab_item.props.is_disable) {handleSelectTab(index);} }}>
Expand Down
1 change: 0 additions & 1 deletion frontend/app/web/src/tsx/display/show_markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ const renderer = {
marked.use({
gfm: true,
breaks: true,
sanitize: true,
renderer,
highlight: function(code: string, lang: string) {
if (lang && Prism.languages[lang]) {
Expand Down
Loading

0 comments on commit dc46a3e

Please sign in to comment.