feat: topic 内容与回复渲染

pull/56/head
moonrailgun 2 years ago
parent 5db553aa7c
commit 2db0b07f29

@ -207,6 +207,7 @@ export { NAME_REGEXP, SYSTEM_USERID } from './utils/consts';
export {
shouldShowMessageTime,
getMessageTimeDiff,
showMessageTime,
formatShortTime,
formatFullTime,
datetimeToNow,

@ -40,6 +40,20 @@ export function getMessageTimeDiff(input: Date): string {
}
}
/**
*
*
*/
export function showMessageTime(input: Date): string {
const date = dayjs(input);
if (isToday(date)) {
return formatShortTime(date);
} else {
return formatFullTime(date);
}
}
/**
*
*

@ -0,0 +1,23 @@
import React from 'react';
import { Avatar } from 'tailchat-design';
import { useCachedUserInfo } from 'tailchat-shared';
/**
*
*/
export const UserAvatar: React.FC<{
userId: string;
className?: string;
}> = React.memo((props) => {
const { userId, className } = props;
const cachedUserInfo = useCachedUserInfo(userId);
return (
<Avatar
className={className}
src={cachedUserInfo.avatar}
name={cachedUserInfo.nickname}
/>
);
});
UserAvatar.displayName = 'UserAvatar';

