diff --git a/client/shared/index.tsx b/client/shared/index.tsx index 192e05fe..7ceb6aa8 100644 --- a/client/shared/index.tsx +++ b/client/shared/index.tsx @@ -207,6 +207,7 @@ export { NAME_REGEXP, SYSTEM_USERID } from './utils/consts'; export { shouldShowMessageTime, getMessageTimeDiff, + showMessageTime, formatShortTime, formatFullTime, datetimeToNow, diff --git a/client/shared/utils/date-helper.ts b/client/shared/utils/date-helper.ts index 0bee8689..58a7c791 100644 --- a/client/shared/utils/date-helper.ts +++ b/client/shared/utils/date-helper.ts @@ -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); + } +} + /** * 是否应该显示消息时间 * 间隔时间大于十五分钟则显示 diff --git a/client/web/src/components/UserAvatar.tsx b/client/web/src/components/UserAvatar.tsx new file mode 100644 index 00000000..a3267747 --- /dev/null +++ b/client/web/src/components/UserAvatar.tsx @@ -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 ( + + ); +}); +UserAvatar.displayName = 'UserAvatar'; diff --git a/client/web/src/components/UserProfileContainer.tsx b/client/web/src/components/UserProfileContainer.tsx index f8d06e13..bf7c1709 100644 --- a/client/web/src/components/UserProfileContainer.tsx +++ b/client/web/src/components/UserProfileContainer.tsx @@ -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 (
Promise>( + fn: T, + deps?: React.DependencyList + ) => [{ loading: boolean; value?: any; error?: Error }, T]; - export const useAsyncRequest: any; + export const useAsyncRequest: Promise>( + 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; diff --git a/server/plugins/com.msgbyte.topic/models/topic.ts b/server/plugins/com.msgbyte.topic/models/topic.ts index 4e7f8172..3878ca04 100644 --- a/server/plugins/com.msgbyte.topic/models/topic.ts +++ b/server/plugins/com.msgbyte.topic/models/topic.ts @@ -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; diff --git a/server/plugins/com.msgbyte.topic/services/topic.service.ts b/server/plugins/com.msgbyte.topic/services/topic.service.ts index d1000953..e24b96f2 100644 --- a/server/plugins/com.msgbyte.topic/services/topic.service.ts +++ b/server/plugins/com.msgbyte.topic/services/topic.service.ts @@ -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; diff --git a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicCard.tsx b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicCard.tsx index 189e63da..8e114f61 100644 --- a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicCard.tsx +++ b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicCard.tsx @@ -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 = 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 (
- +
-
用户名
-
12:00
+
+ +
+
{showMessageTime(topic.createdAt)}
-
- 内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容 -
+
{getMessageRender(topic.content)}
-
回复回复回复
+ {Array.isArray(topic.comments) && topic.comments.length > 0 && ( + + )}
- +
+ + {showReply && ( + + setComment(e.target.value)} + onPressEnter={handleComment} + /> + + )}
); diff --git a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicComments.tsx b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicComments.tsx new file mode 100644 index 00000000..9814630a --- /dev/null +++ b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/components/TopicComments.tsx @@ -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 ( + + {props.comments.map((comment) => ( +
+ :
{comment.content}
+
+ ))} +
+ ); +}); +TopicComments.displayName = 'TopicComments'; diff --git a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx index dff750ff..87165711 100644 --- a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx +++ b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx @@ -78,7 +78,7 @@ const GroupTopicPanelRender: React.FC = React.memo(() => { return ( {Array.isArray(list) && list.length > 0 ? ( - list.map((_, i) => ) + list.map((item, i) => ) ) : (