feat: 增加消息搜索功能 (#285)

* feat: add MessageSearch component for message filtering in chat

* chore: bump version to v0.9.41
This commit is contained in:
haorwen
2025-12-27 18:08:41 +08:00
committed by GitHub
parent edbd7e14fa
commit baeeb2bfed
6 changed files with 163 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vocechat-web",
"version": "0.9.40",
"version": "0.9.41",
"homepage": "https://voce.chat",
"dependencies": {
"@metamask/onboarding": "^1.0.1",
+115
View File
@@ -0,0 +1,115 @@
import { useState, useMemo } from "react";
import { useAppSelector } from "@/app/store";
import { shallowEqual } from "react-redux";
import { ChatContext } from "@/types/common";
import dayjs from "dayjs";
import Avatar from "@/components/Avatar";
import IconSearch from "@/assets/icons/search.svg";
import IconClose from "@/assets/icons/close.circle.svg";
interface Props {
context: ChatContext;
id: number;
onLocate: (mid: number) => void;
}
export default function MessageSearch({ context, id, onLocate }: Props) {
const [query, setQuery] = useState("");
const [visible, setVisible] = useState(false);
const mids = useAppSelector(
(store) => (context === "channel" ? store.channelMessage[id] : store.userMessage.byId[id]) || [],
shallowEqual
);
const messageData = useAppSelector((store) => store.message, shallowEqual);
const usersData = useAppSelector((store) => store.users.byId, shallowEqual);
const searchResults = useMemo(() => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
return mids
.map((mid) => messageData[mid])
.filter((msg) => {
if (!msg || !msg.content) return false;
if (msg.content_type === "text/plain" || msg.content_type === "text/markdown") {
const content = typeof msg.content === "string" ? msg.content : "";
return content.toLowerCase().includes(lowerQuery);
}
return false;
})
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 50);
}, [query, mids, messageData]);
const handleLocate = (mid: number) => {
onLocate(mid);
setVisible(false);
setQuery("");
};
return (
<div className="relative">
<button
onClick={() => setVisible(!visible)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<IconSearch className="w-5 h-5 fill-gray-500" />
</button>
{visible && (
<div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索消息..."
className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded outline-none text-sm"
autoFocus
/>
<button onClick={() => setVisible(false)} className="p-1">
<IconClose className="w-5 h-5 fill-gray-500" />
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{query && searchResults.length === 0 && (
<div className="p-4 text-center text-gray-500 text-sm"></div>
)}
{searchResults.map((msg) => {
const user = usersData[msg.from_uid || 0];
return (
<div
key={msg.mid}
onClick={() => handleLocate(msg.mid)}
className="p-3 flex items-start gap-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700"
>
<Avatar width={32} height={32} src={user?.avatar} name={user?.name} className="rounded-full shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{user?.name}
</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{dayjs(msg.created_at).isSame(dayjs(), 'day')
? dayjs(msg.created_at).format("HH:mm")
: dayjs(msg.created_at).format("MM-DD HH:mm")}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{typeof msg.content === "string" ? msg.content : ""}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+10 -1
View File
@@ -1,4 +1,4 @@
import { memo, useEffect } from "react";
import { memo, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { shallowEqual, useDispatch } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -10,11 +10,13 @@ import { useAppSelector } from "@/app/store";
import ChannelIcon from "@/components/ChannelIcon";
import GoBackNav from "@/components/GoBackNav";
import Tooltip from "@/components/Tooltip";
import MessageSearch from "@/components/MessageSearch";
import IconFav from "@/assets/icons/bookmark.svg";
import IconPeople from "@/assets/icons/people.svg";
import IconPin from "@/assets/icons/pin.svg";
import FavList from "../FavList";
import Layout from "../Layout";
import { VirtualMessageFeedHandle } from "../Layout/VirtualMessageFeed";
import VoiceChat from "../VoiceChat";
import Dashboard from "../VoiceChat/Dashboard";
import Members from "./Members";
@@ -32,6 +34,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
const { pathname } = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const feedRef = useRef<VirtualMessageFeedHandle>(null);
const loginUser = useAppSelector((store) => store.authData.user, shallowEqual);
const visibleAside = useAppSelector((store) => store.footprint.channelAsides[cid], shallowEqual);
const userIds = useAppSelector((store) => store.users.ids, shallowEqual);
@@ -58,6 +61,10 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
);
};
const handleLocate = (mid: number) => {
feedRef.current?.scrollToMessage(mid);
};
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));
@@ -71,6 +78,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
to={cid}
context="channel"
dropFiles={dropFiles}
feedRef={feedRef}
aside={
<ul className="flex flex-col gap-6">
<Tooltip tip={t("pin")} placement="left">
@@ -129,6 +137,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
</Link>
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
</div>
<MessageSearch context="channel" id={cid} onLocate={handleLocate} />
</header>
}
users={
+12 -1
View File
@@ -1,4 +1,4 @@
import { FC, useEffect } from "react";
import { FC, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
@@ -6,9 +6,11 @@ import { useAppSelector } from "@/app/store";
import GoBackNav from "@/components/GoBackNav";
import Tooltip from "@/components/Tooltip";
import User from "@/components/User";
import MessageSearch from "@/components/MessageSearch";
import FavIcon from "@/assets/icons/bookmark.svg";
import FavList from "../FavList";
import Layout from "../Layout";
import { VirtualMessageFeedHandle } from "../Layout/VirtualMessageFeed";
import VoiceChat from "../VoiceChat";
import { shallowEqual } from "react-redux";
@@ -18,19 +20,27 @@ type Props = {
};
const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
const navigate = useNavigate();
const feedRef = useRef<VirtualMessageFeedHandle>(null);
const currUser = useAppSelector((store) => store.users.byId[uid], shallowEqual);
useEffect(() => {
if (!currUser) {
// user不存在了 回首页
navigate("/chat");
}
}, [currUser]);
const handleLocate = (mid: number) => {
feedRef.current?.scrollToMessage(mid);
};
if (!currUser) return null;
return (
<Layout
to={uid}
context="dm"
dropFiles={dropFiles}
feedRef={feedRef}
aside={
<ul className="flex flex-col gap-6">
<VoiceChat context={`dm`} id={uid} />
@@ -54,6 +64,7 @@ const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
<header className="box-border px-5 py-1 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
<GoBackNav />
<User interactive={false} uid={currUser.uid} enableNavToSetting={true} />
<MessageSearch context="dm" id={uid} onLocate={handleLocate} />
</header>
}
/>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
import { shallowEqual, useDispatch } from "react-redux";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { useDebounce } from "rooks";
@@ -16,9 +16,14 @@ type Props = {
context: ChatContext;
id: number;
};
export interface VirtualMessageFeedHandle {
scrollToMessage: (mid: number) => void;
}
// const firstMsgIndex = 10000;
// let prevMids: number[] = [];
const VirtualMessageFeed = ({ context, id }: Props) => {
const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ context, id }, ref) => {
const dispatch = useDispatch();
// const { t } = useTranslation("chat");
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
@@ -102,6 +107,16 @@ const VirtualMessageFeed = ({ context, id }: Props) => {
const handleBottomStateChange = (bottom: boolean) => {
setAtBottom(bottom);
};
useImperativeHandle(ref, () => ({
scrollToMessage: (mid: number) => {
const index = mids.findIndex((m) => m === mid);
if (index !== -1 && vList.current) {
vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" });
}
}
}));
const readIndex = context == "channel" ? readChannels[id] : readUsers[id];
return (
<>
@@ -149,6 +164,8 @@ const VirtualMessageFeed = ({ context, id }: Props) => {
)}
</>
);
};
});
VirtualMessageFeed.displayName = "VirtualMessageFeed";
export default VirtualMessageFeed;
+5 -3
View File
@@ -1,4 +1,4 @@
import { FC, ReactElement, useEffect } from "react";
import { FC, ReactElement, useEffect, useRef } from "react";
import { useDrop } from "react-dnd";
import { NativeTypes } from "react-dnd-html5-backend";
import { toast } from "react-hot-toast";
@@ -17,7 +17,7 @@ import DnDTip from "./DnDTip";
import LicenseUpgradeTip from "./LicenseOutdatedTip";
import LoginTip from "./LoginTip";
import Operations from "./Operations";
import VirtualMessageFeed from "./VirtualMessageFeed";
import VirtualMessageFeed, { VirtualMessageFeedHandle } from "./VirtualMessageFeed";
import { platform } from "@/utils";
import { shallowEqual } from "react-redux";
@@ -30,12 +30,14 @@ interface Props {
dropFiles?: File[];
context: ChatContext;
to: number;
feedRef?: React.RefObject<VirtualMessageFeedHandle>;
}
const Layout: FC<Props> = ({
readonly = false,
header,
aside = null,
feedRef,
users = null,
voice = null,
dropFiles = [],
@@ -101,7 +103,7 @@ const Layout: FC<Props> = ({
{context == "dm" && <DMVoice uid={to} />}
{context == "dm" && <AddContactTip uid={to} />}
{/* 消息流 */}
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={to} />
<VirtualMessageFeed ref={feedRef} key={`${context}_${to}`} context={context} id={to} />
{/* 发送框 */}
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
{readonly ? (