feat: 增加消息搜索功能 (#285)
* feat: add MessageSearch component for message filtering in chat * chore: bump version to v0.9.41
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user