@ -10,6 +10,7 @@ export const UserProfileContainer: React.FC<
PropsWithChildren<{ userInfo: UserBaseInfo }>
> = React.memo((props) => {
const { userInfo } = props;
const { value: bannerColor } = useAsync(async () => {
if (!userInfo.avatar) {
return getTextColorHex(userInfo.nickname);
@ -18,6 +19,7 @@ export const UserProfileContainer: React.FC<
const rgba = await fetchImagePrimaryColor(userInfo.avatar);
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
}, [userInfo.avatar]);
return (
<div className="relative bg-inherit">
<div

@ -43,11 +43,13 @@ export {
useAsyncRequest,
uploadFile,
showToasts,
showSuccessToasts,
showErrorToasts,
fetchAvailableServices,
isValidStr,
useGroupPanelInfo,
sendMessage,
showMessageTime,
} from 'tailchat-shared';
export { useLocation, useNavigate } from 'react-router';

@ -49,5 +49,6 @@ export { GroupPanelSelector } from '@/components/GroupPanelSelector';
export { Emoji } from '@/components/Emoji';
export { PortalAdd, PortalRemove } from '@/components/Portal';
export { ErrorBoundary } from '@/components/ErrorBoundary';
export { UserAvatar } from '@/components/UserAvatar';
export { UserName } from '@/components/UserName';
export { Markdown } from '@/components/Markdown';

@ -122,9 +122,15 @@ declare module '@capital/common' {
export const useAsyncFn: any;
export const useAsyncRefresh: any;
export const useAsyncRefresh: <T extends (...args: any[]) => Promise<any>>(
fn: T,
deps?: React.DependencyList
) => [{ loading: boolean; value?: any; error?: Error }, T];
export const useAsyncRequest: any;
export const useAsyncRequest: <T extends (...args: any[]) => Promise<any>>(
fn: T,
deps?: React.DependencyList
) => [{ loading: boolean; value?: any }, T];
export const uploadFile: any;
@ -133,6 +139,8 @@ declare module '@capital/common' {
type?: 'info' | 'success' | 'error' | 'warning'
) => void;
export const showSuccessToasts: any;
export const showErrorToasts: (error: any) => void;
export const fetchAvailableServices: any;
@ -143,6 +151,8 @@ declare module '@capital/common' {
export const sendMessage: any;
export const showMessageTime: any;
export const useLocation: any;
export const useNavigate: any;
@ -381,6 +391,8 @@ declare module '@capital/component' {
export const ErrorBoundary: any;
export const UserAvatar: any;
export const UserName: React.FC<{
userId: string;
className?: string;

@ -3,7 +3,7 @@ const { getModelForClass, prop, TimeStamps, modelOptions } = db;
import type { Types } from 'mongoose';
import { nanoid } from 'nanoid';
class GroupTopicComment {
class GroupTopicComment extends TimeStamps {
@prop({
default: () => nanoid(8),
})
@ -50,7 +50,7 @@ export class GroupTopic extends TimeStamps implements db.Base {
type: () => GroupTopicComment,
default: [],
})
comment: GroupTopicComment[];
comments: GroupTopicComment[];
}
export type GroupTopicDocument = db.DocumentType<GroupTopic>;

@ -38,6 +38,15 @@ class GroupTopicService extends TcService {
content: 'string',
},
});
this.registerAction('createComment', this.createComment, {
params: {
groupId: 'string',
panelId: 'string',
topicId: 'string',
content: 'string',
replyCommentId: { type: 'string', optional: true },
},
});
}
/**
@ -120,6 +129,60 @@ class GroupTopicService extends TcService {
return true;
}
/**
*
*/
async createComment(
ctx: TcContext<{
groupId: string;
panelId: string;
topicId: string;
content: string;
replyCommentId?: string;
}>
) {
const { groupId, panelId, topicId, content, replyCommentId } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
// 鉴权
const group = await call(ctx).getGroupInfo(groupId);
const isMember = group.members.some((member) => member.userId === userId);
if (!isMember) {
throw new Error(t('不是该群组成员'));
}
const targetPanel = group.panels.find((p) => p.id === panelId);
if (!targetPanel) {
throw new Error(t('面板不存在'));
}
const topic = await this.adapter.model.findOneAndUpdate(
{
_id: topicId,
groupId,
panelId,
},
{
$push: {
comments: {
content,
author: userId,
replyCommentId,
},
},
},
{ new: true }
);
const json = await this.transformDocuments(ctx, {}, topic);
this.roomcastNotify(ctx, groupId, 'createComment', json);
return true;
}
}
export default GroupTopicService;

@ -1,6 +1,16 @@
import React from 'react';
import { Avatar, IconBtn } from '@capital/component';
import React, { useReducer, useState } from 'react';
import {
getMessageRender,
showMessageTime,
showSuccessToasts,
useAsyncRequest,
} from '@capital/common';
import { IconBtn, Input, UserName, UserAvatar } from '@capital/component';
import styled from 'styled-components';
import type { GroupTopic } from '../types';
import { Translate } from '../translate';
import { request } from '../request';
import { TopicComments } from './TopicComments';
const Root = styled.div`
background-color: rgba(0, 0, 0, 0.25);
@ -36,41 +46,77 @@ const Root = styled.div`
margin-top: 6px;
margin-bottom: 6px;
}
.reply {
padding: 10px;
margin-bottom: 6px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
}
}
}
`;
export const TopicCard: React.FC = React.memo(() => {
const ReplyBox = styled.div`
background-color: rgba(0, 0, 0, 0.25);
padding: 10px;
margin-top: 10px;
`;
export const TopicCard: React.FC<{
topic: GroupTopic;
}> = React.memo((props) => {
const topic: Partial<GroupTopic> = props.topic ?? {};
const [showReply, toggleShowReply] = useReducer((state) => !state, false);
const [comment, setComment] = useState('');
const [{ loading }, handleComment] = useAsyncRequest(async () => {
await request.post('createComment', {
groupId: topic.groupId,
panelId: topic.panelId,
topicId: topic._id,
content: comment,
});
setComment('');
showSuccessToasts();
}, [topic.groupId, topic.panelId, topic._id, comment]);
return (
<Root>
<div className="left">
<Avatar name="any" />
<UserAvatar userId={topic.author} />
</div>
<div className="right">
<div className="header">
<div className="name"></div>
<div className="date">12:00</div>
<div className="name">
<UserName userId={topic.author} />
</div>
<div className="date">{showMessageTime(topic.createdAt)}</div>
</div>
<div className="body">
<div className="content">
</div>
<div className="content">{getMessageRender(topic.content)}</div>
<div className="reply"></div>
{Array.isArray(topic.comments) && topic.comments.length > 0 && (
<TopicComments comments={topic.comments} />
)}
</div>
<div className="footer">
<IconBtn title="回复" icon="mdi:message-reply-text-outline" />
<IconBtn
title={Translate.reply}
icon="mdi:message-reply-text-outline"
onClick={toggleShowReply}
/>
</div>
{showReply && (
<ReplyBox>
<Input
autoFocus
placeholder={Translate.replyThisTopic}
disabled={loading}
value={comment}
onChange={(e) => setComment(e.target.value)}
onPressEnter={handleComment}
/>
</ReplyBox>
)}
</div>
</Root>
);

@ -0,0 +1,30 @@
import { UserName } from '@capital/component';
import React from 'react';
import styled from 'styled-components';
import type { GroupTopicComment } from '../types';
const Root = styled.div`
padding: 10px;
margin-bottom: 6px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
> div {
display: flex;
}
`;
export const TopicComments: React.FC<{
comments: GroupTopicComment[];
}> = React.memo((props) => {
return (
<Root>
{props.comments.map((comment) => (
<div key={comment.id}>
<UserName userId={comment.author} />: <div>{comment.content}</div>
</div>
))}
</Root>
);
});
TopicComments.displayName = 'TopicComments';

@ -78,7 +78,7 @@ const GroupTopicPanelRender: React.FC = React.memo(() => {
return (
<Root>
{Array.isArray(list) && list.length > 0 ? (
list.map((_, i) => <TopicCard key={i} />)
list.map((item, i) => <TopicCard key={i} topic={item} />)
) : (
<Empty description={Translate.noTopic}>
<Button type="primary" onClick={handleCreateTopic}>

@ -4,4 +4,9 @@ export const Translate = {
topicpanel: localTrans({ 'zh-CN': '话题面板', 'en-US': 'Topic Panel' }),
noTopic: localTrans({ 'zh-CN': '暂无话题', 'en-US': 'No Topic' }),
createBtn: localTrans({ 'zh-CN': '创建话题', 'en-US': 'Create Topic' }),
reply: localTrans({ 'zh-CN': '回复', 'en-US': 'Reply' }),
replyThisTopic: localTrans({
'zh-CN': '回复该话题',
'en-US': 'Reply this topic',
}),
};

@ -0,0 +1,16 @@
export interface GroupTopicComment {
author: string;
content: string;
id: string;
}
export interface GroupTopic {
_id: string;
author: string;
comments: GroupTopicComment[];
content: string;
createdAt: string;
groupId: string;
panelId: string;
updatedAt: string;
}
Loading…
Cancel
Save