diff --git a/package.json b/package.json
index 6cdd929d..e2b7a7be 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/MessageSearch/index.tsx b/src/components/MessageSearch/index.tsx
new file mode 100644
index 00000000..200d07b5
--- /dev/null
+++ b/src/components/MessageSearch/index.tsx
@@ -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 (
+
+
+
+ {visible && (
+
+
+
+ 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
+ />
+
+
+
+
+
+ {query && searchResults.length === 0 && (
+
未找到匹配的消息
+ )}
+ {searchResults.map((msg) => {
+ const user = usersData[msg.from_uid || 0];
+ return (
+
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"
+ >
+
+
+
+
+ {user?.name}
+
+
+ {dayjs(msg.created_at).isSame(dayjs(), 'day')
+ ? dayjs(msg.created_at).format("HH:mm")
+ : dayjs(msg.created_at).format("MM-DD HH:mm")}
+
+
+
+ {typeof msg.content === "string" ? msg.content : ""}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
diff --git a/src/routes/chat/ChannelChat/index.tsx b/src/routes/chat/ChannelChat/index.tsx
index 95abd8dd..9a693950 100644
--- a/src/routes/chat/ChannelChat/index.tsx
+++ b/src/routes/chat/ChannelChat/index.tsx
@@ -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(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={
@@ -129,6 +137,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
{description}
+
}
users={
diff --git a/src/routes/chat/DMChat/index.tsx b/src/routes/chat/DMChat/index.tsx
index 47e5ffc4..6f75f367 100644
--- a/src/routes/chat/DMChat/index.tsx
+++ b/src/routes/chat/DMChat/index.tsx
@@ -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 = ({ uid = 0, dropFiles }) => {
const navigate = useNavigate();
+ const feedRef = useRef(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 (
@@ -54,6 +64,7 @@ const DMChat: FC = ({ uid = 0, dropFiles }) => {
}
/>
diff --git a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx
index 36a3a71f..c3732124 100644
--- a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx
+++ b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx
@@ -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(({ 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;
diff --git a/src/routes/chat/Layout/index.tsx b/src/routes/chat/Layout/index.tsx
index 63c4b488..76831ba3 100644
--- a/src/routes/chat/Layout/index.tsx
+++ b/src/routes/chat/Layout/index.tsx
@@ -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;
}
const Layout: FC = ({
readonly = false,
header,
aside = null,
+ feedRef,
users = null,
voice = null,
dropFiles = [],
@@ -101,7 +103,7 @@ const Layout: FC = ({
{context == "dm" && }
{context == "dm" && }
{/* 消息流 */}
-
+
{/* 发送框 */}
{readonly ? (