From 91c315be231417ccac6a4bd735ed64c5a3030772 Mon Sep 17 00:00:00 2001 From: haorwen Date: Tue, 20 Jan 2026 08:09:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=BE=A4=E5=85=AC?= =?UTF-8?q?=E5=91=8A=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增加群公告 * chore: bump version to v0.9.54 --- package.json | 2 +- public/locales/en/chat.json | 11 +- public/locales/en/setting.json | 6 +- public/locales/zh/chat.json | 7 + public/locales/zh/setting.json | 6 +- src/app/services/server.ts | 29 +++ src/components/AnnouncementBanner.tsx | 43 ++++ src/components/AnnouncementModal.tsx | 103 ++++++++ src/hooks/useStreaming/index.ts | 8 + src/routes/chat/ChannelChat/index.tsx | 232 ++++++++++++------ .../settingChannel/Announcement/index.tsx | 200 +++++++++++++++ src/routes/settingChannel/navs.tsx | 6 + src/types/sse.ts | 17 +- 13 files changed, 585 insertions(+), 85 deletions(-) create mode 100644 src/components/AnnouncementBanner.tsx create mode 100644 src/components/AnnouncementModal.tsx create mode 100644 src/routes/settingChannel/Announcement/index.tsx diff --git a/package.json b/package.json index 3ecf520a..208a1603 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.53", + "version": "0.9.54", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/locales/en/chat.json b/public/locales/en/chat.json index 47ecfc0a..bf4ad06b 100644 --- a/public/locales/en/chat.json +++ b/public/locales/en/chat.json @@ -2,15 +2,22 @@ "pin": "Pin", "pin_desc": "Do you want to pin this message to", "pinned_msg": "Pinned Message", - "pin_empty_tip": "This channel doesn’t have any pinned message yet.", + "pin_empty_tip": "This channel doesn't have any pinned message yet.", "fav": "Favorite", "fav_msg": "Saved Message", - "fav_empty_tip": "This channel doesn’t have any saved message yet.", + "fav_empty_tip": "This channel doesn't have any saved message yet.", "channel_members": "Channel Members", "add_channel_members": "Add members", "welcome_channel": "Welcome to {{name}}", "welcome_desc": "This is the start of the #{{name}} channel.", "edit_channel": "Edit Channel", + "announcement": "Announcement", + "view_announcement": "View Announcement", + "edit_announcement": "Edit Announcement", + "created_by": "Created by", + "created_at": "Created", + "updated_at": "Last updated", + "dismiss_announcement": "Dismiss announcement", "channel_name": "Channel Name", "private_channel": "Private Channel", diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 1040a329..5a213e3b 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -6,6 +6,7 @@ "nav": { "general": "General", "overview": "Overview", + "announcement": "Announcement", "members": "Members", "data_management": "Data Management", "auto_delete_msg": "Auto-delete Messages", @@ -48,7 +49,10 @@ "add_friend": "Add friend", "dm_to_member": "DM to Member", "show_email": "Show Email", - "only_owner": "Only Owner and Admin Can Send Message" + "only_owner": "Only Owner and Admin Can Send Message", + "announcement_placeholder": "Write your announcement here... Markdown is supported.", + "no_announcements": "No content to preview", + "no_permission": "You don't have permission to manage announcements" }, "dm": { "delete": "Delete User", diff --git a/public/locales/zh/chat.json b/public/locales/zh/chat.json index e6d42ded..f97d5bbd 100644 --- a/public/locales/zh/chat.json +++ b/public/locales/zh/chat.json @@ -11,6 +11,13 @@ "welcome_channel": "欢迎来到 {{name}} 频道", "welcome_desc": "这是频道 #{{name}} 的开始。", "edit_channel": "编辑频道", + "announcement": "频道公告", + "view_announcement": "查看公告", + "edit_announcement": "编辑公告", + "created_by": "创建者", + "created_at": "创建时间", + "updated_at": "最后更新", + "dismiss_announcement": "关闭公告", "channel_name": "频道名称", "private_channel": "私密频道", diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index f7907f34..78a3fe87 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -6,6 +6,7 @@ "nav": { "general": "通用", "overview": "概况", + "announcement": "公告", "members": "成员", "data_management": "数据管理", "auto_delete_msg": "自动删除消息", @@ -47,7 +48,10 @@ "add_friend": "允许加好友", "dm_to_member": "允许私聊", "show_email": "显示邮箱", - "only_owner": "仅创建者和管理员可发消息" + "only_owner": "仅创建者和管理员可发消息", + "announcement_placeholder": "在此输入公告内容... 支持 Markdown 格式。", + "no_announcements": "暂无内容预览", + "no_permission": "您没有权限管理公告" }, "overview": { "title_feat": "特性开关", diff --git a/src/app/services/server.ts b/src/app/services/server.ts index 5cf84f47..edc6b47a 100644 --- a/src/app/services/server.ts +++ b/src/app/services/server.ts @@ -34,10 +34,12 @@ import { updateCallInfo, upsertVoiceList } from "../slices/voice"; import { RootState } from "../store"; import baseQuery from "./base.query"; import { GetFilesDTO, VoceChatFile } from "@/types/resource"; +import { GroupAnnouncement } from "@/types/sse"; export const serverApi = createApi({ reducerPath: "serverApi", baseQuery, + tagTypes: ["GroupAnnouncements"], endpoints: (builder) => ({ getServer: builder.query({ query: () => ({ url: `/admin/system/organization` }), @@ -450,6 +452,30 @@ export const serverApi = createApi({ body: content, }), }), + getGroupAnnouncement: builder.query<{ announcement: GroupAnnouncement | null }, number>({ + query: (gid) => ({ + url: `/group/${gid}/announcement`, + }), + providesTags: (result, error, gid) => [{ type: "GroupAnnouncements", id: gid }], + }), + createOrUpdateGroupAnnouncement: builder.mutation< + GroupAnnouncement, + { gid: number; content: string } + >({ + query: ({ gid, content }) => ({ + url: `/group/${gid}/announcement`, + method: "POST", + body: { content }, + }), + invalidatesTags: (result, error, { gid }) => [{ type: "GroupAnnouncements", id: gid }], + }), + deleteGroupAnnouncement: builder.mutation({ + query: (gid) => ({ + url: `/group/${gid}/announcement`, + method: "DELETE", + }), + invalidatesTags: (result, error, gid) => [{ type: "GroupAnnouncements", id: gid }], + }), }), }); @@ -506,4 +532,7 @@ export const { useLazyGetVocespaceConfigQuery, useUpdateVocespaceConfigMutation, useHealthCheckVocespaceMutation, + useGetGroupAnnouncementQuery, + useCreateOrUpdateGroupAnnouncementMutation, + useDeleteGroupAnnouncementMutation, } = serverApi; diff --git a/src/components/AnnouncementBanner.tsx b/src/components/AnnouncementBanner.tsx new file mode 100644 index 00000000..3fb8ac19 --- /dev/null +++ b/src/components/AnnouncementBanner.tsx @@ -0,0 +1,43 @@ +import { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { GroupAnnouncement } from "@/types/sse"; +import IconClose from "@/assets/icons/close.svg"; + +interface Props { + announcement: GroupAnnouncement; + onExpand: () => void; + onDismiss: () => void; +} + +const AnnouncementBanner: FC = ({ announcement, onExpand, onDismiss }) => { + const { t } = useTranslation("chat"); + const firstLine = announcement.content.split("\n")[0].trim(); + const truncatedContent = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine; + + return ( +
+
+
+ + 📢 {t("announcement")}: + + +
+ +
+
+ ); +}; + +export default AnnouncementBanner; diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx new file mode 100644 index 00000000..f4e0e664 --- /dev/null +++ b/src/components/AnnouncementModal.tsx @@ -0,0 +1,103 @@ +import { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import { GroupAnnouncement } from "@/types/sse"; +import { useAppSelector } from "@/app/store"; +import Modal from "./Modal"; +import MarkdownRender from "./MarkdownRender"; +import IconClose from "@/assets/icons/close.svg"; +import { shallowEqual } from "react-redux"; + +interface Props { + announcement: GroupAnnouncement; + onClose: () => void; + cid: number; +} + +const AnnouncementModal: FC = ({ announcement, onClose, cid }) => { + const { t } = useTranslation("chat"); + const { t: ct } = useTranslation(); + const navigate = useNavigate(); + const loginUser = useAppSelector((store) => store.authData.user, shallowEqual); + const channel = useAppSelector((store) => store.channels.byId[cid], shallowEqual); + const usersData = useAppSelector((store) => store.users.byId, shallowEqual); + + const canManage = loginUser?.is_admin || channel?.owner === loginUser?.uid; + const creator = usersData[announcement.created_by]; + + const handleEdit = () => { + navigate(`/setting/channel/${cid}/announcement`); + onClose(); + }; + + return ( + +
+
+ {/* Header */} +
+

+ 📢 {t("announcement")} +

+ +
+ + {/* Content */} +
+
+ +
+ + {/* Metadata */} +
+
+ {creator && ( +
+ {t("created_by")}: {creator.name} +
+ )} +
+ {t("created_at")}:{" "} + {dayjs(announcement.created_at).format("YYYY-MM-DD h:mm A")} +
+ {announcement.updated_at !== announcement.created_at && ( +
+ {t("updated_at")}:{" "} + {dayjs(announcement.updated_at).format("YYYY-MM-DD h:mm A")} +
+ )} +
+
+
+ + {/* Footer */} +
+ {canManage && ( + + )} + +
+
+
+
+ ); +}; + +export default AnnouncementModal; diff --git a/src/hooks/useStreaming/index.ts b/src/hooks/useStreaming/index.ts index 9a5be298..17aeb0d1 100644 --- a/src/hooks/useStreaming/index.ts +++ b/src/hooks/useStreaming/index.ts @@ -5,6 +5,7 @@ import { isNull, omitBy } from "lodash"; import BASE_URL from "@/app/config"; import { useRenewMutation } from "@/app/services/auth"; +import { serverApi } from "@/app/services/server"; import { resetAuthData, updateLoginUser, updateRoleChanged } from "@/app/slices/auth.data"; import { addChannel, @@ -409,6 +410,13 @@ export default function useStreaming() { case "pinned_message_updated": dispatch(updatePinMessage(data)); break; + case "group_announcement_changed": { + const { gid } = data; + dispatch( + serverApi.util.invalidateTags([{ type: "GroupAnnouncements", id: gid }]) + ); + break; + } case "chat": { chatMessageHandler(data, dispatch, { afterMid: window.AFTER_MID ?? 0, diff --git a/src/routes/chat/ChannelChat/index.tsx b/src/routes/chat/ChannelChat/index.tsx index 9a693950..239aa825 100644 --- a/src/routes/chat/ChannelChat/index.tsx +++ b/src/routes/chat/ChannelChat/index.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef } from "react"; +import { memo, useEffect, useRef, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { shallowEqual, useDispatch } from "react-redux"; import { Link, useLocation, useNavigate } from "react-router-dom"; @@ -7,10 +7,13 @@ import Tippy from "@tippyjs/react"; import { updateChannelVisibleAside } from "@/app/slices/footprint"; import { updateRememberedNavs } from "@/app/slices/ui"; import { useAppSelector } from "@/app/store"; +import { useGetGroupAnnouncementQuery } from "@/app/services/server"; import ChannelIcon from "@/components/ChannelIcon"; import GoBackNav from "@/components/GoBackNav"; import Tooltip from "@/components/Tooltip"; import MessageSearch from "@/components/MessageSearch"; +import AnnouncementBanner from "@/components/AnnouncementBanner"; +import AnnouncementModal from "@/components/AnnouncementModal"; import IconFav from "@/assets/icons/bookmark.svg"; import IconPeople from "@/assets/icons/people.svg"; import IconPin from "@/assets/icons/pin.svg"; @@ -23,6 +26,7 @@ import Members from "./Members"; import PinList from "./PinList"; import { KEY_ADMIN_SEE_CHANNEL_MEMBERS } from "@/app/config"; import useServerExtSetting from "@/hooks/useServerExtSetting"; +import { compareVersion } from "@/utils"; type Props = { cid?: number; @@ -39,6 +43,22 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) { const visibleAside = useAppSelector((store) => store.footprint.channelAsides[cid], shallowEqual); const userIds = useAppSelector((store) => store.users.ids, shallowEqual); const data = useAppSelector((store) => store.channels.byId[cid], shallowEqual); + const currentVersion = useAppSelector((store) => store.server.version, shallowEqual); + + // Check if server version supports announcements + const isAnnouncementSupported = useMemo(() => { + return currentVersion && compareVersion(currentVersion, "0.5.13") >= 0; + }, [currentVersion]); + + // Announcement state - only fetch if version is supported + const { data: announcementResponse } = useGetGroupAnnouncementQuery(cid, { + skip: !cid || !isAnnouncementSupported + }); + const announcement = announcementResponse?.announcement; + const [showModal, setShowModal] = useState(false); + const [showBanner, setShowBanner] = useState(false); + const [bannerDismissed, setBannerDismissed] = useState(false); + useEffect(() => { if (!data) { // channel 不存在了 回首页 @@ -52,6 +72,29 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) { }; }, [pathname]); + // Handle announcement read status + useEffect(() => { + if (!announcement || !cid) { + setShowModal(false); + setShowBanner(false); + return; + } + + const storageKey = `announcement_read_${cid}`; + const storedTimestamp = localStorage.getItem(storageKey); + + if (!storedTimestamp || new Date(storedTimestamp) < new Date(announcement.updated_at)) { + // Unread announcement - show modal + setShowModal(true); + setShowBanner(false); + setBannerDismissed(false); + } else { + // Read announcement - show banner only + setShowModal(false); + setShowBanner(true); + } + }, [announcement, cid]); + const toggleMembersVisible = () => { dispatch( updateChannelVisibleAside({ @@ -65,6 +108,23 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) { feedRef.current?.scrollToMessage(mid); }; + const handleModalClose = () => { + if (announcement) { + const storageKey = `announcement_read_${cid}`; + localStorage.setItem(storageKey, new Date(announcement.updated_at).toISOString()); + } + setShowModal(false); + setShowBanner(true); + }; + + const handleBannerExpand = () => { + setShowModal(true); + }; + + const handleBannerDismiss = () => { + setBannerDismissed(true); + }; + if (!data) return null; const { name, description, is_public, members = [], owner } = data; const memberIds = is_public ? userIds : members.slice(0).sort((n) => (n == owner ? -1 : 0)); @@ -74,85 +134,99 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) { const onlyAdminCanSeeMembers = getExtSetting(KEY_ADMIN_SEE_CHANNEL_MEMBERS); const canViewMembers = loginUser?.is_admin ? true : !onlyAdminCanSeeMembers; return ( - - - } - > -
  • - {pinCount > 0 ? ( - - {pinCount} - - ) : null} - + <> + + + } + > +
  • + {pinCount > 0 ? ( + + {pinCount} + + ) : null} + +
  • +
    +
    + + + } + > +
  • + +
  • +
    +
    + {canViewMembers && ( +
  • + + +
  • - - - - - } - > -
  • - -
  • -
    -
    - {canViewMembers && ( -
  • - - - -
  • - )} - - } - header={ -
    - -
    - - - {name} - - {description} -
    - -
    - } - users={ - canViewMembers ? ( - - ) : null - } - voice={} - /> + )} + + } + header={ + <> +
    + +
    + + + {name} + + {description} +
    + +
    + {announcement && showBanner && !bannerDismissed && ( + + )} + + } + users={ + canViewMembers ? ( + + ) : null + } + voice={} + /> + {announcement && showModal && ( + + )} + ); } export default memo(ChannelChat, (prev, next) => prev.cid == next.cid); diff --git a/src/routes/settingChannel/Announcement/index.tsx b/src/routes/settingChannel/Announcement/index.tsx new file mode 100644 index 00000000..744ba0c2 --- /dev/null +++ b/src/routes/settingChannel/Announcement/index.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState, useMemo } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { useAppSelector } from "@/app/store"; +import { + useGetGroupAnnouncementQuery, + useCreateOrUpdateGroupAnnouncementMutation, + useDeleteGroupAnnouncementMutation, +} from "@/app/services/server"; +import ServerVersionChecker from "@/components/ServerVersionChecker"; +import Label from "@/components/styled/Label"; +import Button from "@/components/styled/Button"; +import Textarea from "@/components/styled/Textarea"; +import MarkdownRender from "@/components/MarkdownRender"; +import { shallowEqual } from "react-redux"; +import { compareVersion } from "@/utils"; + +export default function Announcement({ id = 0 }) { + const { t } = useTranslation("setting", { keyPrefix: "channel" }); + const { t: ct } = useTranslation(); + const loginUser = useAppSelector((store) => store.authData.user, shallowEqual); + const channel = useAppSelector((store) => store.channels.byId[id], shallowEqual); + const currentVersion = useAppSelector((store) => store.server.version, shallowEqual); + + // Check if server version supports announcements + const isVersionSupported = useMemo(() => { + return currentVersion && compareVersion(currentVersion, "0.5.13") >= 0; + }, [currentVersion]); + + const { data: announcementResponse, refetch } = useGetGroupAnnouncementQuery(id, { + skip: !id || !isVersionSupported + }); + const [createOrUpdate, { isSuccess: saveSuccess, isLoading: saving }] = + useCreateOrUpdateGroupAnnouncementMutation(); + const [deleteAnnouncement, { isSuccess: deleteSuccess, isLoading: deleting }] = + useDeleteGroupAnnouncementMutation(); + + const [content, setContent] = useState(""); + const [mode, setMode] = useState<"edit" | "preview">("edit"); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const announcement = announcementResponse?.announcement; + const canManage = loginUser?.is_admin || channel?.owner === loginUser?.uid; + + useEffect(() => { + if (announcement) { + setContent(announcement.content); + } + }, [announcement]); + + useEffect(() => { + if (saveSuccess) { + toast.success(ct("tip.update")); + refetch(); + } + }, [saveSuccess]); + + useEffect(() => { + if (deleteSuccess) { + toast.success("Announcement deleted"); + setContent(""); + setShowDeleteConfirm(false); + refetch(); + } + }, [deleteSuccess]); + + const handleSave = () => { + if (!content.trim()) { + toast.error("Announcement content cannot be empty"); + return; + } + if (content.length > 5000) { + toast.error("Announcement content cannot exceed 5000 characters"); + return; + } + createOrUpdate({ gid: id, content: content.trim() }); + }; + + const handleDelete = () => { + deleteAnnouncement(id); + }; + + return ( + + {!canManage ? ( +
    + {t("no_permission") || "You don't have permission to manage announcements"} +
    + ) : ( +
    +
    + +

    + Create or update the announcement for this channel. Members will see it as a popup when + it's new or updated. +

    +
    + + {/* Tab Switcher */} +
    + + +
    + + {/* Content Area */} + {mode === "edit" ? ( +
    +