新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)
- 基于 openim-electron-demo 改造:工号+密码登录(自研账号服务)、会话列表/聊天窗/通讯录界面重做
- 文字/语音/文件/图片消息、文件拖拽发送、表情面板、一对一语音通话(LiveKit)
- RTC 令牌对接账号服务带鉴权版 /api/rtc_token(Bearer imToken + {room,identity})
- account-service: CORS Allow-Headers 补 authorization(浏览器/渲染进程跨域调 rtc_token 需要)
- 附本地 mock 账号服务(scripts/mock-account-server.js)与截图联调脚本
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
ConversationItem as ConversationItemType,
|
||||
MessageItem,
|
||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Badge } from "antd";
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { memo, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { useConversationStore, useUserStore } from "@/store";
|
||||
import { formatConversionTime, getConversationContent } from "@/utils/imCommon";
|
||||
|
||||
import styles from "./conversation-item.module.scss";
|
||||
|
||||
interface IConversationProps {
|
||||
isActive: boolean;
|
||||
conversation: ConversationItemType;
|
||||
}
|
||||
|
||||
const ConversationItem = ({ isActive, conversation }: IConversationProps) => {
|
||||
const navigate = useNavigate();
|
||||
const updateCurrentConversation = useConversationStore(
|
||||
(state) => state.updateCurrentConversation,
|
||||
);
|
||||
const currentUser = useUserStore((state) => state.selfInfo.userID);
|
||||
|
||||
const toSpecifiedConversation = async () => {
|
||||
if (isActive) {
|
||||
return;
|
||||
}
|
||||
await updateCurrentConversation({ ...conversation });
|
||||
navigate(`/chat/${conversation.conversationID}`);
|
||||
};
|
||||
|
||||
const latestMessageContent = useMemo(() => {
|
||||
let content = "";
|
||||
if (!conversation.latestMsg) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
content = getConversationContent(
|
||||
JSON.parse(conversation.latestMsg) as MessageItem,
|
||||
);
|
||||
} catch (error) {
|
||||
content = t("messageDescription.catchMessage");
|
||||
}
|
||||
return content;
|
||||
}, [conversation.draftText, conversation.latestMsg, isActive, currentUser]);
|
||||
|
||||
const latestMessageTime = formatConversionTime(conversation.latestMsgSendTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles["conversation-item"],
|
||||
"border border-transparent",
|
||||
isActive ? `bg-[var(--primary-active)]` : "hover:bg-[#F3F5F7]",
|
||||
)}
|
||||
onClick={toSpecifiedConversation}
|
||||
>
|
||||
<OIMAvatar
|
||||
size={48}
|
||||
src={conversation.faceURL}
|
||||
isgroup={Boolean(conversation.groupID)}
|
||||
text={conversation.showName}
|
||||
/>
|
||||
|
||||
<div className="ml-3 flex h-12 flex-1 flex-col justify-between overflow-hidden py-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-sm font-medium">
|
||||
{conversation.showName}
|
||||
</div>
|
||||
<div className="ml-2 text-xs text-[var(--sub-text)]">{latestMessageTime}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-h-[18px] flex-1 items-center overflow-hidden">
|
||||
<div
|
||||
className="truncate text-[13px] text-[rgba(81,94,112,0.5)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: latestMessageContent,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<Badge
|
||||
className="ml-2"
|
||||
size="small"
|
||||
count={conversation.unreadCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ConversationItem);
|
||||
@@ -0,0 +1,19 @@
|
||||
.conversation-item {
|
||||
@apply relative mx-2 my-0.5 flex cursor-pointer items-center rounded-lg px-3 py-2.5;
|
||||
|
||||
&-pined {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border: 4px solid;
|
||||
border-color: var(--primary) var(--primary) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.emojione) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Spin } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import FlexibleSider from "@/components/FlexibleSider";
|
||||
import { useConversationStore, useUserStore } from "@/store";
|
||||
|
||||
import ConversationItemComp from "./ConversationItem";
|
||||
|
||||
// 断网时顶部的深色提示横条
|
||||
const OfflineBar = () => (
|
||||
<div>
|
||||
<div className="flex h-9 items-center bg-[#4a4a4a] px-3 text-xs text-white">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="mr-1.5 shrink-0"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="2" />
|
||||
<path
|
||||
d="M12 7.5v5.5"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
|
||||
</svg>
|
||||
{t("states.offline")}
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs text-[var(--sub-text)]">
|
||||
{t("states.offlineTip")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyIcon = () => (
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
|
||||
fill="#dcdfe6"
|
||||
/>
|
||||
<circle cx="8.5" cy="11" r="1.1" fill="#fff" />
|
||||
<circle cx="12" cy="11" r="1.1" fill="#fff" />
|
||||
<circle cx="15.5" cy="11" r="1.1" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ConversationSider = () => {
|
||||
const { conversationID } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const conversationList = useConversationStore((state) => state.conversationList);
|
||||
const getConversationListByReq = useConversationStore(
|
||||
(state) => state.getConversationListByReq,
|
||||
);
|
||||
const syncState = useUserStore((state) => state.syncState);
|
||||
const connectState = useUserStore((state) => state.connectState);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const virtuoso = useRef<VirtuosoHandle>(null);
|
||||
const hasmore = useRef(true);
|
||||
const loading = useRef(false);
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
const word = keyword.trim();
|
||||
if (!word) return conversationList;
|
||||
return conversationList.filter((conversation) =>
|
||||
conversation.showName?.toLowerCase().includes(word.toLowerCase()),
|
||||
);
|
||||
}, [conversationList, keyword]);
|
||||
|
||||
const endReached = async () => {
|
||||
if (!hasmore.current || loading.current || keyword.trim()) return;
|
||||
loading.current = true;
|
||||
hasmore.current = await getConversationListByReq(true);
|
||||
loading.current = false;
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (syncState === "loading" && conversationList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Spin />
|
||||
<div className="mt-4 text-sm">{t("states.loading")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("states.loadingTip")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (syncState === "failed" && conversationList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<EmptyIcon />
|
||||
<div className="mt-4 text-sm">{t("states.loadFailed")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("states.loadFailedTip")}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => getConversationListByReq(false)}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (conversationList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<EmptyIcon />
|
||||
<div className="mt-4 text-sm">{t("states.noMessage")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("states.noMessageTip")}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4"
|
||||
type="primary"
|
||||
onClick={() => navigate("/contact")}
|
||||
>
|
||||
{t("states.findColleague")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Virtuoso
|
||||
className="flex-1"
|
||||
data={filteredList}
|
||||
ref={virtuoso}
|
||||
endReached={endReached}
|
||||
computeItemKey={(_, item) => item.conversationID}
|
||||
itemContent={(_, conversation) => (
|
||||
<ConversationItemComp
|
||||
isActive={conversationID === conversation.conversationID}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleSider
|
||||
needHidden={Boolean(conversationID)}
|
||||
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
|
||||
>
|
||||
{connectState === "failed" && <OfflineBar />}
|
||||
<div className="app-drag px-4 pt-4">
|
||||
<div className="pb-3 text-base font-bold">{t("placeholder.chat")}</div>
|
||||
<Input
|
||||
className="app-no-drag mb-2 rounded-md border-none !bg-[#F3F5F7]"
|
||||
prefix={<SearchOutlined className="text-[#8e9ab0]" rev={undefined} />}
|
||||
placeholder={t("placeholder.search")}
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{renderBody()}
|
||||
</FlexibleSider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationSider;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Layout } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const EmptyChat = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Layout className="no-mobile flex items-center justify-center !bg-[#F3F5F7]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--primary)]">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-4 text-base font-medium">{t("placeholder.title")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.subTitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Layout } from "antd";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import ConversationSider from "./ConversationSider";
|
||||
|
||||
export const Chat = () => {
|
||||
return (
|
||||
<Layout className="flex-row">
|
||||
<ConversationSider />
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { SessionType } from "@openim/wasm-client-sdk";
|
||||
import { Button, Layout, Spin } from "antd";
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import { SystemMessageTypes } from "@/constants/im";
|
||||
import { useUserStore } from "@/store";
|
||||
import emitter from "@/utils/events";
|
||||
import { formatMessageTime } from "@/utils/imCommon";
|
||||
|
||||
import MessageItem from "./MessageItem";
|
||||
import NotificationMessage from "./NotificationMessage";
|
||||
import { useHistoryMessageList } from "./useHistoryMessageList";
|
||||
|
||||
// 两条消息间隔超过 5 分钟则显示时间分隔
|
||||
const TIME_DIVIDER_GAP = 5 * 60 * 1000;
|
||||
|
||||
const ChatContent = () => {
|
||||
const virtuoso = useRef<VirtuosoHandle>(null);
|
||||
const selfUserID = useUserStore((state) => state.selfInfo.userID);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
virtuoso.current?.scrollToIndex({
|
||||
index: 9999,
|
||||
align: "end",
|
||||
behavior: "auto",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
SPLIT_COUNT,
|
||||
conversationID,
|
||||
loadState,
|
||||
moreOldLoading,
|
||||
getMoreOldMessages,
|
||||
loadError,
|
||||
} = useHistoryMessageList();
|
||||
|
||||
useEffect(() => {
|
||||
emitter.on("CHAT_LIST_SCROLL_TO_BOTTOM", scrollToBottom);
|
||||
return () => {
|
||||
emitter.off("CHAT_LIST_SCROLL_TO_BOTTOM", scrollToBottom);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadMoreMessage = () => {
|
||||
if (!loadState.hasMoreOld || moreOldLoading) return;
|
||||
|
||||
getMoreOldMessages().catch(() => undefined);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (loadState.initLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<Spin spinning />
|
||||
<div className="mt-4 text-sm">{t("states.loading")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("states.loadingTip")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<div className="text-sm">{t("states.loadFailed")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("states.loadFailedTip")}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => getMoreOldMessages(false).catch(() => undefined)}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Virtuoso
|
||||
id="chat-list"
|
||||
className="w-full overflow-x-hidden"
|
||||
followOutput="smooth"
|
||||
firstItemIndex={loadState.firstItemIndex}
|
||||
initialTopMostItemIndex={SPLIT_COUNT - 1}
|
||||
startReached={loadMoreMessage}
|
||||
ref={virtuoso}
|
||||
data={loadState.messageList}
|
||||
components={{
|
||||
Header: () =>
|
||||
loadState.hasMoreOld ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex justify-center py-2 opacity-0",
|
||||
moreOldLoading && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Spin />
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
computeItemKey={(_, item) => item.clientMsgID}
|
||||
itemContent={(index, message) => {
|
||||
const dataIndex = index - loadState.firstItemIndex;
|
||||
const prevMessage = loadState.messageList[dataIndex - 1];
|
||||
const showTimeDivider =
|
||||
!prevMessage ||
|
||||
message.sendTime - prevMessage.sendTime > TIME_DIVIDER_GAP;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showTimeDivider && (
|
||||
<div className="pt-4 text-center text-xs text-[var(--sub-text)]">
|
||||
{formatMessageTime(message.sendTime)}
|
||||
</div>
|
||||
)}
|
||||
{SystemMessageTypes.includes(message.contentType) ? (
|
||||
<NotificationMessage key={message.clientMsgID} message={message} />
|
||||
) : (
|
||||
<MessageItem
|
||||
key={message.clientMsgID}
|
||||
conversationID={conversationID}
|
||||
message={message}
|
||||
messageUpdateFlag={
|
||||
(message.senderNickname ?? "") + (message.senderFaceUrl ?? "")
|
||||
}
|
||||
isSender={selfUserID === message.sendID}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout.Content
|
||||
className="relative flex h-full overflow-hidden !bg-[#F3F5F7]"
|
||||
id="chat-main"
|
||||
>
|
||||
{renderBody()}
|
||||
</Layout.Content>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChatContent);
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MessageItem } from "@openim/wasm-client-sdk";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { uploadFile } from "@/utils/imCommon";
|
||||
|
||||
export interface FileWithPath extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const getPicInfo = (file: File): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const _URL = window.URL || window.webkitURL;
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = _URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
export function useFileMessage() {
|
||||
const getImageMessage = async (file: FileWithPath): Promise<MessageItem> => {
|
||||
if (window.electronAPI && file.path) {
|
||||
const imageMessage = (await IMSDK.createImageMessageFromFullPath(file.path))
|
||||
.data as MessageItem;
|
||||
imageMessage.pictureElem!.sourcePicture.url = URL.createObjectURL(file);
|
||||
return imageMessage;
|
||||
}
|
||||
|
||||
const { width, height } = await getPicInfo(file);
|
||||
const {
|
||||
data: { url },
|
||||
} = await uploadFile(file);
|
||||
const baseInfo = {
|
||||
uuid: uuidV4(),
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width,
|
||||
height,
|
||||
url: URL.createObjectURL(file),
|
||||
};
|
||||
const imageMessage = (
|
||||
await IMSDK.createImageMessageByURL({
|
||||
sourcePicture: { ...baseInfo, url },
|
||||
bigPicture: { ...baseInfo, url },
|
||||
snapshotPicture: { ...baseInfo, url },
|
||||
sourcePath: "",
|
||||
})
|
||||
).data as MessageItem;
|
||||
// 本地预览用 blob 地址,避免发送中图片空白
|
||||
imageMessage.pictureElem!.snapshotPicture!.url = baseInfo.url;
|
||||
return imageMessage;
|
||||
};
|
||||
|
||||
const getFileMessage = async (file: FileWithPath): Promise<MessageItem> => {
|
||||
if (window.electronAPI && file.path) {
|
||||
return (await IMSDK.createFileMessageFromFullPath(file.path, file.name))
|
||||
.data as MessageItem;
|
||||
}
|
||||
const {
|
||||
data: { url },
|
||||
} = await uploadFile(file);
|
||||
return (
|
||||
await IMSDK.createFileMessageByURL({
|
||||
filePath: "",
|
||||
fileName: file.name,
|
||||
uuid: uuidV4(),
|
||||
sourceUrl: url,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
})
|
||||
).data as MessageItem;
|
||||
};
|
||||
|
||||
const getSoundMessage = async (
|
||||
file: FileWithPath,
|
||||
duration: number,
|
||||
): Promise<MessageItem> => {
|
||||
if (window.electronAPI) {
|
||||
const filePath = file.path ?? (await window.electronAPI.saveFileToDisk({ file, sync: true }));
|
||||
return (await IMSDK.createSoundMessageFromFullPath(filePath, duration))
|
||||
.data as MessageItem;
|
||||
}
|
||||
const {
|
||||
data: { url },
|
||||
} = await uploadFile(file);
|
||||
return (
|
||||
await IMSDK.createSoundMessageByURL({
|
||||
uuid: uuidV4(),
|
||||
soundPath: "",
|
||||
sourceUrl: url,
|
||||
dataSize: file.size,
|
||||
duration,
|
||||
soundType: file.type,
|
||||
})
|
||||
).data as MessageItem;
|
||||
};
|
||||
|
||||
return {
|
||||
getImageMessage,
|
||||
getFileMessage,
|
||||
getSoundMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
AudioOutlined,
|
||||
FileOutlined,
|
||||
PictureOutlined,
|
||||
SmileOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Popover, Upload } from "antd";
|
||||
import { TextAreaRef } from "antd/es/input/TextArea";
|
||||
import { t } from "i18next";
|
||||
import { UploadRequestOption } from "rc-upload/lib/interface";
|
||||
import { forwardRef, ForwardRefRenderFunction, memo, useRef, useState } from "react";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore } from "@/store";
|
||||
import { canSendImageTypeList, feedbackToast } from "@/utils/common";
|
||||
|
||||
import { useFileMessage } from "./SendActionBar/useFileMessage";
|
||||
import { useSendMessage } from "./useSendMessage";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
|
||||
const EMOJI_LIST = [
|
||||
"😀", "😁", "😂", "🤣", "😃", "😄", "😅", "😆",
|
||||
"😉", "😊", "😋", "😎", "😍", "😘", "🥰", "😗",
|
||||
"🙂", "🤗", "🤔", "😐", "😑", "😶", "🙄", "😏",
|
||||
"😮", "😪", "😴", "😌", "😜", "🤤", "🙃", "😇",
|
||||
"🥳", "🤠", "😭", "😢", "😤", "😠", "😡", "🤯",
|
||||
"👍", "👎", "👌", "✌️", "🤝", "👏", "🙏", "💪",
|
||||
"❤️", "💔", "💯", "🎉", "🎊", "🔥", "✨", "🌹",
|
||||
];
|
||||
|
||||
const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
|
||||
<div className="grid w-72 grid-cols-8 gap-1">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<div
|
||||
key={emoji}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-xl hover:bg-[var(--primary-active)]"
|
||||
onClick={() => onSelect(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [emojiVisible, setEmojiVisible] = useState(false);
|
||||
const textareaRef = useRef<TextAreaRef>(null);
|
||||
|
||||
const currentConversation = useConversationStore(
|
||||
(state) => state.currentConversation,
|
||||
);
|
||||
|
||||
const { getImageMessage, getFileMessage, getSoundMessage } = useFileMessage();
|
||||
const { sendMessage } = useSendMessage();
|
||||
|
||||
const sendSound = async (file: File, duration: number) => {
|
||||
try {
|
||||
const message = await getSoundMessage(file, duration);
|
||||
sendMessage({ message });
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
};
|
||||
|
||||
const { recording, seconds, start, finish, cancel } = useVoiceRecorder(
|
||||
({ file, duration }) => sendSound(file, duration),
|
||||
);
|
||||
|
||||
const sendText = async () => {
|
||||
const cleanText = text.trim();
|
||||
if (!cleanText || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const message = (await IMSDK.createTextMessage(cleanText)).data;
|
||||
setText("");
|
||||
sendMessage({ message });
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendText();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
insertAtCursor("\n");
|
||||
}
|
||||
};
|
||||
|
||||
const insertAtCursor = (value: string) => {
|
||||
const textarea = textareaRef.current?.resizableTextArea?.textArea;
|
||||
if (!textarea) {
|
||||
setText((prev) => prev + value);
|
||||
return;
|
||||
}
|
||||
const startPos = textarea.selectionStart ?? text.length;
|
||||
const endPos = textarea.selectionEnd ?? text.length;
|
||||
const next = text.slice(0, startPos) + value + text.slice(endPos);
|
||||
setText(next);
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const pos = startPos + value.length;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
|
||||
const onEmojiSelect = (emoji: string) => {
|
||||
insertAtCursor(emoji);
|
||||
};
|
||||
|
||||
const sendFileMessage = async (file: File) => {
|
||||
try {
|
||||
const message = await getFileMessage(file);
|
||||
sendMessage({ message });
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
};
|
||||
|
||||
const sendImageMessage = async (options: UploadRequestOption) => {
|
||||
const file = options.file as File;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (file.type.startsWith("image/") || canSendImageTypeList.includes(ext)) {
|
||||
try {
|
||||
const message = await getImageMessage(file);
|
||||
sendMessage({ message });
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendFileMessage(file);
|
||||
};
|
||||
|
||||
const toolIconClass =
|
||||
"cursor-pointer text-xl text-[#8e9ab0] hover:text-[#515E70]";
|
||||
|
||||
return (
|
||||
<footer className="relative bg-white">
|
||||
<div className="flex flex-col border-t border-t-[var(--gap-text)]">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-5 px-4 pt-2">
|
||||
<Popover
|
||||
content={<EmojiPanel onSelect={onEmojiSelect} />}
|
||||
title={null}
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
open={emojiVisible}
|
||||
onOpenChange={setEmojiVisible}
|
||||
>
|
||||
<SmileOutlined className={toolIconClass} rev={undefined} />
|
||||
</Popover>
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
customRequest={(options) => sendFileMessage(options.file as File)}
|
||||
multiple
|
||||
className="flex"
|
||||
>
|
||||
<FileOutlined className={toolIconClass} rev={undefined} />
|
||||
</Upload>
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
customRequest={sendImageMessage}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="flex"
|
||||
>
|
||||
<PictureOutlined className={toolIconClass} rev={undefined} />
|
||||
</Upload>
|
||||
<AudioOutlined
|
||||
className={toolIconClass}
|
||||
rev={undefined}
|
||||
onClick={() => start()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 录音条 */}
|
||||
{recording && (
|
||||
<div className="mx-4 mt-2 flex items-center rounded-md bg-[#F3F5F7] px-3 py-2">
|
||||
<span className="mr-2 h-2 w-2 animate-pulse rounded-full bg-[#F4493C]" />
|
||||
<span className="flex-1 text-sm">
|
||||
{t("placeholder.microphone")} {seconds}″
|
||||
</span>
|
||||
<Button size="small" className="mr-2" onClick={cancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={finish}>
|
||||
{t("placeholder.send")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="px-4 pt-2">
|
||||
<Input.TextArea
|
||||
ref={textareaRef}
|
||||
className="!resize-none !border-none !shadow-none"
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
placeholder={t("placeholder.sendToName", {
|
||||
name: currentConversation?.showName ?? "",
|
||||
})}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部提示 + 发送按钮 */}
|
||||
<div className="flex items-center justify-end px-4 pb-2">
|
||||
<span className="mr-3 text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.enterToSendTip")}
|
||||
</span>
|
||||
<Button
|
||||
className="w-fit px-6"
|
||||
type="primary"
|
||||
disabled={!text.trim()}
|
||||
loading={sending}
|
||||
onClick={sendText}
|
||||
>
|
||||
{t("placeholder.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(ChatFooter));
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MessageStatus } from "@openim/wasm-client-sdk";
|
||||
import { MessageItem, WsResponse } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { SendMsgParams } from "@openim/wasm-client-sdk/lib/types/params";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore } from "@/store";
|
||||
import { emit } from "@/utils/events";
|
||||
|
||||
import { pushNewMessage, updateOneMessage } from "../useHistoryMessageList";
|
||||
|
||||
export type SendMessageParams = Partial<Omit<SendMsgParams, "message">> & {
|
||||
message: MessageItem;
|
||||
needPush?: boolean;
|
||||
};
|
||||
|
||||
export function useSendMessage() {
|
||||
const sendMessage = useCallback(
|
||||
async ({ recvID, groupID, message, needPush }: SendMessageParams) => {
|
||||
const currentConversation = useConversationStore.getState().currentConversation;
|
||||
const sourceID = recvID || groupID;
|
||||
const inCurrentConversation =
|
||||
currentConversation?.userID === sourceID ||
|
||||
currentConversation?.groupID === sourceID ||
|
||||
!sourceID;
|
||||
needPush = needPush ?? inCurrentConversation;
|
||||
|
||||
if (needPush) {
|
||||
pushNewMessage(message);
|
||||
emit("CHAT_LIST_SCROLL_TO_BOTTOM");
|
||||
}
|
||||
|
||||
const options = {
|
||||
recvID: recvID ?? currentConversation?.userID ?? "",
|
||||
groupID: groupID ?? currentConversation?.groupID ?? "",
|
||||
message,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data: successMessage } = await IMSDK.sendMessage(options);
|
||||
updateOneMessage(successMessage);
|
||||
} catch (error) {
|
||||
updateOneMessage({
|
||||
...message,
|
||||
status: MessageStatus.Failed,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
const MAX_DURATION = 60;
|
||||
|
||||
export interface RecorderResult {
|
||||
file: File;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function useVoiceRecorder(onFinish: (result: RecorderResult) => void) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const recorderRef = useRef<MediaRecorder>();
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout>();
|
||||
const secondsRef = useRef(0);
|
||||
const canceledRef = useRef(false);
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stopTracks = () => {
|
||||
recorderRef.current?.stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimer();
|
||||
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
||||
canceledRef.current = true;
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
stopTracks();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = async () => {
|
||||
if (recording) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: "audio/webm";
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
recorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
canceledRef.current = false;
|
||||
secondsRef.current = 0;
|
||||
setSeconds(0);
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
stopTracks();
|
||||
clearTimer();
|
||||
setRecording(false);
|
||||
if (canceledRef.current) return;
|
||||
const duration = secondsRef.current;
|
||||
if (duration < 1) {
|
||||
feedbackToast({ msg: t("toast.recordTooShort"), error: t("toast.recordTooShort") });
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, {
|
||||
type: mimeType,
|
||||
});
|
||||
onFinish({ file, duration });
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setRecording(true);
|
||||
timerRef.current = setInterval(() => {
|
||||
secondsRef.current += 1;
|
||||
setSeconds(secondsRef.current);
|
||||
if (secondsRef.current >= MAX_DURATION) {
|
||||
recorderRef.current?.stop();
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
feedbackToast({ msg: t("toast.noMicrophone"), error });
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
canceledRef.current = true;
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
recording,
|
||||
seconds,
|
||||
start,
|
||||
finish,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { MoreOutlined } from "@ant-design/icons";
|
||||
import { SessionType } from "@openim/wasm-client-sdk";
|
||||
import { Dropdown, Layout } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
|
||||
import { OverlayVisibleHandle } from "@/hooks/useOverlayVisible";
|
||||
import { useConversationStore, useUserStore } from "@/store";
|
||||
import emitter, { emit } from "@/utils/events";
|
||||
|
||||
import GroupSetting from "../GroupSetting";
|
||||
import SingleSetting from "../SingleSetting";
|
||||
|
||||
const ChatHeader = () => {
|
||||
const singleSettingRef = useRef<OverlayVisibleHandle>(null);
|
||||
const groupSettingRef = useRef<OverlayVisibleHandle>(null);
|
||||
|
||||
const currentConversation = useConversationStore(
|
||||
(state) => state.currentConversation,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (singleSettingRef.current?.isOverlayOpen) {
|
||||
singleSettingRef.current?.closeOverlay();
|
||||
}
|
||||
if (groupSettingRef.current?.isOverlayOpen) {
|
||||
groupSettingRef.current?.closeOverlay();
|
||||
}
|
||||
}, [currentConversation?.conversationID]);
|
||||
|
||||
const isSingleSession = currentConversation?.conversationType === SessionType.Single;
|
||||
|
||||
const startVoiceCall = () => {
|
||||
if (!currentConversation?.userID) return;
|
||||
emitter.emit("OPEN_RTC_MODAL", {
|
||||
invitation: {
|
||||
inviterUserID: useUserStore.getState().selfInfo.userID,
|
||||
inviteeUserIDList: [currentConversation.userID],
|
||||
groupID: "",
|
||||
roomID: uuidV4(),
|
||||
timeout: 60,
|
||||
mediaType: "audio",
|
||||
sessionType: SessionType.Single,
|
||||
platformID: window.electronAPI?.getPlatform() ?? 5,
|
||||
},
|
||||
participant: {
|
||||
userInfo: {
|
||||
nickname: currentConversation.showName,
|
||||
userID: currentConversation.userID,
|
||||
faceURL: currentConversation.faceURL,
|
||||
ex: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const menuItems = isSingleSession
|
||||
? [
|
||||
{ key: "voiceCall", label: t("placeholder.voiceCall") },
|
||||
{ key: "viewProfile", label: t("placeholder.viewProfile") },
|
||||
]
|
||||
: [{ key: "groupSetting", label: t("placeholder.groupSetting") }];
|
||||
|
||||
const onMenuClick = ({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case "voiceCall":
|
||||
startVoiceCall();
|
||||
break;
|
||||
case "viewProfile":
|
||||
emit("OPEN_USER_CARD", {
|
||||
userID: currentConversation?.userID,
|
||||
isSelf: false,
|
||||
});
|
||||
break;
|
||||
case "groupSetting":
|
||||
groupSettingRef.current?.openOverlay();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout.Header className="app-drag relative border-b border-b-[var(--gap-text)] !bg-white !px-4">
|
||||
<div className="flex h-full items-center leading-none">
|
||||
<div className="flex flex-1 items-center overflow-hidden">
|
||||
<div className="truncate text-base font-bold">
|
||||
{currentConversation?.showName}
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems, onClick: onMenuClick }}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<MoreOutlined
|
||||
className="app-no-drag cursor-pointer p-1 text-lg text-[#515E70] hover:text-[var(--primary)]"
|
||||
rev={undefined}
|
||||
/>
|
||||
</Dropdown>
|
||||
{/* 给悬浮窗口控制条留出空间 */}
|
||||
{Boolean(window.electronAPI) && <div className="w-28 shrink-0" />}
|
||||
</div>
|
||||
<SingleSetting ref={singleSettingRef} />
|
||||
<GroupSetting ref={groupSettingRef} />
|
||||
</Layout.Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChatHeader);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { GroupMemberItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Empty, Spin } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { FC, memo, useEffect } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { useCurrentMemberRole } from "@/hooks/useCurrentMemberRole";
|
||||
import useGroupMembers from "@/hooks/useGroupMembers";
|
||||
import { useUserStore } from "@/store";
|
||||
|
||||
import styles from "./group-setting.module.scss";
|
||||
import { GroupMemberRole } from "@openim/wasm-client-sdk";
|
||||
|
||||
const GroupMemberList: FC = () => {
|
||||
const selfUserID = useUserStore((state) => state.selfInfo.userID);
|
||||
const { currentMemberInGroup } = useCurrentMemberRole();
|
||||
const { fetchState, getMemberData, resetState } = useGroupMembers();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMemberInGroup?.groupID) {
|
||||
getMemberData(true);
|
||||
}
|
||||
return () => {
|
||||
resetState();
|
||||
};
|
||||
}, [currentMemberInGroup?.groupID]);
|
||||
|
||||
const endReached = () => {
|
||||
getMemberData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full px-2 py-2.5">
|
||||
{fetchState.groupMemberList.length === 0 ? (
|
||||
<Empty
|
||||
className="flex h-full flex-col items-center justify-center"
|
||||
description={t("empty.noSearchResults")}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
className="h-full overflow-x-hidden"
|
||||
data={fetchState.groupMemberList}
|
||||
endReached={endReached}
|
||||
components={{
|
||||
Header: () => (fetchState.loading ? <Spin /> : null),
|
||||
}}
|
||||
itemContent={(_, member) => (
|
||||
<MemberItem member={member} selfUserID={selfUserID} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMemberList;
|
||||
|
||||
interface IMemberItemProps {
|
||||
member: GroupMemberItem;
|
||||
selfUserID: string;
|
||||
}
|
||||
|
||||
const MemberItem = memo(({ member }: IMemberItemProps) => {
|
||||
const isOwner = member.roleLevel === GroupMemberRole.Owner;
|
||||
return (
|
||||
<div className={styles["list-member-item"]}>
|
||||
<div
|
||||
className="flex items-center overflow-hidden"
|
||||
onClick={() => window.userClick(member.userID, member.groupID)}
|
||||
>
|
||||
<OIMAvatar src={member.faceURL} text={member.nickname} />
|
||||
<div className="ml-3 flex items-center">
|
||||
<div className="max-w-[120px] truncate">{member.nickname}</div>
|
||||
{isOwner && (
|
||||
<span className="ml-2 rounded border border-[#FF9831] px-1 text-xs text-[#FF9831]">
|
||||
{t("placeholder.groupOwner")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LeftOutlined } from "@ant-design/icons";
|
||||
import { t } from "i18next";
|
||||
import { memo } from "react";
|
||||
|
||||
import invite_header from "@/assets/images/chatSetting/invite_header.png";
|
||||
import { useConversationStore } from "@/store";
|
||||
import { emit } from "@/utils/events";
|
||||
|
||||
const GroupMemberListHeader = ({ back2Settings }: { back2Settings: () => void }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<LeftOutlined
|
||||
className="mr-2 !text-[var(--base-black)]"
|
||||
rev={undefined}
|
||||
onClick={back2Settings}
|
||||
/>
|
||||
<div>{t("placeholder.memberList")}</div>
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<img
|
||||
className="mr-3 cursor-pointer"
|
||||
width={18}
|
||||
src={invite_header}
|
||||
alt=""
|
||||
onClick={() =>
|
||||
emit("OPEN_CHOOSE_MODAL", {
|
||||
type: "INVITE_TO_GROUP",
|
||||
extraData: useConversationStore.getState().currentConversation?.groupID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GroupMemberListHeader);
|
||||
@@ -0,0 +1,103 @@
|
||||
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
import invite from "@/assets/images/chatSetting/invite.png";
|
||||
import kick from "@/assets/images/chatSetting/kick.png";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import useGroupMembers from "@/hooks/useGroupMembers";
|
||||
import { emit } from "@/utils/events";
|
||||
|
||||
import styles from "./group-setting.module.scss";
|
||||
|
||||
const GroupMemberRow = ({
|
||||
currentGroupInfo,
|
||||
isNomal,
|
||||
updateTravel,
|
||||
}: {
|
||||
currentGroupInfo: GroupItem;
|
||||
isNomal: boolean;
|
||||
updateTravel: () => void;
|
||||
}) => {
|
||||
const { fetchState, getMemberData, resetState } = useGroupMembers();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGroupInfo?.groupID) {
|
||||
getMemberData(true);
|
||||
}
|
||||
return () => {
|
||||
resetState();
|
||||
};
|
||||
}, [currentGroupInfo?.groupID]);
|
||||
|
||||
const sliceCount = isNomal ? 17 : 16;
|
||||
|
||||
const inviteMember = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
emit("OPEN_CHOOSE_MODAL", {
|
||||
type: "INVITE_TO_GROUP",
|
||||
extraData: currentGroupInfo.groupID,
|
||||
});
|
||||
};
|
||||
|
||||
const kickMember = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
emit("OPEN_CHOOSE_MODAL", {
|
||||
type: "KICK_FORM_GROUP",
|
||||
extraData: currentGroupInfo.groupID,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 font-medium">
|
||||
<span>{t("placeholder.groupMember")}</span>
|
||||
<span className="ml-2">{currentGroupInfo?.memberCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center">
|
||||
{fetchState.groupMemberList.slice(0, sliceCount).map((member) => (
|
||||
<div
|
||||
key={member.userID}
|
||||
title={member.nickname}
|
||||
className={styles["member-item"]}
|
||||
onClick={() => window.userClick(member.userID, member.groupID)}
|
||||
>
|
||||
<OIMAvatar src={member.faceURL} text={member.nickname} size={36} />
|
||||
<div className="mt-2 min-h-[16px] max-w-full truncate text-xs">
|
||||
{member.nickname}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={clsx(styles["member-item"], "cursor-pointer")}
|
||||
onClick={inviteMember}
|
||||
>
|
||||
<img width={36} src={invite} alt="invite" />
|
||||
<div className="mt-2 max-w-full truncate text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.add")}
|
||||
</div>
|
||||
</div>
|
||||
{!isNomal && (
|
||||
<div
|
||||
className={clsx(styles["member-item"], "cursor-pointer")}
|
||||
onClick={kickMember}
|
||||
>
|
||||
<img width={36} src={kick} alt="kick" />
|
||||
<div className="mt-2 max-w-full truncate text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.remove")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center pt-2 text-xs text-[var(--primary)]"
|
||||
onClick={updateTravel}
|
||||
>
|
||||
{t("placeholder.viewMore")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GroupMemberRow);
|
||||
@@ -0,0 +1,167 @@
|
||||
import { RightOutlined } from "@ant-design/icons";
|
||||
import { Button, Divider, Upload } from "antd";
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import copy from "@/assets/images/chatSetting/copy.png";
|
||||
import edit_avatar from "@/assets/images/chatSetting/edit_avatar.png";
|
||||
import EditableContent from "@/components/EditableContent";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import SettingRow from "@/components/SettingRow";
|
||||
import { useCurrentMemberRole } from "@/hooks/useCurrentMemberRole";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
import { emit } from "@/utils/events";
|
||||
import { uploadFile } from "@/utils/imCommon";
|
||||
|
||||
import { FileWithPath } from "../ChatFooter/SendActionBar/useFileMessage";
|
||||
import GroupMemberRow from "./GroupMemberRow";
|
||||
import { useGroupSettings } from "./useGroupSettings";
|
||||
|
||||
const GroupSettings = ({
|
||||
updateTravel,
|
||||
closeOverlay,
|
||||
}: {
|
||||
updateTravel: () => void;
|
||||
closeOverlay: () => void;
|
||||
}) => {
|
||||
const { isNomal, isOwner, isAdmin, isJoinGroup } = useCurrentMemberRole();
|
||||
|
||||
const { currentGroupInfo, updateGroupInfo, tryQuitGroup, tryDismissGroup } =
|
||||
useGroupSettings({ closeOverlay });
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const customUpload = async ({ file }: { file: FileWithPath }) => {
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
} = await uploadFile(file);
|
||||
await updateGroupInfo({ faceURL: url });
|
||||
} catch (error) {
|
||||
feedbackToast({ error: t("toast.updateAvatarFailed") });
|
||||
}
|
||||
};
|
||||
|
||||
const updateGroupName = useCallback(
|
||||
async (groupName: string) => {
|
||||
await updateGroupInfo({ groupName });
|
||||
},
|
||||
[updateGroupInfo],
|
||||
);
|
||||
|
||||
const transferGroup = () => {
|
||||
emit("OPEN_CHOOSE_MODAL", {
|
||||
type: "TRANSFER_IN_GROUP",
|
||||
extraData: currentGroupInfo?.groupID,
|
||||
});
|
||||
};
|
||||
|
||||
const hasPermissions = isAdmin || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center">
|
||||
<Upload
|
||||
accept="image/*"
|
||||
className={clsx({ "disabled-upload": isNomal })}
|
||||
openFileDialogOnClick={hasPermissions}
|
||||
showUploadList={false}
|
||||
customRequest={customUpload as any}
|
||||
>
|
||||
<div className="relative">
|
||||
<OIMAvatar
|
||||
isgroup
|
||||
src={currentGroupInfo?.faceURL}
|
||||
text={currentGroupInfo?.groupName}
|
||||
/>
|
||||
{hasPermissions && (
|
||||
<img
|
||||
className="absolute -bottom-1 -right-1"
|
||||
width={15}
|
||||
src={edit_avatar}
|
||||
alt="edit avatar"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Upload>
|
||||
|
||||
<EditableContent
|
||||
textClassName="font-medium"
|
||||
value={currentGroupInfo?.groupName}
|
||||
editable={hasPermissions}
|
||||
onChange={updateGroupName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
{currentGroupInfo && isJoinGroup && (
|
||||
<GroupMemberRow
|
||||
currentGroupInfo={currentGroupInfo}
|
||||
isNomal={isNomal}
|
||||
updateTravel={updateTravel}
|
||||
/>
|
||||
)}
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
<SettingRow className="pb-2" title={`${t("placeholder.group")}ID`}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-1 text-xs text-[var(--sub-text)]">
|
||||
{currentGroupInfo?.groupID}
|
||||
</span>
|
||||
<img
|
||||
className="cursor-pointer"
|
||||
width={14}
|
||||
src={copy}
|
||||
alt=""
|
||||
onClick={() => {
|
||||
copyToClipboard(currentGroupInfo?.groupID ?? "");
|
||||
feedbackToast({ msg: t("toast.copySuccess") });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title={t("placeholder.groupTppe")}>
|
||||
<span className="text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.workGroup")}
|
||||
</span>
|
||||
</SettingRow>
|
||||
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
<SettingRow
|
||||
className="cursor-pointer"
|
||||
title={t("placeholder.transferGroup")}
|
||||
rowClick={transferGroup}
|
||||
>
|
||||
<RightOutlined rev={undefined} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
{isJoinGroup && (
|
||||
<div className="flex w-full justify-center pb-3 pt-24">
|
||||
{!isOwner ? (
|
||||
<Button type="primary" danger ghost onClick={tryQuitGroup}>
|
||||
{t("placeholder.exitGroup")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" danger onClick={tryDismissGroup}>
|
||||
{t("placeholder.disbandGroup")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GroupSettings);
|
||||
@@ -0,0 +1,23 @@
|
||||
.member-item {
|
||||
@apply flex flex-col items-center w-9 mr-3 mb-3;
|
||||
|
||||
&:nth-child(9n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-member-item {
|
||||
@apply flex items-center justify-between px-3.5 py-1 rounded-md;
|
||||
|
||||
.tools-row {
|
||||
@apply flex items-center invisible;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-[var(--primary-active)];
|
||||
|
||||
.tools-row {
|
||||
@apply visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Drawer } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { forwardRef, ForwardRefRenderFunction, memo, useRef, useState } from "react";
|
||||
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
|
||||
import GroupMemberList from "./GroupMemberList";
|
||||
import GroupMemberListHeader from "./GroupMemberListHeader";
|
||||
import GroupSettings from "./GroupSettings";
|
||||
|
||||
const GroupSetting: ForwardRefRenderFunction<OverlayVisibleHandle, unknown> = (
|
||||
_,
|
||||
ref,
|
||||
) => {
|
||||
const [isPreviewMembers, setIsPreviewMembers] = useState(false);
|
||||
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
|
||||
const closePreviewMembers = () => {
|
||||
setIsPreviewMembers(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
!isPreviewMembers ? (
|
||||
t("placeholder.setting")
|
||||
) : (
|
||||
<GroupMemberListHeader back2Settings={closePreviewMembers} />
|
||||
)
|
||||
}
|
||||
destroyOnClose
|
||||
placement="right"
|
||||
rootClassName="chat-drawer"
|
||||
onClose={closeOverlay}
|
||||
afterOpenChange={(visible) => {
|
||||
if (!visible) {
|
||||
closePreviewMembers();
|
||||
}
|
||||
}}
|
||||
open={isOverlayOpen}
|
||||
maskClassName="opacity-0"
|
||||
maskMotion={{
|
||||
visible: false,
|
||||
}}
|
||||
width={460}
|
||||
getContainer={"#chat-container"}
|
||||
>
|
||||
{!isPreviewMembers ? (
|
||||
<GroupSettings
|
||||
closeOverlay={closeOverlay}
|
||||
updateTravel={() => setIsPreviewMembers(true)}
|
||||
/>
|
||||
) : (
|
||||
<GroupMemberList />
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(GroupSetting));
|
||||
@@ -0,0 +1,96 @@
|
||||
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { t } from "i18next";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { modal } from "@/AntdGlobalComp";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore } from "@/store";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
export type PermissionField = "applyMemberFriend" | "lookMemberInfo";
|
||||
|
||||
export function useGroupSettings({ closeOverlay }: { closeOverlay: () => void }) {
|
||||
const currentGroupInfo = useConversationStore((state) => state.currentGroupInfo);
|
||||
|
||||
const modalRef = useRef<{
|
||||
destroy: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const updateGroupInfo = useCallback(
|
||||
async (value: Partial<GroupItem>) => {
|
||||
if (!currentGroupInfo) return;
|
||||
try {
|
||||
await IMSDK.setGroupInfo({
|
||||
...value,
|
||||
groupID: currentGroupInfo.groupID,
|
||||
});
|
||||
} catch (error) {
|
||||
feedbackToast({ error, msg: t("toast.updateGroupInfoFailed") });
|
||||
}
|
||||
},
|
||||
[currentGroupInfo?.groupID],
|
||||
);
|
||||
|
||||
const tryDismissGroup = () => {
|
||||
if (!currentGroupInfo || modalRef.current) return;
|
||||
|
||||
modalRef.current = modal.confirm({
|
||||
title: t("placeholder.disbandGroup"),
|
||||
content: (
|
||||
<div className="flex items-baseline">
|
||||
<div>{t("toast.confirmDisbandGroup")}</div>
|
||||
<span className="text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.disbandGroupToast")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await IMSDK.dismissGroup(currentGroupInfo.groupID);
|
||||
closeOverlay();
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
modalRef.current = null;
|
||||
},
|
||||
onCancel: () => {
|
||||
modalRef.current = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const tryQuitGroup = () => {
|
||||
if (!currentGroupInfo || modalRef.current) return;
|
||||
|
||||
modalRef.current = modal.confirm({
|
||||
title: t("placeholder.exitGroup"),
|
||||
content: (
|
||||
<div className="flex items-baseline">
|
||||
<div>{t("toast.confirmExitGroup")}</div>
|
||||
<span className="text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.exitGroupToast")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await IMSDK.quitGroup(currentGroupInfo.groupID);
|
||||
closeOverlay();
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
modalRef.current = null;
|
||||
},
|
||||
onCancel: () => {
|
||||
modalRef.current = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
currentGroupInfo,
|
||||
updateGroupInfo,
|
||||
tryQuitGroup,
|
||||
tryDismissGroup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MessageType } from "@openim/wasm-client-sdk";
|
||||
import { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
import styles from "./message-item.module.scss";
|
||||
|
||||
const CatchMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const text =
|
||||
message.contentType === MessageType.VideoMessage
|
||||
? t("messageDescription.videoNotSupport")
|
||||
: t("messageDescription.catchMessage");
|
||||
|
||||
return <div className={styles.bubble}>{text}</div>;
|
||||
};
|
||||
|
||||
export default CatchMessageRender;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
if (!size && size !== 0) return "";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
const FileIcon = () => (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[var(--primary-active)]">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M6 2.5h8L19 7.5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-17a1 1 0 0 1 1-1Z"
|
||||
stroke="#0073D9"
|
||||
strokeWidth="1.6"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path d="M13.5 2.5v5.5H19" stroke="#0073D9" strokeWidth="1.6" fill="none" />
|
||||
<path
|
||||
d="M8.5 12h7M8.5 15.5h7"
|
||||
stroke="#0073D9"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FileMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const fileElem = message.fileElem;
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!fileElem || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
let url = fileElem.sourceUrl;
|
||||
let revoke = false;
|
||||
if (!url && fileElem.filePath && window.electronAPI) {
|
||||
const file = await window.electronAPI.getFileByPath(fileElem.filePath);
|
||||
if (file) {
|
||||
url = URL.createObjectURL(file);
|
||||
revoke = true;
|
||||
}
|
||||
}
|
||||
if (!url) throw new Error("no file url");
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileElem.fileName || "file";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
if (revoke) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileElem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-60 cursor-pointer items-center rounded-lg bg-white p-3"
|
||||
onClick={saveFile}
|
||||
>
|
||||
<FileIcon />
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<div className="truncate text-sm" title={fileElem.fileName}>
|
||||
{fileElem.fileName}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--sub-text)]">
|
||||
{formatFileSize(fileElem.fileSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileMessageRender;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MessageStatus } from "@openim/wasm-client-sdk";
|
||||
import { Image, Spin } from "antd";
|
||||
import { FC } from "react";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
|
||||
const min = (a: number, b: number) => (a > b ? b : a);
|
||||
|
||||
const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
||||
const imageHeight = message.pictureElem!.sourcePicture.height;
|
||||
const imageWidth = message.pictureElem!.sourcePicture.width;
|
||||
const snapshotMaxHeight = message.pictureElem!.snapshotPicture?.height ?? imageHeight;
|
||||
const minHeight = min(200, imageWidth) * (imageHeight / imageWidth) + 2;
|
||||
const adaptedHight = min(minHeight, snapshotMaxHeight) + 10;
|
||||
const adaptedWidth = min(imageWidth, 200) + 10;
|
||||
|
||||
const sourceUrl =
|
||||
message.pictureElem!.snapshotPicture?.url || message.pictureElem!.sourcePicture.url;
|
||||
const isSending = message.status === MessageStatus.Sending;
|
||||
const minStyle = { minHeight: `${adaptedHight}px`, minWidth: `${adaptedWidth}px` };
|
||||
|
||||
return (
|
||||
<Spin spinning={isSending}>
|
||||
<div className="relative max-w-[200px]" style={minStyle}>
|
||||
<Image
|
||||
rootClassName="message-image cursor-pointer"
|
||||
className="max-w-[200px] rounded-md"
|
||||
src={sourceUrl}
|
||||
preview
|
||||
placeholder={
|
||||
<div style={minStyle} className="flex items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaMessageRender;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MessageItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import CatchMessageRender from "./CatchMsgRenderer";
|
||||
|
||||
type MessageItemErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
message: MessageItem;
|
||||
};
|
||||
|
||||
type MessageItemErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
message: MessageItem;
|
||||
};
|
||||
|
||||
class MessageItemErrorBoundary extends Component<
|
||||
MessageItemErrorBoundaryProps,
|
||||
MessageItemErrorBoundaryState
|
||||
> {
|
||||
constructor(props: MessageItemErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, message: props.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("MessageItemErrorBoundary:::");
|
||||
console.error(this.state.message);
|
||||
console.error(error);
|
||||
|
||||
this.setState({ hasError: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <CatchMessageRender message={this.state.message} isSender={false} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageItemErrorBoundary;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ExclamationCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
||||
import { MessageStatus } from "@openim/wasm-client-sdk";
|
||||
import { Spin, Tooltip } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore } from "@/store";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
import styles from "./message-item.module.scss";
|
||||
import { updateOneMessage } from "../useHistoryMessageList";
|
||||
|
||||
const MessageSuffix: FC<IMessageItemProps> = ({ message }) => {
|
||||
const [showSending, setShowSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (message.status !== MessageStatus.Sending) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (message.status === MessageStatus.Sending) {
|
||||
setShowSending(true);
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [message.status]);
|
||||
|
||||
const resendMessage = async () => {
|
||||
const currentConversation = useConversationStore.getState().currentConversation;
|
||||
updateOneMessage({ ...message, status: MessageStatus.Sending });
|
||||
try {
|
||||
const { data: successMessage } = await IMSDK.sendMessage({
|
||||
recvID: currentConversation?.userID ?? "",
|
||||
groupID: currentConversation?.groupID ?? "",
|
||||
message,
|
||||
});
|
||||
updateOneMessage(successMessage);
|
||||
} catch (error) {
|
||||
updateOneMessage({ ...message, status: MessageStatus.Failed });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.suffix}>
|
||||
{showSending && message.status === MessageStatus.Sending && (
|
||||
<Spin
|
||||
className="flex"
|
||||
indicator={<LoadingOutlined style={{ fontSize: 16 }} spin rev={undefined} />}
|
||||
/>
|
||||
)}
|
||||
{message.status === MessageStatus.Failed && (
|
||||
<Tooltip title={t("messageDescription.resendTip")}>
|
||||
<ExclamationCircleFilled
|
||||
className="cursor-pointer text-base text-[var(--warn-text)]"
|
||||
rev={undefined}
|
||||
onClick={resendMessage}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageSuffix;
|
||||
@@ -0,0 +1,97 @@
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
import styles from "./message-item.module.scss";
|
||||
|
||||
const PLAYED_KEY = "chaglian_played_sounds";
|
||||
|
||||
const getPlayedMap = (): Record<string, boolean> => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PLAYED_KEY) ?? "{}");
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const markPlayed = (clientMsgID: string) => {
|
||||
const map = getPlayedMap();
|
||||
map[clientMsgID] = true;
|
||||
localStorage.setItem(PLAYED_KEY, JSON.stringify(map));
|
||||
};
|
||||
|
||||
const SoundIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path
|
||||
d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15 9.2a4 4 0 0 1 0 5.6M17.5 6.8a7.4 7.4 0 0 1 0 10.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SoundMessageRender: FC<IMessageItemProps> = ({ message, isSender }) => {
|
||||
const soundElem = message.soundElem;
|
||||
const duration = Math.max(1, Math.round(soundElem?.duration ?? 0));
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [played, setPlayed] = useState(
|
||||
() => Boolean(getPlayedMap()[message.clientMsgID]),
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 宽度随时长微变:60~180px
|
||||
const width = Math.min(180, 60 + duration * 6);
|
||||
|
||||
const play = () => {
|
||||
const url = soundElem?.sourceUrl || soundElem?.soundPath;
|
||||
if (!url || playing) return;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
setPlaying(true);
|
||||
audio.onended = () => setPlaying(false);
|
||||
audio.onerror = () => {
|
||||
setPlaying(false);
|
||||
feedbackToast({ msg: t("toast.accessFailed"), error: new Error("audio error") });
|
||||
};
|
||||
audio.play().catch(() => setPlaying(false));
|
||||
markPlayed(message.clientMsgID);
|
||||
setPlayed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
styles.bubble,
|
||||
"flex cursor-pointer items-center justify-between text-sm",
|
||||
isSender && "flex-row-reverse",
|
||||
)}
|
||||
style={{ width }}
|
||||
onClick={play}
|
||||
>
|
||||
<SoundIcon className="shrink-0" />
|
||||
<span className={clsx(isSender ? "mr-2" : "ml-2")}>{duration}″</span>
|
||||
</div>
|
||||
{!isSender && !played && (
|
||||
<span className="ml-2 h-2 w-2 shrink-0 rounded-full bg-[#F4493C]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoundMessageRender;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { formatBr } from "@/utils/common";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
import styles from "./message-item.module.scss";
|
||||
|
||||
const TextMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
||||
let content = message.textElem?.content;
|
||||
|
||||
content = formatBr(content!);
|
||||
|
||||
return (
|
||||
<div className={styles.bubble} dangerouslySetInnerHTML={{ __html: content }}></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextMessageRender;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { MessageItem as MessageItemType, MessageType } from "@openim/wasm-client-sdk";
|
||||
import clsx from "clsx";
|
||||
import { FC, memo } from "react";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
|
||||
import CatchMessageRender from "./CatchMsgRenderer";
|
||||
import FileMessageRender from "./FileMessageRender";
|
||||
import MediaMessageRender from "./MediaMessageRender";
|
||||
import styles from "./message-item.module.scss";
|
||||
import MessageItemErrorBoundary from "./MessageItemErrorBoundary";
|
||||
import MessageSuffix from "./MessageSuffix";
|
||||
import SoundMessageRender from "./SoundMessageRender";
|
||||
import TextMessageRender from "./TextMessageRender";
|
||||
|
||||
export interface IMessageItemProps {
|
||||
message: MessageItemType;
|
||||
isSender: boolean;
|
||||
disabled?: boolean;
|
||||
conversationID?: string;
|
||||
messageUpdateFlag?: string;
|
||||
}
|
||||
|
||||
const components: Record<number, FC<IMessageItemProps>> = {
|
||||
[MessageType.TextMessage]: TextMessageRender,
|
||||
[MessageType.PictureMessage]: MediaMessageRender,
|
||||
[MessageType.FileMessage]: FileMessageRender,
|
||||
[MessageType.VoiceMessage]: SoundMessageRender,
|
||||
};
|
||||
|
||||
const MessageItem: FC<IMessageItemProps> = ({
|
||||
message,
|
||||
disabled,
|
||||
isSender,
|
||||
conversationID,
|
||||
}) => {
|
||||
const MessageRenderComponent = components[message.contentType] || CatchMessageRender;
|
||||
const isGroupMessage = Boolean(message.groupID);
|
||||
const showNickname = isGroupMessage && !isSender;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`chat_${message.clientMsgID}`}
|
||||
className={clsx("relative flex select-text px-5 py-2")}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
styles["message-container"],
|
||||
isSender && styles["message-container-sender"],
|
||||
)}
|
||||
>
|
||||
<OIMAvatar
|
||||
className="rounded-lg"
|
||||
size={40}
|
||||
src={message.senderFaceUrl}
|
||||
text={message.senderNickname}
|
||||
/>
|
||||
|
||||
<div className={styles["message-wrap"]}>
|
||||
{showNickname && (
|
||||
<div className={styles["message-profile"]}>
|
||||
<div
|
||||
title={message.senderNickname}
|
||||
className="max-w-[60%] truncate text-xs text-[var(--sub-text)]"
|
||||
>
|
||||
{message.senderNickname}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles["menu-wrap"]}>
|
||||
<MessageItemErrorBoundary message={message}>
|
||||
<MessageRenderComponent
|
||||
message={message}
|
||||
isSender={isSender}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</MessageItemErrorBoundary>
|
||||
|
||||
<MessageSuffix
|
||||
message={message}
|
||||
isSender={isSender}
|
||||
disabled={false}
|
||||
conversationID={conversationID}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MessageItem);
|
||||
@@ -0,0 +1,73 @@
|
||||
.message-container {
|
||||
@apply flex flex-1 overflow-hidden;
|
||||
|
||||
.message-wrap {
|
||||
@apply mx-3 flex flex-1 flex-col overflow-hidden;
|
||||
|
||||
.message-profile {
|
||||
@apply mb-1 flex w-full text-xs;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@apply w-fit rounded-xl p-2.5;
|
||||
word-break: break-word;
|
||||
background-color: var(--chat-bubble);
|
||||
}
|
||||
|
||||
.suffix {
|
||||
@apply ml-3 flex items-center;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
@apply flex w-fit items-center;
|
||||
}
|
||||
|
||||
&-sender {
|
||||
@apply flex-row-reverse;
|
||||
|
||||
.message-wrap {
|
||||
@apply items-end;
|
||||
|
||||
.message-profile {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background-color: var(--chat-bubble-sender);
|
||||
}
|
||||
|
||||
.suffix {
|
||||
@apply ml-0 mr-3;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.animate-container {
|
||||
// background-color: var(--primary-active);
|
||||
animation: animate 2s ease-in-out;
|
||||
}
|
||||
@keyframes animate {
|
||||
from {
|
||||
background-color: var(--primary-active);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
@apply w-60 cursor-pointer overflow-hidden rounded-md shadow-md;
|
||||
box-shadow: 3px 3px 8px 1px rgba(81, 94, 112, 0.1);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MessageItem } from "@openim/wasm-client-sdk";
|
||||
import clsx from "clsx";
|
||||
import { FC, memo, useRef } from "react";
|
||||
|
||||
import { notificationMessageFormat } from "@/utils/imCommon";
|
||||
|
||||
const NotificationMessage: FC<{
|
||||
message: MessageItem;
|
||||
}> = ({ message }) => {
|
||||
const messageWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="relative" id={`chat_${message.clientMsgID}`}>
|
||||
<div
|
||||
ref={messageWrapRef}
|
||||
className={clsx("mx-6 py-3 text-center text-xs text-[var(--sub-text)]")}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(notificationMessageFormat(message)),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NotificationMessage);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { RightOutlined } from "@ant-design/icons";
|
||||
import { Button, Divider, Drawer } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { forwardRef, ForwardRefRenderFunction, memo } from "react";
|
||||
|
||||
import { modal } from "@/AntdGlobalComp";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import SettingRow from "@/components/SettingRow";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useContactStore } from "@/store/contact";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
import { emit } from "@/utils/events";
|
||||
import { useConversationStore } from "@/store";
|
||||
|
||||
// export interface SingleSettingProps {}
|
||||
|
||||
const SingleSetting: ForwardRefRenderFunction<OverlayVisibleHandle, unknown> = (
|
||||
_,
|
||||
ref,
|
||||
) => {
|
||||
const currentConversation = useConversationStore(
|
||||
(state) => state.currentConversation,
|
||||
);
|
||||
|
||||
const isBlack = useContactStore((state) => state.blackList).some(
|
||||
(black) => currentConversation?.userID === black.userID,
|
||||
);
|
||||
const isFriend = useContactStore((state) => state.friendList).some(
|
||||
(friend) => currentConversation?.userID === friend.userID,
|
||||
);
|
||||
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
|
||||
const updateBlack = async () => {
|
||||
if (!currentConversation) return;
|
||||
const execFunc = async () => {
|
||||
try {
|
||||
isBlack
|
||||
? await IMSDK.removeBlack(currentConversation?.userID)
|
||||
: await IMSDK.addBlack({
|
||||
toUserID: currentConversation?.userID,
|
||||
});
|
||||
} catch (error) {
|
||||
feedbackToast({ error, msg: t("toast.updateBlackStateFailed") });
|
||||
}
|
||||
};
|
||||
if (!isBlack) {
|
||||
modal.confirm({
|
||||
title: t("placeholder.moveBlacklist"),
|
||||
content: (
|
||||
<div className="flex items-baseline">
|
||||
<div>{t("toast.confirmMoveBlacklist")}</div>
|
||||
<span className="text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.willFilterThisUserMessage")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
onOk: execFunc,
|
||||
});
|
||||
} else {
|
||||
await execFunc();
|
||||
}
|
||||
};
|
||||
|
||||
const tryUnfriend = () => {
|
||||
if (!currentConversation) return;
|
||||
modal.confirm({
|
||||
title: t("placeholder.unfriend"),
|
||||
content: t("toast.confirmUnfriend"),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await IMSDK.deleteFriend(currentConversation.userID);
|
||||
} catch (error) {
|
||||
feedbackToast({ error, msg: t("toast.unfriendFailed") });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openUserCard = () => {
|
||||
emit("OPEN_USER_CARD", { userID: currentConversation?.userID });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={t("placeholder.setting")}
|
||||
placement="right"
|
||||
rootClassName="chat-drawer"
|
||||
destroyOnClose
|
||||
onClose={closeOverlay}
|
||||
open={isOverlayOpen}
|
||||
maskClassName="opacity-0"
|
||||
maskMotion={{
|
||||
visible: false,
|
||||
}}
|
||||
width={450}
|
||||
getContainer={"#chat-container"}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-4"
|
||||
onClick={openUserCard}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<OIMAvatar
|
||||
src={currentConversation?.faceURL}
|
||||
text={currentConversation?.showName}
|
||||
/>
|
||||
<div className="ml-3">{currentConversation?.showName}</div>
|
||||
</div>
|
||||
<RightOutlined rev={undefined} />
|
||||
</div>
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
<SettingRow
|
||||
title={t("placeholder.moveBlacklist")}
|
||||
value={isBlack}
|
||||
tryChange={updateBlack}
|
||||
/>
|
||||
<Divider className="m-0 border-4 border-[#F4F5F7]" />
|
||||
|
||||
<div className="flex-1" />
|
||||
{isFriend && (
|
||||
<div className="flex w-full justify-center pb-3 pt-24">
|
||||
<Button type="primary" danger onClick={tryUnfriend}>
|
||||
{t("placeholder.unfriend")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(SingleSetting));
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Layout } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { useUnmount } from "ahooks";
|
||||
import { DragEvent, useRef, useState } from "react";
|
||||
|
||||
import { useConversationStore } from "@/store";
|
||||
import { canSendImageTypeList, feedbackToast } from "@/utils/common";
|
||||
|
||||
import ChatContent from "./ChatContent";
|
||||
import ChatFooter from "./ChatFooter";
|
||||
import { useFileMessage } from "./ChatFooter/SendActionBar/useFileMessage";
|
||||
import ChatHeader from "./ChatHeader";
|
||||
import useConversationState from "./useConversationState";
|
||||
import { useSendMessage } from "./ChatFooter/useSendMessage";
|
||||
|
||||
export const QueryChat = () => {
|
||||
const [dropVisible, setDropVisible] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
const updateCurrentConversation = useConversationStore(
|
||||
(state) => state.updateCurrentConversation,
|
||||
);
|
||||
|
||||
const { getImageMessage, getFileMessage } = useFileMessage();
|
||||
const { sendMessage } = useSendMessage();
|
||||
|
||||
useConversationState();
|
||||
|
||||
useUnmount(() => {
|
||||
updateCurrentConversation();
|
||||
});
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDropVisible(true);
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onDragLeave = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
dragDepth.current -= 1;
|
||||
if (dragDepth.current <= 0) {
|
||||
dragDepth.current = 0;
|
||||
setDropVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDropVisible(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
for (const file of files) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const isImage =
|
||||
file.type.startsWith("image/") || canSendImageTypeList.includes(ext);
|
||||
try {
|
||||
const message = isImage
|
||||
? await getImageMessage(file)
|
||||
: await getFileMessage(file);
|
||||
sendMessage({ message });
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
id="chat-container"
|
||||
className="relative overflow-hidden"
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ChatHeader />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatContent />
|
||||
</div>
|
||||
<ChatFooter />
|
||||
{dropVisible && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-[rgba(0,115,217,0.12)]">
|
||||
<div className="rounded-lg bg-white px-8 py-4 text-sm shadow-lg">
|
||||
{t("placeholder.dropToSend")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useLatest, useThrottleFn, useUpdateEffect } from "ahooks";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore, useUserStore } from "@/store";
|
||||
|
||||
export default function useConversationState() {
|
||||
const syncState = useUserStore((state) => state.syncState);
|
||||
const latestSyncState = useLatest(syncState);
|
||||
const currentConversation = useConversationStore(
|
||||
(state) => state.currentConversation,
|
||||
);
|
||||
const latestCurrentConversation = useLatest(currentConversation);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (syncState !== "loading") {
|
||||
checkConversationState();
|
||||
}
|
||||
}, [syncState]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
throttleCheckConversationState();
|
||||
}, [currentConversation?.unreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
checkConversationState();
|
||||
}, [currentConversation?.conversationID]);
|
||||
|
||||
const checkConversationState = () => {
|
||||
if (
|
||||
!latestCurrentConversation.current ||
|
||||
latestSyncState.current === "loading"
|
||||
)
|
||||
return;
|
||||
|
||||
if (latestCurrentConversation.current.unreadCount > 0) {
|
||||
IMSDK.markConversationMessageAsRead(
|
||||
latestCurrentConversation.current.conversationID,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const { run: throttleCheckConversationState } = useThrottleFn(
|
||||
checkConversationState,
|
||||
{ wait: 2000, leading: false },
|
||||
);
|
||||
|
||||
return {
|
||||
currentConversation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { MessageItem, ViewType } from "@openim/wasm-client-sdk";
|
||||
import { useLatest, useRequest } from "ahooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import emitter, { emit } from "@/utils/events";
|
||||
|
||||
const START_INDEX = 10000;
|
||||
const SPLIT_COUNT = 20;
|
||||
|
||||
export function useHistoryMessageList() {
|
||||
const { conversationID } = useParams();
|
||||
const [loadState, setLoadState] = useState({
|
||||
initLoading: true,
|
||||
hasMoreOld: true,
|
||||
messageList: [] as MessageItem[],
|
||||
firstItemIndex: START_INDEX,
|
||||
});
|
||||
const latestLoadState = useLatest(loadState);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistoryMessages();
|
||||
return () => {
|
||||
setLoadState(() => ({
|
||||
initLoading: true,
|
||||
hasMoreOld: true,
|
||||
messageList: [] as MessageItem[],
|
||||
firstItemIndex: START_INDEX,
|
||||
}));
|
||||
};
|
||||
}, [conversationID]);
|
||||
|
||||
useEffect(() => {
|
||||
const pushNewMessage = (message: MessageItem) => {
|
||||
if (
|
||||
latestLoadState.current.messageList.find(
|
||||
(item) => item.clientMsgID === message.clientMsgID,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLoadState((preState) => ({
|
||||
...preState,
|
||||
messageList: [...preState.messageList, message],
|
||||
}));
|
||||
};
|
||||
const updateOneMessage = (message: MessageItem) => {
|
||||
setLoadState((preState) => {
|
||||
const tmpList = [...preState.messageList];
|
||||
const idx = tmpList.findIndex((msg) => msg.clientMsgID === message.clientMsgID);
|
||||
if (idx < 0) {
|
||||
return preState;
|
||||
}
|
||||
|
||||
tmpList[idx] = { ...tmpList[idx], ...message };
|
||||
return {
|
||||
...preState,
|
||||
messageList: tmpList,
|
||||
};
|
||||
});
|
||||
};
|
||||
emitter.on("PUSH_NEW_MSG", pushNewMessage);
|
||||
emitter.on("UPDATE_ONE_MSG", updateOneMessage);
|
||||
return () => {
|
||||
emitter.off("PUSH_NEW_MSG", pushNewMessage);
|
||||
emitter.off("UPDATE_ONE_MSG", updateOneMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadHistoryMessages = () => getMoreOldMessages(false).catch(() => undefined);
|
||||
|
||||
const {
|
||||
loading: moreOldLoading,
|
||||
runAsync: getMoreOldMessages,
|
||||
error: loadError,
|
||||
} = useRequest(
|
||||
async (loadMore = true) => {
|
||||
const reqConversationID = conversationID;
|
||||
const { data } = await IMSDK.getAdvancedHistoryMessageList({
|
||||
count: SPLIT_COUNT,
|
||||
startClientMsgID: loadMore
|
||||
? latestLoadState.current?.messageList[0]?.clientMsgID ?? ""
|
||||
: "",
|
||||
conversationID: conversationID ?? "",
|
||||
viewType: ViewType.History,
|
||||
});
|
||||
if (conversationID !== reqConversationID) return;
|
||||
setTimeout(() =>
|
||||
setLoadState((preState) => ({
|
||||
...preState,
|
||||
initLoading: false,
|
||||
hasMoreOld: !data.isEnd,
|
||||
messageList: [...data.messageList, ...(loadMore ? preState.messageList : [])],
|
||||
firstItemIndex: preState.firstItemIndex - data.messageList.length,
|
||||
})),
|
||||
);
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
onError: () => {
|
||||
setLoadState((preState) => ({ ...preState, initLoading: false }));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
SPLIT_COUNT,
|
||||
loadState,
|
||||
latestLoadState,
|
||||
conversationID,
|
||||
moreOldLoading,
|
||||
getMoreOldMessages,
|
||||
loadError: Boolean(loadError) && loadState.messageList.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const pushNewMessage = (message: MessageItem) => emit("PUSH_NEW_MSG", message);
|
||||
export const updateOneMessage = (message: MessageItem) =>
|
||||
emit("UPDATE_ONE_MSG", message);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CloseOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { SessionType } from "@openim/wasm-client-sdk";
|
||||
import {
|
||||
ConversationItem,
|
||||
FriendUserItem,
|
||||
GroupItem,
|
||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Checkbox } from "antd";
|
||||
import clsx from "clsx";
|
||||
import { FC, memo } from "react";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
|
||||
interface ICheckItemProps {
|
||||
data: CheckListItem;
|
||||
isChecked?: boolean;
|
||||
showCheck?: boolean;
|
||||
disabled?: boolean;
|
||||
itemClick?: (data: CheckListItem) => void;
|
||||
cancelClick?: (data: CheckListItem) => void;
|
||||
}
|
||||
|
||||
export type CheckListItem = Partial<
|
||||
FriendUserItem & ConversationItem & GroupItem & { disabled?: boolean }
|
||||
>;
|
||||
|
||||
const CheckItem: FC<ICheckItemProps> = (props) => {
|
||||
const { data, isChecked, showCheck, disabled, itemClick, cancelClick } = props;
|
||||
const showName = data.remark || data.nickname || data.groupName || data.showName;
|
||||
const isDisabled = disabled ?? data.disabled;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"mx-2 flex items-center justify-between rounded-md px-3.5 py-2.5 hover:bg-[var(--primary-active)]",
|
||||
{ "cursor-pointer": showCheck },
|
||||
)}
|
||||
onClick={() => !isDisabled && itemClick?.(data)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{showCheck && (
|
||||
<Checkbox className="mr-3" checked={isChecked} disabled={isDisabled} />
|
||||
)}
|
||||
<OIMAvatar
|
||||
src={data.faceURL}
|
||||
text={showName}
|
||||
isgroup={
|
||||
Boolean(data.groupName) ||
|
||||
data.conversationType === SessionType.WorkingGroup
|
||||
}
|
||||
/>
|
||||
<div className="ml-3 max-w-[120px] truncate">{showName}</div>
|
||||
</div>
|
||||
{showCheck ? (
|
||||
<RightOutlined
|
||||
className="cursor-pointer text-[var(--sub-text)]"
|
||||
rev={undefined}
|
||||
/>
|
||||
) : (
|
||||
<CloseOutlined
|
||||
className="cursor-pointer text-[var(--sub-text)]"
|
||||
rev={undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelClick?.(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CheckItem);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RightOutlined } from "@ant-design/icons";
|
||||
|
||||
import { ChooseMenuItem } from ".";
|
||||
|
||||
const MenuItem = ({
|
||||
menu,
|
||||
menuClick,
|
||||
}: {
|
||||
menu: ChooseMenuItem;
|
||||
menuClick: (idx: number) => void;
|
||||
}) => (
|
||||
<div
|
||||
className="mx-2 flex items-center justify-between rounded-md px-3.5 py-2.5 hover:bg-[var(--primary-active)]"
|
||||
key={menu.idx}
|
||||
onClick={() => menuClick(menu.idx)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<img width={42} src={menu.icon} alt="" />
|
||||
<div className="ml-3.5">{menu.title}</div>
|
||||
</div>
|
||||
<RightOutlined className="text-[var(--sub-text)]" rev={undefined} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,336 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { SessionType } from "@openim/wasm-client-sdk";
|
||||
import { GroupMemberItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { useDebounceFn, useLatest } from "ahooks";
|
||||
import { Breadcrumb, Input, Spin } from "antd";
|
||||
import { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
|
||||
import clsx from "clsx";
|
||||
import i18n, { t } from "i18next";
|
||||
import {
|
||||
ChangeEvent,
|
||||
FC,
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
import friend from "@/assets/images/chooseModal/friend.png";
|
||||
import group from "@/assets/images/chooseModal/group.png";
|
||||
import recently from "@/assets/images/chooseModal/recently.png";
|
||||
import { useCurrentMemberRole } from "@/hooks/useCurrentMemberRole";
|
||||
import useGroupMembers from "@/hooks/useGroupMembers";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useConversationStore } from "@/store";
|
||||
import { useContactStore } from "@/store/contact";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import CheckItem, { CheckListItem } from "./CheckItem";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
const menuList = [
|
||||
{
|
||||
idx: 0,
|
||||
title: t("placeholder.myFriend"),
|
||||
icon: friend,
|
||||
},
|
||||
];
|
||||
|
||||
i18n.on("languageChanged", () => {
|
||||
menuList[0].title = t("placeholder.myFriend");
|
||||
});
|
||||
|
||||
export type ChooseMenuItem = (typeof menuList)[0];
|
||||
|
||||
interface IChooseBoxProps {
|
||||
className?: string;
|
||||
isCheckInGroup?: boolean;
|
||||
showGroupMember?: boolean;
|
||||
chooseOneOnly?: boolean;
|
||||
checkMemberRole?: boolean;
|
||||
}
|
||||
|
||||
export interface ChooseBoxHandle {
|
||||
getCheckedList: () => CheckListItem[];
|
||||
updatePrevCheckList: (data: CheckListItem[]) => void;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
const ChooseBox: ForwardRefRenderFunction<ChooseBoxHandle, IChooseBoxProps> = (
|
||||
props,
|
||||
ref,
|
||||
) => {
|
||||
const { className, isCheckInGroup, showGroupMember, chooseOneOnly, checkMemberRole } =
|
||||
props;
|
||||
|
||||
const [checkedList, setCheckedList] = useState<CheckListItem[]>([]);
|
||||
const latestCheckedList = useLatest(checkedList);
|
||||
|
||||
const checkClick = useCallback(
|
||||
(data: CheckListItem) => {
|
||||
const idx = latestCheckedList.current.findIndex(
|
||||
(item) =>
|
||||
(item.userID && item.userID === data.userID) ||
|
||||
(item.groupID && item.groupID === data.groupID && !showGroupMember),
|
||||
);
|
||||
if (idx > -1) {
|
||||
setCheckedList((state) => {
|
||||
const newState = [...state];
|
||||
newState.splice(idx, 1);
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
if (chooseOneOnly && latestCheckedList.current.length > 0) {
|
||||
feedbackToast({
|
||||
msg: t("toast.beyondSelectionLimit"),
|
||||
error: t("toast.beyondSelectionLimit"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckedList((state) => [...state, data]);
|
||||
}
|
||||
},
|
||||
[chooseOneOnly],
|
||||
);
|
||||
|
||||
const isChecked = useCallback(
|
||||
(data: CheckListItem) =>
|
||||
checkedList.some(
|
||||
(item) =>
|
||||
(item.userID && item.userID === data.userID) ||
|
||||
(item.groupID && item.groupID === data.groupID && !showGroupMember),
|
||||
),
|
||||
[checkedList.length, showGroupMember],
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setCheckedList([]);
|
||||
};
|
||||
|
||||
const updatePrevCheckList = (data: CheckListItem[]) => {
|
||||
setCheckedList([...data]);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCheckedList: () => checkedList,
|
||||
resetState,
|
||||
updatePrevCheckList,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"mx-9 mt-5 flex h-[480px] rounded-md border border-[var(--gap-text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col border-r border-[var(--gap-text)]">
|
||||
<div className="py-3 pb-3" />
|
||||
|
||||
{showGroupMember ? (
|
||||
<ForwardMemberList
|
||||
isChecked={isChecked}
|
||||
checkClick={checkClick}
|
||||
checkMemberRole={checkMemberRole}
|
||||
/>
|
||||
) : (
|
||||
<ForwardCommonLeft
|
||||
isCheckInGroup={isCheckInGroup!}
|
||||
isChecked={isChecked}
|
||||
checkClick={checkClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="mx-5 py-5.5">
|
||||
{t("placeholder.selected")}
|
||||
<span className="text-[var(--primary)]">{` ${checkedList.length} `}</span>
|
||||
</div>
|
||||
<div className="mb-3 flex-1 overflow-y-auto">
|
||||
{checkedList.map((item) => (
|
||||
<CheckItem
|
||||
data={item}
|
||||
key={item.userID || item.groupID}
|
||||
cancelClick={checkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(ChooseBox));
|
||||
|
||||
interface ICommonLeftProps {
|
||||
isCheckInGroup: boolean;
|
||||
checkClick: (data: CheckListItem) => void;
|
||||
isChecked: (data: CheckListItem) => boolean;
|
||||
}
|
||||
|
||||
const CommonLeft: FC<ICommonLeftProps> = ({
|
||||
isCheckInGroup,
|
||||
checkClick,
|
||||
isChecked,
|
||||
}) => {
|
||||
const [breadcrumb, setBreadcrumb] = useState<BreadcrumbItemType[]>([]);
|
||||
const [checkList, setCheckList] = useState<CheckListItem[]>([]);
|
||||
|
||||
const breadcrumbClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
setBreadcrumb([]);
|
||||
};
|
||||
|
||||
const checkInGroup = async (list: CheckListItem[]) => {
|
||||
const currentGroupID = useConversationStore.getState().currentConversation?.groupID;
|
||||
if (!isCheckInGroup || !currentGroupID) {
|
||||
return list;
|
||||
}
|
||||
const tmpList = JSON.parse(JSON.stringify(list)) as CheckListItem[];
|
||||
const userIDList = tmpList
|
||||
.filter((item) => Boolean(item.userID))
|
||||
.map((item) => item.userID!);
|
||||
try {
|
||||
const { data } = await IMSDK.getUsersInGroup({
|
||||
groupID: currentGroupID,
|
||||
userIDList,
|
||||
});
|
||||
tmpList.map((item) => {
|
||||
item.disabled = data.includes(item.userID!);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return tmpList;
|
||||
};
|
||||
|
||||
const menuClick = useCallback(async (idx: number) => {
|
||||
const pushItem = {
|
||||
title: "",
|
||||
className: "text-xs text-[var(--primary)]",
|
||||
};
|
||||
switch (idx) {
|
||||
case 0:
|
||||
setCheckList(await checkInGroup(useContactStore.getState().friendList));
|
||||
pushItem.title = t("placeholder.myFriend");
|
||||
break;
|
||||
case 1:
|
||||
setCheckList(await checkInGroup(useContactStore.getState().groupList));
|
||||
pushItem.title = t("placeholder.myGroup");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setBreadcrumb((state) => [...state, pushItem]);
|
||||
}, []);
|
||||
|
||||
if (breadcrumb.length < 1) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{menuList.map((menu) => (
|
||||
<MenuItem menu={menu} key={menu.idx} menuClick={menuClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Breadcrumb
|
||||
className="mx-5.5"
|
||||
separator=">"
|
||||
items={[
|
||||
{
|
||||
title: t("placeholder.contacts"),
|
||||
href: "",
|
||||
className: "text-xs text-[var(--sub-text)]",
|
||||
onClick: breadcrumbClick,
|
||||
},
|
||||
...breadcrumb,
|
||||
]}
|
||||
/>
|
||||
<div className="mb-3 flex-1 overflow-y-auto">
|
||||
<Virtuoso
|
||||
className="h-full"
|
||||
data={checkList}
|
||||
itemContent={(_, item) => (
|
||||
<CheckItem
|
||||
showCheck
|
||||
isChecked={isChecked(item)}
|
||||
data={item}
|
||||
key={item.userID || item.groupID}
|
||||
itemClick={checkClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ForwardCommonLeft = memo(CommonLeft);
|
||||
|
||||
interface IGroupMemberListProps {
|
||||
checkMemberRole?: boolean;
|
||||
checkClick: (data: CheckListItem) => void;
|
||||
isChecked: (data: CheckListItem) => boolean;
|
||||
}
|
||||
|
||||
const GroupMemberList: FC<IGroupMemberListProps> = ({
|
||||
checkMemberRole,
|
||||
checkClick,
|
||||
isChecked,
|
||||
}) => {
|
||||
const { currentRolevel, currentMemberInGroup } = useCurrentMemberRole();
|
||||
const { fetchState, getMemberData, resetState } = useGroupMembers();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMemberInGroup?.groupID) {
|
||||
getMemberData(true);
|
||||
}
|
||||
return () => {
|
||||
resetState();
|
||||
};
|
||||
}, [currentMemberInGroup?.groupID]);
|
||||
|
||||
const endReached = () => {
|
||||
if (fetchState.loading || !fetchState.hasMore) {
|
||||
return;
|
||||
}
|
||||
getMemberData();
|
||||
};
|
||||
|
||||
const isDisabled = (member: GroupMemberItem) => {
|
||||
if (member.userID === currentMemberInGroup?.userID) return true;
|
||||
if (!checkMemberRole) return false;
|
||||
return member.roleLevel >= currentRolevel;
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin wrapperClassName="h-full" spinning={fetchState.loading}>
|
||||
<Virtuoso
|
||||
className="h-full overflow-x-hidden"
|
||||
data={fetchState.groupMemberList}
|
||||
fixedItemHeight={62}
|
||||
endReached={endReached}
|
||||
itemContent={(_, member) => (
|
||||
<CheckItem
|
||||
showCheck
|
||||
isChecked={isChecked(member)}
|
||||
disabled={isDisabled(member)}
|
||||
data={member}
|
||||
itemClick={checkClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
const ForwardMemberList = memo(GroupMemberList);
|
||||
@@ -0,0 +1,309 @@
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import { GroupType, SessionType } from "@openim/wasm-client-sdk";
|
||||
import { Button, Input, Modal, Upload } from "antd";
|
||||
import clsx from "clsx";
|
||||
import i18n, { t } from "i18next";
|
||||
import {
|
||||
FC,
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { message } from "@/AntdGlobalComp";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { useConversationToggle } from "@/hooks/useConversationToggle";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { FileWithPath } from "@/pages/chat/queryChat/ChatFooter/SendActionBar/useFileMessage";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
import { emit } from "@/utils/events";
|
||||
import { uploadFile } from "@/utils/imCommon";
|
||||
|
||||
import ChooseBox, { ChooseBoxHandle } from "./ChooseBox";
|
||||
import { CheckListItem } from "./ChooseBox/CheckItem";
|
||||
|
||||
export type ChooseModalType =
|
||||
| "CRATE_GROUP"
|
||||
| "INVITE_TO_GROUP"
|
||||
| "KICK_FORM_GROUP"
|
||||
| "TRANSFER_IN_GROUP"
|
||||
| "SELECT_USER";
|
||||
|
||||
export interface SelectUserExtraData {
|
||||
notConversation: boolean;
|
||||
list: CheckListItem[];
|
||||
}
|
||||
|
||||
export interface ChooseModalState {
|
||||
type: ChooseModalType;
|
||||
extraData?: unknown;
|
||||
}
|
||||
|
||||
interface IChooseModalProps {
|
||||
state: ChooseModalState;
|
||||
}
|
||||
|
||||
const titleMap = {
|
||||
CRATE_GROUP: t("placeholder.createGroup"),
|
||||
INVITE_TO_GROUP: t("placeholder.invitation"),
|
||||
KICK_FORM_GROUP: t("placeholder.kickMember"),
|
||||
TRANSFER_IN_GROUP: t("placeholder.transferGroup"),
|
||||
SELECT_USER: t("placeholder.selectUser"),
|
||||
};
|
||||
|
||||
i18n.on("languageChanged", () => {
|
||||
titleMap.CRATE_GROUP = t("placeholder.createGroup");
|
||||
titleMap.INVITE_TO_GROUP = t("placeholder.invitation");
|
||||
titleMap.KICK_FORM_GROUP = t("placeholder.kickMember");
|
||||
titleMap.TRANSFER_IN_GROUP = t("placeholder.transferGroup");
|
||||
titleMap.SELECT_USER = t("placeholder.selectUser");
|
||||
});
|
||||
|
||||
const onlyOneTypes = ["TRANSFER_IN_GROUP"];
|
||||
const onlyMemberTypes = ["KICK_FORM_GROUP", "TRANSFER_IN_GROUP"];
|
||||
|
||||
const ChooseModal: ForwardRefRenderFunction<OverlayVisibleHandle, IChooseModalProps> = (
|
||||
{ state: { type, extraData } },
|
||||
ref,
|
||||
) => {
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
footer={null}
|
||||
centered
|
||||
open={isOverlayOpen}
|
||||
closable={false}
|
||||
width={680}
|
||||
onCancel={closeOverlay}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
mask: {
|
||||
opacity: 0,
|
||||
transition: "none",
|
||||
},
|
||||
}}
|
||||
className="no-padding-modal max-w-[80vw]"
|
||||
maskTransitionName=""
|
||||
>
|
||||
<ChooseContact
|
||||
isOverlayOpen={isOverlayOpen}
|
||||
type={type}
|
||||
extraData={extraData}
|
||||
closeOverlay={closeOverlay}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(ChooseModal));
|
||||
|
||||
type ChooseContactProps = {
|
||||
isOverlayOpen: boolean;
|
||||
type: ChooseModalType;
|
||||
extraData?: unknown;
|
||||
closeOverlay: () => void;
|
||||
};
|
||||
|
||||
export const ChooseContact: FC<ChooseContactProps> = ({
|
||||
isOverlayOpen,
|
||||
type,
|
||||
extraData,
|
||||
closeOverlay,
|
||||
}) => {
|
||||
const chooseBoxRef = useRef<ChooseBoxHandle>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupBaseInfo, setGroupBaseInfo] = useState({
|
||||
groupName: "",
|
||||
groupAvatar: "",
|
||||
});
|
||||
|
||||
const { toSpecifiedConversation } = useConversationToggle();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOverlayOpen && type === "CRATE_GROUP" && extraData) {
|
||||
setTimeout(
|
||||
() => chooseBoxRef.current?.updatePrevCheckList(extraData as CheckListItem[]),
|
||||
100,
|
||||
);
|
||||
}
|
||||
if (isOverlayOpen && type === "SELECT_USER" && extraData) {
|
||||
setTimeout(
|
||||
() =>
|
||||
chooseBoxRef.current?.updatePrevCheckList(
|
||||
(extraData as SelectUserExtraData).list,
|
||||
),
|
||||
100,
|
||||
);
|
||||
}
|
||||
if (!isOverlayOpen) resetState();
|
||||
}, [isOverlayOpen]);
|
||||
|
||||
const confirmChoose = async () => {
|
||||
const choosedList = chooseBoxRef.current?.getCheckedList() ?? [];
|
||||
if (!choosedList?.length && type !== "SELECT_USER")
|
||||
return message.warning(t("toast.selectLeastOne"));
|
||||
|
||||
if (!groupBaseInfo.groupName.trim() && type === "CRATE_GROUP")
|
||||
return message.warning(t("toast.inputGroupName"));
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
switch (type) {
|
||||
case "CRATE_GROUP":
|
||||
if (choosedList.length === 1) {
|
||||
toSpecifiedConversation({
|
||||
sourceID: choosedList[0].userID!,
|
||||
sessionType: SessionType.Single,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await IMSDK.createGroup({
|
||||
groupInfo: {
|
||||
groupType: GroupType.WorkingGroup,
|
||||
groupName: groupBaseInfo.groupName,
|
||||
faceURL: groupBaseInfo.groupAvatar,
|
||||
},
|
||||
memberUserIDs: choosedList.map((item) => item.userID!),
|
||||
adminUserIDs: [],
|
||||
});
|
||||
break;
|
||||
case "INVITE_TO_GROUP":
|
||||
await IMSDK.inviteUserToGroup({
|
||||
groupID: extraData as string,
|
||||
userIDList: choosedList.map((item) => item.userID!),
|
||||
reason: "",
|
||||
});
|
||||
break;
|
||||
case "KICK_FORM_GROUP":
|
||||
await IMSDK.kickGroupMember({
|
||||
groupID: extraData as string,
|
||||
userIDList: choosedList.map((item) => item.userID!),
|
||||
reason: "",
|
||||
});
|
||||
break;
|
||||
case "TRANSFER_IN_GROUP":
|
||||
await IMSDK.transferGroupOwner({
|
||||
groupID: extraData as string,
|
||||
newOwnerUserID: choosedList[0].userID!,
|
||||
});
|
||||
break;
|
||||
case "SELECT_USER":
|
||||
emit("SELECT_USER", {
|
||||
notConversation: (extraData as SelectUserExtraData).notConversation,
|
||||
choosedList,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
setLoading(false);
|
||||
closeOverlay();
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
chooseBoxRef.current?.resetState();
|
||||
setGroupBaseInfo({
|
||||
groupName: "",
|
||||
groupAvatar: "",
|
||||
});
|
||||
};
|
||||
|
||||
const customUpload = async ({ file }: { file: FileWithPath }) => {
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
} = await uploadFile(file);
|
||||
setGroupBaseInfo((prev) => ({ ...prev, groupAvatar: url }));
|
||||
} catch (error) {
|
||||
feedbackToast({ error: t("toast.updateAvatarFailed") });
|
||||
}
|
||||
};
|
||||
|
||||
const isCheckInGroup = type === "INVITE_TO_GROUP";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-16 items-center justify-between bg-[var(--gap-text)] px-7">
|
||||
<div>{titleMap[type]}</div>
|
||||
<CloseOutlined
|
||||
className="cursor-pointer text-[var(--sub-text)]"
|
||||
rev={undefined}
|
||||
onClick={closeOverlay}
|
||||
/>
|
||||
</div>
|
||||
{type === "CRATE_GROUP" ? (
|
||||
<div className="px-6 pt-4">
|
||||
<div className="mb-6 flex items-center">
|
||||
<div className="min-w-[60px] font-medium">{t("placeholder.groupName")}</div>
|
||||
<Input
|
||||
placeholder={t("placeholder.pleaseEnter")}
|
||||
maxLength={16}
|
||||
spellCheck={false}
|
||||
value={groupBaseInfo.groupName}
|
||||
onChange={(e) =>
|
||||
setGroupBaseInfo((state) => ({ ...state, groupName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6 flex items-center">
|
||||
<div className="min-w-[60px] font-medium">
|
||||
{t("placeholder.groupAvatar")}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<OIMAvatar src={groupBaseInfo.groupAvatar} isgroup />
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={customUpload as any}
|
||||
>
|
||||
<span className="ml-3 cursor-pointer text-xs text-[var(--primary)]">
|
||||
{t("placeholder.clickToModify")}
|
||||
</span>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="min-w-[60px] font-medium">
|
||||
{t("placeholder.groupMember")}
|
||||
</div>
|
||||
<ChooseBox className={clsx("!m-0 !h-[40vh] flex-1")} ref={chooseBoxRef} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChooseBox
|
||||
className="!h-[60vh]"
|
||||
ref={chooseBoxRef}
|
||||
isCheckInGroup={isCheckInGroup}
|
||||
showGroupMember={onlyMemberTypes.includes(type)}
|
||||
chooseOneOnly={onlyOneTypes.includes(type)}
|
||||
checkMemberRole={type === "KICK_FORM_GROUP"}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end px-9 py-6">
|
||||
<Button
|
||||
className="mr-6 border-0 bg-[var(--chat-bubble)] px-6"
|
||||
onClick={closeOverlay}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="px-6"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={confirmChoose}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { LeftOutlined } from "@ant-design/icons";
|
||||
import { GroupJoinSource, SessionType } from "@openim/wasm-client-sdk";
|
||||
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { useRequest } from "ahooks";
|
||||
import { Button, Input } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { t } from "i18next";
|
||||
import { forwardRef, ForwardRefRenderFunction, memo, useEffect, useState } from "react";
|
||||
|
||||
import clock from "@/assets/images/common/clock.png";
|
||||
import member_etc from "@/assets/images/common/member_etc.png";
|
||||
import DraggableModalWrap from "@/components/DraggableModalWrap";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { useConversationToggle } from "@/hooks/useConversationToggle";
|
||||
import useGroupMembers from "@/hooks/useGroupMembers";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
interface IGroupCardModalProps {
|
||||
groupData?: GroupItem & { inGroup?: boolean };
|
||||
}
|
||||
|
||||
const GroupCardModal: ForwardRefRenderFunction<
|
||||
OverlayVisibleHandle,
|
||||
IGroupCardModalProps
|
||||
> = ({ groupData }, ref) => {
|
||||
const [reqMsg, setReqMsg] = useState("");
|
||||
const [isSendRequest, setIsSendRequest] = useState(false);
|
||||
|
||||
const { fetchState, getMemberData, resetState } = useGroupMembers({
|
||||
groupID: groupData?.groupID,
|
||||
});
|
||||
|
||||
const { toSpecifiedConversation } = useConversationToggle();
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
|
||||
const { runAsync, loading } = useRequest(IMSDK.joinGroup, {
|
||||
manual: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOverlayOpen) {
|
||||
getMemberData(true);
|
||||
}
|
||||
}, [isOverlayOpen]);
|
||||
|
||||
const createTimeStr = dayjs(groupData?.createTime ?? 0).format("YYYY/M/D");
|
||||
|
||||
const sliceNum = groupData?.memberCount === 8 ? 8 : 7;
|
||||
const renderList = fetchState.groupMemberList.slice(0, sliceNum);
|
||||
|
||||
const joinOrSendMessage = () => {
|
||||
if (groupData?.inGroup) {
|
||||
toSpecifiedConversation({
|
||||
sourceID: groupData.groupID,
|
||||
sessionType: SessionType.WorkingGroup,
|
||||
});
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendRequest(true);
|
||||
};
|
||||
|
||||
const sendApplication = async () => {
|
||||
try {
|
||||
await runAsync({
|
||||
groupID: groupData!.groupID,
|
||||
reqMsg,
|
||||
joinSource: GroupJoinSource.Search,
|
||||
});
|
||||
feedbackToast({ msg: t("toast.sendJoinGroupRequestSuccess") });
|
||||
setIsSendRequest(false);
|
||||
} catch (error) {
|
||||
feedbackToast({ error, msg: t("toast.sendApplicationFailed") });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalWrap
|
||||
title={null}
|
||||
footer={null}
|
||||
open={isOverlayOpen}
|
||||
closable={false}
|
||||
width={484}
|
||||
onCancel={closeOverlay}
|
||||
afterClose={resetState}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
mask: {
|
||||
opacity: 0,
|
||||
transition: "none",
|
||||
},
|
||||
}}
|
||||
ignoreClasses=".ignore-drag, .no-padding-modal, .cursor-pointer"
|
||||
className="no-padding-modal"
|
||||
maskTransitionName=""
|
||||
>
|
||||
<div>
|
||||
{isSendRequest && (
|
||||
<div
|
||||
className="flex w-fit cursor-pointer items-center pl-5.5 pt-5.5"
|
||||
onClick={() => setIsSendRequest(false)}
|
||||
>
|
||||
<LeftOutlined rev={undefined} />
|
||||
<div className="ml-1 font-medium">{t("placeholder.groupVerification")}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex p-5.5">
|
||||
<OIMAvatar size={60} src={groupData?.faceURL} isgroup />
|
||||
<div className="ml-3">
|
||||
<div className="mb-3 max-w-[120px] truncate text-base font-medium">
|
||||
{groupData?.groupName}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="text-xs text-[var(--sub-text)]">{`ID:${groupData?.groupID}`}</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<img src={clock} width={10} alt="" />
|
||||
<div className="text-xs text-[var(--sub-text)]">{createTimeStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSendRequest ? (
|
||||
<div className="mx-5.5">
|
||||
<div className="text-xs text-[var(--sub-text)]">
|
||||
{t("application.information")}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Input.TextArea
|
||||
showCount
|
||||
value={reqMsg}
|
||||
maxLength={50}
|
||||
bordered={false}
|
||||
spellCheck={false}
|
||||
placeholder={t("placeholder.pleaseEnter")}
|
||||
style={{ padding: "8px 6px" }}
|
||||
autoSize={{ minRows: 4, maxRows: 4 }}
|
||||
onChange={(e) => setReqMsg(e.target.value)}
|
||||
className="bg-[var(--chat-bubble)] hover:bg-[var(--chat-bubble)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="my-6 flex justify-center">
|
||||
<Button
|
||||
className="w-[60%]"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={sendApplication}
|
||||
>
|
||||
{t("placeholder.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#F2F8FF] p-5.5">
|
||||
<div className="mb-3">{`${t("placeholder.groupMember")}:${
|
||||
groupData?.memberCount
|
||||
}`}</div>
|
||||
<div className="flex items-center">
|
||||
{renderList.map((item) => (
|
||||
<OIMAvatar
|
||||
className="mr-3"
|
||||
src={item.faceURL}
|
||||
text={item.nickname}
|
||||
key={item.userID}
|
||||
/>
|
||||
))}
|
||||
{renderList.length === 7 && <OIMAvatar src={member_etc} />}
|
||||
</div>
|
||||
<div className="mt-28 flex justify-center">
|
||||
<Button
|
||||
className="w-[60%]"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={joinOrSendMessage}
|
||||
>
|
||||
{groupData?.inGroup
|
||||
? t("placeholder.sendMessage")
|
||||
: t("placeholder.addGroup")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalWrap>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(GroupCardModal));
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
memo,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { secondsToMS } from "@/utils/common";
|
||||
|
||||
export type CounterHandle = {
|
||||
getTimeStr: () => string;
|
||||
};
|
||||
const Counter: ForwardRefRenderFunction<
|
||||
CounterHandle,
|
||||
{
|
||||
isConnected: boolean;
|
||||
className?: string;
|
||||
}
|
||||
> = ({ isConnected, className }, ref) => {
|
||||
const [count, setCount] = useState(0);
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
countStart();
|
||||
}
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearInterval(timer.current);
|
||||
}
|
||||
};
|
||||
}, [isConnected]);
|
||||
|
||||
const countStart = () => {
|
||||
timer.current = setInterval(() => {
|
||||
setCount((prev) => prev + 1);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getTimeStr: () => secondsToMS(count),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-white">{secondsToMS(count)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ForwardCounter = memo(forwardRef(Counter));
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
TrackToggle,
|
||||
useLocalParticipant,
|
||||
useRoomContext,
|
||||
} from "@livekit/components-react";
|
||||
import { CbEvents, MessageType } from "@openim/wasm-client-sdk";
|
||||
import {
|
||||
MessageItem,
|
||||
RtcInvite,
|
||||
WSEvent,
|
||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { RemoteParticipant, RoomEvent, Track } from "livekit-client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { getRtcConnectData } from "@/api/imApi";
|
||||
import rtc_accept from "@/assets/images/rtc/rtc_accept.png";
|
||||
import rtc_camera from "@/assets/images/rtc/rtc_camera.png";
|
||||
import rtc_camera_off from "@/assets/images/rtc/rtc_camera_off.png";
|
||||
import rtc_hungup from "@/assets/images/rtc/rtc_hungup.png";
|
||||
import rtc_mic from "@/assets/images/rtc/rtc_mic.png";
|
||||
import rtc_mic_off from "@/assets/images/rtc/rtc_mic_off.png";
|
||||
import { CustomType } from "@/constants";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useUserStore } from "@/store";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { CounterHandle, ForwardCounter } from "./Counter";
|
||||
import { AuthData } from "./data";
|
||||
|
||||
interface IRtcControlProps {
|
||||
isWaiting: boolean;
|
||||
isRecv: boolean;
|
||||
isConnected: boolean;
|
||||
invitation: RtcInvite;
|
||||
connectRtc: (data?: AuthData) => void;
|
||||
closeOverlay: () => void;
|
||||
sendCustomSignal: (recvID: string, customType: CustomType) => Promise<void>;
|
||||
}
|
||||
export const RtcControl = ({
|
||||
isWaiting,
|
||||
isRecv,
|
||||
isConnected,
|
||||
invitation,
|
||||
connectRtc,
|
||||
closeOverlay,
|
||||
sendCustomSignal,
|
||||
}: IRtcControlProps) => {
|
||||
const room = useRoomContext();
|
||||
const localParticipantState = useLocalParticipant();
|
||||
const counterRef = useRef<CounterHandle>(null);
|
||||
|
||||
const recvID = isRecv ? invitation.inviterUserID : invitation.inviteeUserIDList[0];
|
||||
const isVideoCall = invitation.mediaType === "video";
|
||||
|
||||
useEffect(() => {
|
||||
const acceptHandler = async ({ roomID }: RtcInvite) => {
|
||||
if (invitation.roomID !== roomID) return;
|
||||
const { data } = await getRtcConnectData(
|
||||
roomID,
|
||||
useUserStore.getState().selfInfo.userID,
|
||||
);
|
||||
connectRtc(data);
|
||||
};
|
||||
const rejectHandler = ({ roomID }: RtcInvite) => {
|
||||
if (invitation.roomID !== roomID) return;
|
||||
closeOverlay();
|
||||
};
|
||||
const hangupHandler = ({ roomID }: RtcInvite) => {
|
||||
if (invitation.roomID !== roomID) return;
|
||||
room.disconnect();
|
||||
closeOverlay();
|
||||
};
|
||||
const cancelHandler = ({ roomID }: RtcInvite) => {
|
||||
if (invitation.roomID !== roomID) return;
|
||||
if (!isWaiting) return;
|
||||
closeOverlay();
|
||||
};
|
||||
const participantDisconnectedHandler = (remoteParticipant: RemoteParticipant) => {
|
||||
const identity = remoteParticipant.identity;
|
||||
if (
|
||||
identity === invitation.inviterUserID ||
|
||||
identity === invitation.inviteeUserIDList[0]
|
||||
) {
|
||||
room.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const newMessageHandler = ({ data }: WSEvent<MessageItem[]>) => {
|
||||
data.map((message) => {
|
||||
if (message.contentType === MessageType.CustomMessage) {
|
||||
const customData = JSON.parse(message.customElem!.data) as {
|
||||
data: RtcInvite;
|
||||
customType: CustomType;
|
||||
};
|
||||
if (customData.customType === CustomType.CallingAccept) {
|
||||
acceptHandler(customData.data);
|
||||
}
|
||||
if (customData.customType === CustomType.CallingReject) {
|
||||
rejectHandler(customData.data);
|
||||
}
|
||||
if (customData.customType === CustomType.CallingCancel) {
|
||||
cancelHandler(customData.data);
|
||||
}
|
||||
if (customData.customType === CustomType.CallingHungup) {
|
||||
hangupHandler(customData.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
IMSDK.on(CbEvents.OnRecvNewMessages, newMessageHandler);
|
||||
room.on(RoomEvent.ParticipantDisconnected, participantDisconnectedHandler);
|
||||
return () => {
|
||||
IMSDK.off(CbEvents.OnRecvNewMessages, newMessageHandler);
|
||||
room.off(RoomEvent.ParticipantDisconnected, participantDisconnectedHandler);
|
||||
};
|
||||
}, [room, invitation.roomID, isWaiting]);
|
||||
|
||||
const hungup = () => {
|
||||
if (isWaiting) {
|
||||
const customType = isRecv ? CustomType.CallingReject : CustomType.CallingCancel;
|
||||
sendCustomSignal(recvID, customType);
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
sendCustomSignal(recvID, CustomType.CallingHungup);
|
||||
room.disconnect();
|
||||
};
|
||||
|
||||
const acceptInvitation = async () => {
|
||||
try {
|
||||
await sendCustomSignal(recvID, CustomType.CallingAccept);
|
||||
const { data } = await getRtcConnectData(
|
||||
invitation.roomID,
|
||||
useUserStore.getState().selfInfo.userID,
|
||||
);
|
||||
connectRtc(data);
|
||||
} catch (error) {
|
||||
feedbackToast({ msg: t("toast.byInviteUserFailed"), error });
|
||||
closeOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ignore-drag absolute bottom-[6%] z-10 flex justify-center">
|
||||
{!isWaiting && (
|
||||
<ForwardCounter
|
||||
ref={counterRef}
|
||||
className={clsx("absolute -top-8")}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
{!isWaiting && (
|
||||
<TrackToggle
|
||||
className="flex cursor-pointer flex-col items-center !justify-start !gap-0 !p-0"
|
||||
source={Track.Source.Microphone}
|
||||
showIcon={false}
|
||||
>
|
||||
<img
|
||||
width={48}
|
||||
src={localParticipantState.isMicrophoneEnabled ? rtc_mic : rtc_mic_off}
|
||||
alt=""
|
||||
/>
|
||||
<span className="mt-2 text-xs text-white">{t("placeholder.microphone")}</span>
|
||||
</TrackToggle>
|
||||
)}
|
||||
<div
|
||||
className={clsx("ml-12 flex cursor-pointer flex-col items-center", {
|
||||
"mr-12": isVideoCall,
|
||||
"!mx-0": !isRecv && isWaiting,
|
||||
})}
|
||||
onClick={hungup}
|
||||
>
|
||||
<img width={48} src={rtc_hungup} alt="" />
|
||||
<span
|
||||
className={clsx("mt-2 text-xs text-white", {
|
||||
"!text-[var(--sub-text)]": isWaiting,
|
||||
})}
|
||||
>
|
||||
{isWaiting ? t("cancel") : t("hangUp")}
|
||||
</span>
|
||||
</div>
|
||||
{isRecv && isWaiting && (
|
||||
<div
|
||||
className="mx-12 flex cursor-pointer flex-col items-center"
|
||||
onClick={acceptInvitation}
|
||||
>
|
||||
<img width={48} src={rtc_accept} alt="" />
|
||||
<span
|
||||
className={clsx("mt-2 text-xs text-white", {
|
||||
"!text-[var(--sub-text)]": isWaiting,
|
||||
})}
|
||||
>
|
||||
{t("answer")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isWaiting && isVideoCall && (
|
||||
<TrackToggle
|
||||
className="flex cursor-pointer flex-col items-center justify-start !gap-0 !p-0"
|
||||
source={Track.Source.Camera}
|
||||
showIcon={false}
|
||||
>
|
||||
<img
|
||||
width={48}
|
||||
src={localParticipantState.isCameraEnabled ? rtc_camera : rtc_camera_off}
|
||||
alt=""
|
||||
/>
|
||||
<span className="mt-2 text-xs text-white">{t("placeholder.camera")}</span>
|
||||
</TrackToggle>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
RoomAudioRenderer,
|
||||
TrackLoop,
|
||||
TrackRefContext,
|
||||
useConnectionState,
|
||||
useTracks,
|
||||
VideoTrack,
|
||||
} from "@livekit/components-react";
|
||||
import { PublicUserItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Spin } from "antd";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
Participant,
|
||||
ParticipantEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { CustomType } from "@/constants";
|
||||
|
||||
import { AuthData, InviteData } from "./data";
|
||||
import { RtcControl } from "./RtcControl";
|
||||
|
||||
const localVideoClasses =
|
||||
"absolute right-3 top-3 !w-[100px] !h-[150px] rounded-md z-10";
|
||||
const remoteVideoClasses = "absolute top-0 z-0";
|
||||
|
||||
interface IRtcLayoutProps {
|
||||
connect: boolean;
|
||||
isConnected: boolean;
|
||||
isRecv: boolean;
|
||||
inviteData?: InviteData;
|
||||
closeOverlay: () => void;
|
||||
sendCustomSignal: (recvID: string, customType: CustomType) => Promise<void>;
|
||||
connectRtc: (data?: AuthData) => void;
|
||||
}
|
||||
export const RtcLayout = ({
|
||||
connect,
|
||||
isConnected,
|
||||
isRecv,
|
||||
inviteData,
|
||||
connectRtc,
|
||||
sendCustomSignal,
|
||||
closeOverlay,
|
||||
}: IRtcLayoutProps) => {
|
||||
const isVideoCall = inviteData?.invitation?.mediaType === "video";
|
||||
const tracks = useTracks([Track.Source.Camera]);
|
||||
const remoteParticipant = tracks.find((track) => !isLocal(track.participant));
|
||||
const isWaiting = !connect && !isConnected;
|
||||
const [isRemoteVideoMuted, setIsRemoteVideoMuted] = useState(false);
|
||||
|
||||
const connectState = useConnectionState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteParticipant?.participant.identity) return;
|
||||
const trackMuteUpdate = () => {
|
||||
setIsRemoteVideoMuted(!remoteParticipant?.participant.isCameraEnabled);
|
||||
};
|
||||
remoteParticipant?.participant.on(ParticipantEvent.TrackMuted, trackMuteUpdate);
|
||||
remoteParticipant?.participant.on(ParticipantEvent.TrackUnmuted, trackMuteUpdate);
|
||||
trackMuteUpdate();
|
||||
}, [remoteParticipant?.participant.identity]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isWaiting && isVideoCall && !isRemoteVideoMuted) return null;
|
||||
|
||||
return (
|
||||
<SingleProfile
|
||||
isWaiting={isWaiting}
|
||||
userInfo={inviteData?.participant?.userInfo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={connectState === ConnectionState.Connecting}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `340px`,
|
||||
width: `480px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex h-full flex-col items-center justify-between bg-[#262729]",
|
||||
{ "!bg-[#F2F8FF]": isWaiting },
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
<RtcControl
|
||||
isWaiting={isWaiting}
|
||||
isRecv={isRecv}
|
||||
isConnected={isConnected}
|
||||
// @ts-ignore
|
||||
invitation={inviteData?.invitation}
|
||||
closeOverlay={closeOverlay}
|
||||
connectRtc={connectRtc}
|
||||
sendCustomSignal={sendCustomSignal}
|
||||
/>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<TrackLoop tracks={tracks}>
|
||||
<TrackRefContext.Consumer>
|
||||
{(track) =>
|
||||
track && (
|
||||
<VideoTrack
|
||||
{...track}
|
||||
className={
|
||||
isLocal(track.participant)
|
||||
? localVideoClasses
|
||||
: `${remoteVideoClasses} ${isRemoteVideoMuted ? "hidden" : ""}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</TrackRefContext.Consumer>
|
||||
</TrackLoop>
|
||||
)}
|
||||
</div>
|
||||
<RoomAudioRenderer />
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISingleProfileProps {
|
||||
isWaiting: boolean;
|
||||
userInfo?: PublicUserItem;
|
||||
}
|
||||
const SingleProfile = ({ isWaiting, userInfo }: ISingleProfileProps) => {
|
||||
return (
|
||||
<div className="absolute top-[10%] flex flex-col items-center">
|
||||
<OIMAvatar size={48} src={userInfo?.faceURL} text={userInfo?.nickname} />
|
||||
<div
|
||||
className={clsx("mt-3 max-w-[120px] truncate text-white", {
|
||||
"!text-[var(--base-black)]": isWaiting,
|
||||
})}
|
||||
>
|
||||
{userInfo?.nickname}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isLocal = (p: Participant) => {
|
||||
return p instanceof LocalParticipant;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
GroupItem,
|
||||
GroupMemberItem,
|
||||
PublicUserItem,
|
||||
RtcInvite,
|
||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
|
||||
export interface InviteData {
|
||||
invitation?: RtcInvite;
|
||||
participant?: ParticipantInfo;
|
||||
isJoin?: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantInfo {
|
||||
userInfo: PublicUserItem;
|
||||
groupMemberInfo?: GroupMemberItem;
|
||||
groupInfo?: GroupItem;
|
||||
}
|
||||
|
||||
export interface RtcInviteResults {
|
||||
liveURL: string;
|
||||
roomID: string;
|
||||
token: string;
|
||||
busyLineUserIDList?: string[];
|
||||
}
|
||||
|
||||
export interface AuthData {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import "@livekit/components-styles";
|
||||
|
||||
import { LiveKitRoom } from "@livekit/components-react";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import DraggableModalWrap from "@/components/DraggableModalWrap";
|
||||
import { CustomType } from "@/constants";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useUserStore } from "@/store";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { AuthData, InviteData } from "./data";
|
||||
import { RtcLayout } from "./RtcLayout";
|
||||
|
||||
interface IRtcCallModalProps {
|
||||
inviteData: InviteData;
|
||||
}
|
||||
|
||||
const RtcCallModal: ForwardRefRenderFunction<
|
||||
OverlayVisibleHandle,
|
||||
IRtcCallModalProps
|
||||
> = ({ inviteData }, ref) => {
|
||||
const { invitation } = inviteData;
|
||||
const [connect, setConnect] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [authData, setAuthData] = useState<AuthData>({
|
||||
serverUrl: "",
|
||||
token: "",
|
||||
});
|
||||
const selfID = useUserStore((state) => state.selfInfo.userID);
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
const timer = useRef<NodeJS.Timeout>();
|
||||
|
||||
const isRecv = selfID !== invitation?.inviterUserID;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOverlayOpen) return;
|
||||
tryInvite();
|
||||
}, [isOverlayOpen, isRecv]);
|
||||
|
||||
const checkTimeout = () => {
|
||||
if (timer.current) clearTimer();
|
||||
timer.current = setTimeout(() => {
|
||||
clearTimer();
|
||||
|
||||
if (!invitation) return;
|
||||
|
||||
sendCustomSignal(invitation?.inviteeUserIDList[0], CustomType.CallingCancel);
|
||||
closeOverlay();
|
||||
}, (invitation?.timeout ?? 30) * 1000);
|
||||
};
|
||||
|
||||
const clearTimer = useCallback(() => clearTimeout(timer.current), []);
|
||||
|
||||
const closeOverlayAndClearTimer = useCallback(() => {
|
||||
clearTimer();
|
||||
closeOverlay();
|
||||
}, []);
|
||||
|
||||
const sendCustomSignal = useCallback(
|
||||
async (recvID: string, customType: CustomType) => {
|
||||
const data = {
|
||||
customType,
|
||||
data: {
|
||||
...invitation,
|
||||
},
|
||||
};
|
||||
const { data: message } = await IMSDK.createCustomMessage({
|
||||
data: JSON.stringify(data),
|
||||
extension: "",
|
||||
description: "",
|
||||
});
|
||||
await IMSDK.sendMessage({
|
||||
recvID,
|
||||
message,
|
||||
groupID: "",
|
||||
isOnlineOnly: true,
|
||||
});
|
||||
},
|
||||
[invitation?.roomID],
|
||||
);
|
||||
|
||||
const tryInvite = async () => {
|
||||
if (!isRecv) {
|
||||
try {
|
||||
await sendCustomSignal(
|
||||
invitation.inviteeUserIDList[0],
|
||||
CustomType.CallingInvite,
|
||||
);
|
||||
checkTimeout();
|
||||
} catch (error) {
|
||||
feedbackToast({ msg: t("toast.inviteUserFailed"), error });
|
||||
closeOverlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connectRtc = useCallback((data?: AuthData) => {
|
||||
if (data) {
|
||||
setAuthData(data);
|
||||
}
|
||||
clearTimer();
|
||||
setTimeout(() => setConnect(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DraggableModalWrap
|
||||
title={null}
|
||||
footer={null}
|
||||
open={isOverlayOpen}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
mask={false}
|
||||
centered
|
||||
width="auto"
|
||||
onCancel={closeOverlay}
|
||||
destroyOnClose
|
||||
ignoreClasses=".ignore-drag, .no-padding-modal, .cursor-pointer"
|
||||
className="no-padding-modal rtc-single-modal"
|
||||
wrapClassName="pointer-events-none"
|
||||
>
|
||||
<div>
|
||||
{isOverlayOpen && (
|
||||
<LiveKitRoom
|
||||
serverUrl={authData.serverUrl}
|
||||
token={authData.token}
|
||||
video={invitation?.mediaType === "video"}
|
||||
audio={true}
|
||||
connect={connect}
|
||||
options={{
|
||||
publishDefaults: {
|
||||
videoCodec: "vp9",
|
||||
backupCodec: { codec: "vp8" },
|
||||
},
|
||||
}}
|
||||
onConnected={() => setIsConnected(true)}
|
||||
onDisconnected={() => {
|
||||
closeOverlayAndClearTimer();
|
||||
setIsConnected(false);
|
||||
setConnect(false);
|
||||
}}
|
||||
>
|
||||
<RtcLayout
|
||||
connect={connect}
|
||||
isConnected={isConnected}
|
||||
isRecv={isRecv}
|
||||
inviteData={inviteData}
|
||||
sendCustomSignal={sendCustomSignal}
|
||||
connectRtc={connectRtc}
|
||||
closeOverlay={closeOverlayAndClearTimer}
|
||||
/>
|
||||
</LiveKitRoom>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalWrap>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(RtcCallModal);
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Button, DatePicker, Form, Input, Modal, Select } from "antd";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { t } from "i18next";
|
||||
import { forwardRef, ForwardRefRenderFunction, memo } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { errorHandle } from "@/api/errorHandle";
|
||||
import { BusinessUserInfo, updateBusinessUserInfo } from "@/api/login";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { useUserStore } from "@/store";
|
||||
|
||||
const EditSelfInfo: ForwardRefRenderFunction<
|
||||
OverlayVisibleHandle,
|
||||
{ refreshSelfInfo: () => void }
|
||||
> = ({ refreshSelfInfo }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
const selfInfo = useUserStore((state) => state.selfInfo);
|
||||
const updateSelfInfo = useUserStore((state) => state.updateSelfInfo);
|
||||
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
|
||||
const { isLoading, mutate } = useMutation(updateBusinessUserInfo, {
|
||||
onError: errorHandle,
|
||||
});
|
||||
|
||||
const onFinish = (value: BusinessUserInfo & { birth: Dayjs }) => {
|
||||
const options = {
|
||||
nickname: value.nickname,
|
||||
email: value.email,
|
||||
gender: value.gender,
|
||||
birth: value.birth.unix() * 1000,
|
||||
};
|
||||
mutate(options, {
|
||||
onSuccess: () => {
|
||||
updateSelfInfo(options);
|
||||
refreshSelfInfo();
|
||||
closeOverlay();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
footer={null}
|
||||
closable={false}
|
||||
open={isOverlayOpen}
|
||||
centered
|
||||
onCancel={closeOverlay}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
mask: {
|
||||
opacity: 0,
|
||||
transition: "none",
|
||||
},
|
||||
}}
|
||||
width={484}
|
||||
className="no-padding-modal"
|
||||
maskTransitionName=""
|
||||
>
|
||||
<div>
|
||||
<div className="flex bg-[var(--chat-bubble)] p-5">
|
||||
<span className="text-base font-medium">{t("placeholder.editInfo")}</span>
|
||||
</div>
|
||||
{isOverlayOpen && (
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
requiredMark={false}
|
||||
labelCol={{ span: 3 }}
|
||||
onFinish={onFinish}
|
||||
className="sub-label-form p-6.5"
|
||||
autoComplete="off"
|
||||
initialValues={{ ...selfInfo, birth: dayjs(selfInfo.birth) }}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("placeholder.nickName")}
|
||||
name="nickname"
|
||||
rules={[{ required: true, message: t("toast.inputNickName") }]}
|
||||
>
|
||||
<Input maxLength={20} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("placeholder.gender")} name="gender">
|
||||
<Select>
|
||||
<Select.Option value={1}>{t("placeholder.man")}</Select.Option>
|
||||
<Select.Option value={2}>{t("placeholder.female")}</Select.Option>
|
||||
<Select.Option value={0}>{t("placeholder.unknown")}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("placeholder.phoneNumber")}
|
||||
name="phoneNumber"
|
||||
// rules={[{ pattern: /^1[3-9]\d{9}$/, message: t("placeholder.inputCorrectPhoneNumber") }]}
|
||||
>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("placeholder.email")}
|
||||
name="email"
|
||||
rules={[{ type: "email", message: t("toast.inputCorrectEmail") }]}
|
||||
>
|
||||
<Input spellCheck={false} placeholder={t("toast.inputEmail")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("placeholder.birth")} name="birth">
|
||||
<DatePicker
|
||||
disabledDate={(current) => current && current > dayjs().endOf("day")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="mr-3.5 border-0 bg-[var(--chat-bubble)] px-6"
|
||||
onClick={closeOverlay}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="px-6"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(EditSelfInfo));
|
||||
@@ -0,0 +1,99 @@
|
||||
import { LeftOutlined } from "@ant-design/icons";
|
||||
import { useRequest } from "ahooks";
|
||||
import { Button, Input } from "antd";
|
||||
import { t } from "i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { CardInfo } from ".";
|
||||
|
||||
const SendRequest = ({
|
||||
cardInfo,
|
||||
backToCard,
|
||||
}: {
|
||||
cardInfo: CardInfo;
|
||||
backToCard: () => void;
|
||||
}) => {
|
||||
const [reqMsg, setReqMsg] = useState("");
|
||||
const { runAsync, loading } = useRequest(IMSDK.addFriend, {
|
||||
manual: true,
|
||||
});
|
||||
|
||||
const sendApplication = async () => {
|
||||
try {
|
||||
await runAsync({
|
||||
toUserID: cardInfo.userID!,
|
||||
reqMsg,
|
||||
});
|
||||
feedbackToast({ msg: t("toast.sendFreiendRequestSuccess") });
|
||||
} catch (error) {
|
||||
feedbackToast({ error, msg: t("toast.sendApplicationFailed") });
|
||||
}
|
||||
backToCard();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[520px] min-h-[484px] flex-col overflow-hidden px-5.5">
|
||||
<div className="w-full cursor-move">
|
||||
<div className="mb-8 mt-4.5 flex items-center">
|
||||
<LeftOutlined
|
||||
className="cursor-pointer text-[var(--sub-text)]"
|
||||
rev={undefined}
|
||||
onClick={backToCard}
|
||||
/>
|
||||
<div className="ml-2 font-medium">{t("placeholder.friendVerification")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ignore-drag flex flex-1 flex-col">
|
||||
<div className="flex items-center">
|
||||
<OIMAvatar size={60} src={cardInfo?.faceURL} text={cardInfo?.nickname} />
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<div
|
||||
className="mb-3 flex-1 truncate text-base font-medium"
|
||||
title={cardInfo?.nickname}
|
||||
>
|
||||
{cardInfo?.nickname}
|
||||
</div>
|
||||
<div className="mr-3 text-xs text-[var(--sub-text)]">
|
||||
{cardInfo?.userID}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-7">
|
||||
<div className="text-xs text-[var(--sub-text)]">
|
||||
{t("application.information")}
|
||||
</div>
|
||||
<div className="mx-2 my-4">
|
||||
<Input.TextArea
|
||||
showCount
|
||||
value={reqMsg}
|
||||
maxLength={50}
|
||||
bordered={false}
|
||||
placeholder={t("placeholder.pleaseEnter")}
|
||||
spellCheck={false}
|
||||
style={{ padding: "8px 6px" }}
|
||||
autoSize={{ minRows: 6, maxRows: 6 }}
|
||||
onChange={(e) => setReqMsg(e.target.value)}
|
||||
className="bg-[var(--chat-bubble)] hover:bg-[var(--chat-bubble)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-2 mb-6 flex flex-1 items-end">
|
||||
<Button
|
||||
className="flex-1"
|
||||
type="primary"
|
||||
onClick={sendApplication}
|
||||
loading={loading}
|
||||
>
|
||||
{t("placeholder.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendRequest;
|
||||
@@ -0,0 +1,369 @@
|
||||
import { CbEvents } from "@openim/wasm-client-sdk";
|
||||
import { SessionType } from "@openim/wasm-client-sdk";
|
||||
import {
|
||||
FriendUserItem,
|
||||
GroupMemberItem,
|
||||
WSEvent,
|
||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { useLatest } from "ahooks";
|
||||
import { Button, Divider, Spin } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
FC,
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { BusinessUserInfo, getBusinessUserInfo } from "@/api/login";
|
||||
import DraggableModalWrap from "@/components/DraggableModalWrap";
|
||||
import EditableContent from "@/components/EditableContent";
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
import { useConversationToggle } from "@/hooks/useConversationToggle";
|
||||
import { OverlayVisibleHandle, useOverlayVisible } from "@/hooks/useOverlayVisible";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useContactStore, useUserStore } from "@/store";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import EditSelfInfo from "./EditSelfInfo";
|
||||
import SendRequest from "./SendRequest";
|
||||
|
||||
interface IUserCardModalProps {
|
||||
userID?: string;
|
||||
groupID?: string;
|
||||
isSelf?: boolean;
|
||||
notAdd?: boolean;
|
||||
cardInfo?: CardInfo;
|
||||
}
|
||||
|
||||
export type CardInfo = Partial<BusinessUserInfo & FriendUserItem>;
|
||||
|
||||
const getGender = (gender: number) => {
|
||||
if (!gender) return "-";
|
||||
return gender === 1 ? t("placeholder.man") : t("placeholder.female");
|
||||
};
|
||||
|
||||
const UserCardModal: ForwardRefRenderFunction<
|
||||
OverlayVisibleHandle,
|
||||
IUserCardModalProps
|
||||
> = (props, ref) => {
|
||||
const { userID, isSelf, notAdd } = props;
|
||||
|
||||
const editInfoRef = useRef<OverlayVisibleHandle>(null);
|
||||
const [cardInfo, setCardInfo] = useState<CardInfo>();
|
||||
const [isSendRequest, setIsSendRequest] = useState(false);
|
||||
const [userFields, setUserFields] = useState<FieldRow[]>([]);
|
||||
|
||||
const selfInfo = useUserStore((state) => state.selfInfo);
|
||||
const isFriendUser = useContactStore(
|
||||
(state) => state.friendList.findIndex((item) => item.userID === userID) !== -1,
|
||||
);
|
||||
|
||||
const { isOverlayOpen, closeOverlay } = useOverlayVisible(ref);
|
||||
const { toSpecifiedConversation } = useConversationToggle();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getCardInfo = async (): Promise<{
|
||||
cardInfo: CardInfo;
|
||||
memberInfo?: GroupMemberItem | null;
|
||||
}> => {
|
||||
if (isSelf) {
|
||||
return {
|
||||
cardInfo: selfInfo,
|
||||
};
|
||||
}
|
||||
let userInfo: CardInfo | null = null;
|
||||
const friendInfo = useContactStore
|
||||
.getState()
|
||||
.friendList.find((item) => item.userID === userID);
|
||||
if (friendInfo) {
|
||||
userInfo = { ...friendInfo };
|
||||
} else {
|
||||
const { data } = await IMSDK.getUsersInfo([userID!]);
|
||||
userInfo = { ...(data[0] ?? {}) };
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { users },
|
||||
} = await getBusinessUserInfo([userID!]);
|
||||
userInfo = { ...userInfo, ...users[0] };
|
||||
} catch (error) {
|
||||
console.error("get business user info failed", userID, error);
|
||||
}
|
||||
return {
|
||||
cardInfo: userInfo,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshData = (data?: { cardInfo: CardInfo | null }) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const { cardInfo } = data;
|
||||
|
||||
setCardInfo(cardInfo!);
|
||||
setUserInfoRow(cardInfo!);
|
||||
};
|
||||
|
||||
const {
|
||||
data: fullCardInfo,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery(["userInfo", userID], getCardInfo, {
|
||||
enabled: isOverlayOpen && Boolean(userID),
|
||||
onSuccess: refreshData,
|
||||
});
|
||||
|
||||
const latestFullCardInfo = useLatest(fullCardInfo);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOverlayOpen) return;
|
||||
const friendAddedHandler = ({ data }: WSEvent<FriendUserItem>) => {
|
||||
if (data.userID === userID) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
IMSDK.on(CbEvents.OnFriendAdded, friendAddedHandler);
|
||||
refreshData(
|
||||
props.cardInfo ? { cardInfo: props.cardInfo } : latestFullCardInfo.current,
|
||||
);
|
||||
return () => {
|
||||
IMSDK.off(CbEvents.OnFriendAdded, friendAddedHandler);
|
||||
};
|
||||
}, [isOverlayOpen, props.cardInfo]);
|
||||
|
||||
const refreshSelfInfo = useCallback(() => {
|
||||
const latestInfo = useUserStore.getState().selfInfo;
|
||||
setCardInfo(latestInfo);
|
||||
setUserInfoRow(latestInfo);
|
||||
}, [isSelf]);
|
||||
|
||||
const updateCardRemark = (remark: string) => {
|
||||
setUserInfoRow({ ...cardInfo!, remark });
|
||||
};
|
||||
const setUserInfoRow = (info: CardInfo) => {
|
||||
let tmpFields = [] as FieldRow[];
|
||||
tmpFields.push({
|
||||
title: t("placeholder.nickName"),
|
||||
value: info.nickname || "",
|
||||
});
|
||||
const isFriend = info?.remark !== undefined;
|
||||
|
||||
if (isFriend) {
|
||||
tmpFields.push({
|
||||
title: t("placeholder.remark"),
|
||||
value: info.remark || "-",
|
||||
editable: true,
|
||||
});
|
||||
}
|
||||
if (isFriend || isSelf) {
|
||||
tmpFields = [
|
||||
...tmpFields,
|
||||
...[
|
||||
{
|
||||
title: t("placeholder.gender"),
|
||||
value: getGender(info.gender!),
|
||||
},
|
||||
{
|
||||
title: t("placeholder.birth"),
|
||||
value: info.birth ? dayjs(info.birth).format("YYYY/M/D") : "-",
|
||||
},
|
||||
{
|
||||
title: t("placeholder.phoneNumber"),
|
||||
value: info.phoneNumber || "-",
|
||||
},
|
||||
{
|
||||
title: t("placeholder.email"),
|
||||
value: info.email || "-",
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
setUserFields(tmpFields);
|
||||
};
|
||||
|
||||
const backToCard = () => {
|
||||
setIsSendRequest(false);
|
||||
};
|
||||
|
||||
const trySendRequest = () => {
|
||||
setIsSendRequest(true);
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setCardInfo(undefined);
|
||||
setUserFields([]);
|
||||
setIsSendRequest(false);
|
||||
};
|
||||
|
||||
const showAddFriend = !isFriendUser && !isSelf && !notAdd;
|
||||
|
||||
return (
|
||||
<DraggableModalWrap
|
||||
title={null}
|
||||
footer={null}
|
||||
open={isOverlayOpen}
|
||||
closable={false}
|
||||
width={332}
|
||||
centered
|
||||
onCancel={closeOverlay}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
mask: {
|
||||
opacity: 0,
|
||||
transition: "none",
|
||||
},
|
||||
}}
|
||||
afterClose={resetState}
|
||||
ignoreClasses=".ignore-drag, .no-padding-modal, .cursor-pointer"
|
||||
className="no-padding-modal"
|
||||
maskTransitionName=""
|
||||
>
|
||||
<Spin spinning={isLoading}>
|
||||
{isSendRequest ? (
|
||||
<SendRequest cardInfo={cardInfo!} backToCard={backToCard} />
|
||||
) : (
|
||||
<div className="flex max-h-[520px] min-h-[484px] flex-col overflow-hidden bg-[url(@/assets/images/common/card_bg.png)] bg-[length:332px_134px] bg-no-repeat px-5.5">
|
||||
<div className="h-[104px] min-h-[104px] w-full cursor-move" />
|
||||
<div className="ignore-drag flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center">
|
||||
<OIMAvatar
|
||||
size={60}
|
||||
src={cardInfo?.faceURL}
|
||||
text={cardInfo?.nickname}
|
||||
/>
|
||||
<div className="ml-3 flex h-[60px] flex-1 flex-col justify-around overflow-hidden">
|
||||
<div className="flex w-fit max-w-[80%] items-baseline">
|
||||
<div
|
||||
className="flex-1 select-text truncate text-base font-medium text-white"
|
||||
title={cardInfo?.nickname}
|
||||
>
|
||||
{cardInfo?.nickname}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-3 cursor-pointer text-xs text-[var(--sub-text)]"
|
||||
onClick={() => {
|
||||
copyToClipboard(cardInfo?.userID ?? "");
|
||||
feedbackToast({ msg: t("toast.copySuccess") });
|
||||
}}
|
||||
>
|
||||
{cardInfo?.userID}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<UserCardDataGroup
|
||||
title={t("placeholder.personalInfo")}
|
||||
userID={cardInfo?.userID}
|
||||
fieldRows={userFields}
|
||||
updateCardRemark={updateCardRemark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-1 mb-6 mt-3 flex items-center gap-6">
|
||||
{showAddFriend && (
|
||||
<Button type="primary" className="flex-1" onClick={trySendRequest}>
|
||||
{t("placeholder.addFriends")}
|
||||
</Button>
|
||||
)}
|
||||
{isSelf && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="flex-1"
|
||||
onClick={() => editInfoRef.current?.openOverlay()}
|
||||
>
|
||||
{t("placeholder.editInfo")}
|
||||
</Button>
|
||||
)}
|
||||
{!isSelf && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="flex-1"
|
||||
onClick={() =>
|
||||
toSpecifiedConversation({
|
||||
sourceID: userID!,
|
||||
sessionType: SessionType.Single,
|
||||
}).then(closeOverlay)
|
||||
}
|
||||
>
|
||||
{t("placeholder.sendMessage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
<EditSelfInfo ref={editInfoRef} refreshSelfInfo={refreshSelfInfo} />
|
||||
</DraggableModalWrap>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(UserCardModal));
|
||||
|
||||
interface IUserCardDataGroupProps {
|
||||
title: string;
|
||||
userID?: string;
|
||||
divider?: boolean;
|
||||
fieldRows: FieldRow[];
|
||||
updateCardRemark?: (remark: string) => void;
|
||||
}
|
||||
|
||||
type FieldRow = {
|
||||
title: string;
|
||||
value: string;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
const UserCardDataGroup: FC<IUserCardDataGroupProps> = ({
|
||||
title,
|
||||
userID,
|
||||
divider,
|
||||
fieldRows,
|
||||
updateCardRemark,
|
||||
}) => {
|
||||
const tryUpdateRemark = async (remark: string) => {
|
||||
try {
|
||||
await IMSDK.updateFriends({
|
||||
friendUserIDs: [userID!],
|
||||
remark,
|
||||
});
|
||||
updateCardRemark?.(remark);
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="my-4 text-[var(--sub-text)]">{title}</div>
|
||||
{fieldRows.map((fieldRow, idx) => (
|
||||
<div className="my-4 flex items-center text-xs" key={idx}>
|
||||
<div className="w-24 text-[var(--sub-text)]">{fieldRow.title}</div>
|
||||
{fieldRow.editable ? (
|
||||
<EditableContent
|
||||
className="!ml-0"
|
||||
textClassName="font-medium"
|
||||
value={fieldRow.value}
|
||||
editable={true}
|
||||
onChange={tryUpdateRemark}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 select-text truncate">{fieldRow.value}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{divider && <Divider className="my-0 border-[var(--gap-text)]" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Badge } from "antd";
|
||||
import clsx from "clsx";
|
||||
import i18n, { t } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import group_notifications from "@/assets/images/contact/group_notifications.png";
|
||||
import my_friends from "@/assets/images/contact/my_friends.png";
|
||||
import my_groups from "@/assets/images/contact/my_groups.png";
|
||||
import new_friends from "@/assets/images/contact/new_friends.png";
|
||||
import FlexibleSider from "@/components/FlexibleSider";
|
||||
import { useContactStore } from "@/store";
|
||||
|
||||
const Links = [
|
||||
{
|
||||
label: t("placeholder.newFriends"),
|
||||
icon: new_friends,
|
||||
path: "/contact/newFriends",
|
||||
},
|
||||
{
|
||||
label: t("placeholder.groupNotification"),
|
||||
icon: group_notifications,
|
||||
path: "/contact/groupNotifications",
|
||||
},
|
||||
{
|
||||
label: t("placeholder.myFriend"),
|
||||
icon: my_friends,
|
||||
path: "/contact",
|
||||
},
|
||||
{
|
||||
label: t("placeholder.myGroup"),
|
||||
icon: my_groups,
|
||||
path: "/contact/myGroups",
|
||||
},
|
||||
];
|
||||
|
||||
i18n.on("languageChanged", () => {
|
||||
Links[0].label = t("placeholder.newFriends");
|
||||
Links[1].label = t("placeholder.groupNotification");
|
||||
Links[2].label = t("placeholder.myFriend");
|
||||
Links[3].label = t("placeholder.myGroup");
|
||||
});
|
||||
|
||||
const ContactSider = () => {
|
||||
const [selectIndex, setSelectIndex] = useState(2);
|
||||
const unHandleFriendApplicationCount = useContactStore(
|
||||
(state) => state.unHandleFriendApplicationCount,
|
||||
);
|
||||
const unHandleGroupApplicationCount = useContactStore(
|
||||
(state) => state.unHandleGroupApplicationCount,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash.includes("/contact/newFriends")) {
|
||||
setSelectIndex(0);
|
||||
}
|
||||
if (location.hash.includes("/contact/groupNotifications")) {
|
||||
setSelectIndex(1);
|
||||
}
|
||||
if (location.hash.includes("/contact/myGroups")) {
|
||||
setSelectIndex(3);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getBadge = (index: number) => {
|
||||
if (index === 0) {
|
||||
return unHandleFriendApplicationCount;
|
||||
}
|
||||
if (index === 1) {
|
||||
return unHandleGroupApplicationCount;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleSider
|
||||
needHidden={true}
|
||||
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
|
||||
>
|
||||
<div className="app-drag px-4 pt-4">
|
||||
<div className="pb-3 text-base font-bold">{t("placeholder.contact")}</div>
|
||||
</div>
|
||||
<div className="h-full flex-1 overflow-y-auto bg-white">
|
||||
<ul>
|
||||
{Links.map((item, index) => {
|
||||
return (
|
||||
<li
|
||||
key={item.path}
|
||||
className={clsx(
|
||||
"mx-2 flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm",
|
||||
index === selectIndex
|
||||
? "bg-[var(--primary-active)]"
|
||||
: "hover:bg-[#F3F5F7]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectIndex(index);
|
||||
navigate(String(item.path));
|
||||
}}
|
||||
>
|
||||
<Badge size="small" count={getBadge(index)}>
|
||||
<img
|
||||
alt={item.label}
|
||||
src={item.icon}
|
||||
className="mr-3 h-12 w-12 rounded-lg"
|
||||
/>
|
||||
</Badge>
|
||||
<div className="text-sm">{item.label}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</FlexibleSider>
|
||||
);
|
||||
};
|
||||
export default ContactSider;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ApplicationHandleResult } from "@openim/wasm-client-sdk";
|
||||
import { GroupApplicationItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
import ApplicationItem, { AccessFunction } from "@/components/ApplicationItem";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useContactStore } from "@/store/contact";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
export const GroupNotifications = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentUserID = useUserStore((state) => state.selfInfo.userID);
|
||||
|
||||
const recvGroupApplicationList = useContactStore(
|
||||
(state) => state.recvGroupApplicationList,
|
||||
);
|
||||
const sendGroupApplicationList = useContactStore(
|
||||
(state) => state.sendGroupApplicationList,
|
||||
);
|
||||
const updateRecvGroupApplication = useContactStore(
|
||||
(state) => state.updateRecvGroupApplication,
|
||||
);
|
||||
const updateSendGroupApplication = useContactStore(
|
||||
(state) => state.updateSendGroupApplication,
|
||||
);
|
||||
|
||||
const groupApplicationList = sortArray(
|
||||
recvGroupApplicationList.concat(sendGroupApplicationList),
|
||||
);
|
||||
|
||||
const onAccept = useCallback(
|
||||
async (application: GroupApplicationItem, isRecv: boolean) => {
|
||||
try {
|
||||
await IMSDK.acceptGroupApplication({
|
||||
groupID: application.groupID,
|
||||
fromUserID: application.userID,
|
||||
handleMsg: "",
|
||||
});
|
||||
const newApplication = {
|
||||
...application,
|
||||
handleResult: ApplicationHandleResult.Agree,
|
||||
};
|
||||
if (isRecv) {
|
||||
updateRecvGroupApplication(newApplication);
|
||||
} else {
|
||||
updateSendGroupApplication(newApplication);
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onReject = useCallback(
|
||||
async (application: GroupApplicationItem, isRecv: boolean) => {
|
||||
try {
|
||||
await IMSDK.refuseGroupApplication({
|
||||
groupID: application.groupID,
|
||||
fromUserID: application.userID,
|
||||
handleMsg: "",
|
||||
});
|
||||
const newApplication = {
|
||||
...application,
|
||||
handleResult: ApplicationHandleResult.Reject,
|
||||
};
|
||||
if (isRecv) {
|
||||
updateRecvGroupApplication(newApplication);
|
||||
} else {
|
||||
updateSendGroupApplication(newApplication);
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<p className="m-5.5 text-base font-extrabold">
|
||||
{t("placeholder.groupNotification")}
|
||||
</p>
|
||||
<div className="flex-1 pb-3">
|
||||
<Virtuoso
|
||||
className="h-full overflow-x-hidden"
|
||||
data={groupApplicationList}
|
||||
itemContent={(_, item) => (
|
||||
<ApplicationItem
|
||||
key={`${item.userID}${item.reqTime}`}
|
||||
source={item}
|
||||
currentUserID={currentUserID}
|
||||
onAccept={onAccept as AccessFunction}
|
||||
onReject={onReject as AccessFunction}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sortArray = (list: GroupApplicationItem[]) => {
|
||||
list.sort((a, b) => {
|
||||
if (a.handleResult === 0 && b.handleResult !== 0) {
|
||||
return -1;
|
||||
} else if (b.handleResult === 0 && a.handleResult !== 0) {
|
||||
return 1;
|
||||
}
|
||||
return b.reqTime - a.reqTime;
|
||||
});
|
||||
return list;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Layout } from "antd";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import ContactSider from "@/pages/contact/ContactSider";
|
||||
|
||||
export const Contact = () => {
|
||||
return (
|
||||
<Layout className="relative z-0 flex-row">
|
||||
<ContactSider />
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
forwardRef,
|
||||
ForwardRefRenderFunction,
|
||||
memo,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type IAlphabetIndexProps = {
|
||||
indexList: string[];
|
||||
scrollToLetter: (idx: number) => void;
|
||||
};
|
||||
|
||||
const AlphabetIndex: ForwardRefRenderFunction<
|
||||
{ updateCurrentLetter: (letter: string) => void },
|
||||
IAlphabetIndexProps
|
||||
> = ({ indexList, scrollToLetter }, ref) => {
|
||||
const [currentAlphabet, setCurrentAlphabet] = useState("");
|
||||
|
||||
const jumpToLetter = (idx: number, letter: string) => {
|
||||
scrollToLetter(idx);
|
||||
setCurrentAlphabet(letter);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
updateCurrentLetter: (letter: string) => setCurrentAlphabet(letter),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-14 z-10 flex scale-90 flex-col items-center">
|
||||
{indexList.map((letter, idx) => (
|
||||
<span
|
||||
className={clsx("my-0.5 cursor-pointer text-xs text-[var(--sub-text)]", {
|
||||
"!text-[#0073D9]": currentAlphabet === letter,
|
||||
})}
|
||||
key={letter}
|
||||
onClick={() => jumpToLetter(idx, letter)}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(AlphabetIndex));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FriendUserItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
|
||||
const FriendListItem = ({
|
||||
friend,
|
||||
showUserCard,
|
||||
}: {
|
||||
friend: FriendUserItem;
|
||||
showUserCard: (userID: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center rounded-md px-3.5 pb-3 pt-2.5 transition-colors hover:bg-[var(--primary-active)]"
|
||||
onClick={() => showUserCard(friend.userID)}
|
||||
>
|
||||
<OIMAvatar size={48} src={friend.faceURL} text={friend.remark || friend.nickname} />
|
||||
<div className="ml-3 truncate text-sm">{friend.remark || friend.nickname}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendListItem;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useRequest } from "ahooks";
|
||||
import { Empty, Spin } from "antd";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import { useContactStore } from "@/store";
|
||||
import { formatContactsByWorker } from "@/utils/contactsFormat";
|
||||
import { emit } from "@/utils/events";
|
||||
|
||||
import AlphabetIndex from "./AlphabetIndex";
|
||||
import FriendListItem from "./FriendListItem";
|
||||
|
||||
export const MyFriends = () => {
|
||||
const { t } = useTranslation();
|
||||
const friendList = useContactStore((state) => state.friendList);
|
||||
const virtuoso = useRef<GroupedVirtuosoHandle>(null);
|
||||
const alphabetRef = useRef<{ updateCurrentLetter: (letter: string) => void }>(null);
|
||||
|
||||
const { data: sectionData, cancel } = useRequest(
|
||||
() => formatContactsByWorker(friendList),
|
||||
{
|
||||
refreshDeps: [friendList],
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToLetter = useCallback(
|
||||
(idx: number) => {
|
||||
const prevNum = sectionData?.groupCounts.slice(0, idx).reduce((a, b) => a + b, 0);
|
||||
console.log(prevNum);
|
||||
|
||||
virtuoso.current?.scrollToIndex({
|
||||
index: prevNum ?? 0,
|
||||
// behavior: "smooth",
|
||||
});
|
||||
},
|
||||
[sectionData?.groupCounts],
|
||||
);
|
||||
|
||||
const showUserCard = useCallback((userID: string) => {
|
||||
emit("OPEN_USER_CARD", {
|
||||
userID,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const determineCurrentGroup = (startIndex: number) => {
|
||||
if (!sectionData) return;
|
||||
|
||||
let currentItemIndex = 0;
|
||||
|
||||
for (
|
||||
let groupIndex = 0;
|
||||
groupIndex < sectionData.groupCounts.length;
|
||||
groupIndex++
|
||||
) {
|
||||
const groupItemCount = sectionData.groupCounts[groupIndex];
|
||||
|
||||
if (startIndex < currentItemIndex + groupItemCount) {
|
||||
alphabetRef.current?.updateCurrentLetter(sectionData.indexList[groupIndex]);
|
||||
break;
|
||||
}
|
||||
|
||||
currentItemIndex += groupItemCount;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-white">
|
||||
<div className="m-5.5 text-base font-extrabold">{t("placeholder.myFriend")}</div>
|
||||
{!sectionData ? (
|
||||
<Spin />
|
||||
) : !sectionData.groupCounts.length ? (
|
||||
<Empty className="mt-[30%]" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="ml-4 mt-4 flex-1 overflow-auto pr-4">
|
||||
<AlphabetIndex
|
||||
ref={alphabetRef}
|
||||
indexList={sectionData.indexList}
|
||||
scrollToLetter={scrollToLetter}
|
||||
/>
|
||||
|
||||
<GroupedVirtuoso
|
||||
ref={virtuoso}
|
||||
groupCounts={sectionData.groupCounts}
|
||||
groupContent={(index) => (
|
||||
<div>
|
||||
<div className="bg-white px-3.5 pb-1 text-[13px] text-[#8E9AB0FF]">
|
||||
{sectionData.indexList[index]}
|
||||
</div>
|
||||
<div className="mx-3.5 mb-3 h-px w-full bg-[#E8EAEFFF] bg-white" />
|
||||
</div>
|
||||
)}
|
||||
itemContent={(index) => {
|
||||
return (
|
||||
<FriendListItem
|
||||
key={sectionData.totalList[index].userID}
|
||||
friend={sectionData.totalList[index]}
|
||||
showUserCard={showUserCard}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
rangeChanged={({ startIndex }) => determineCurrentGroup(startIndex)}
|
||||
className="no-scrollbar h-full overflow-x-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
|
||||
import OIMAvatar from "@/components/OIMAvatar";
|
||||
|
||||
const GroupListItem = ({
|
||||
source,
|
||||
showGroupCard,
|
||||
}: {
|
||||
source: GroupItem;
|
||||
showGroupCard: (group: GroupItem) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row rounded-md px-3.5 py-3 transition-colors hover:bg-[var(--primary-active)]"
|
||||
onClick={() => showGroupCard(source)}
|
||||
>
|
||||
<OIMAvatar size={48} src={source?.faceURL} isgroup />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm">{source.groupName}</p>
|
||||
<p className="text-xs text-[#8E9AB0FF]">{source.memberCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupListItem;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { Select } from "antd";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
import { useContactStore, useUserStore } from "@/store";
|
||||
import { emit } from "@/utils/events";
|
||||
|
||||
import GroupListItem from "./GroupListItem";
|
||||
|
||||
export enum GroupTypeEnum {
|
||||
JoinedGroup,
|
||||
CreatedGroup,
|
||||
}
|
||||
|
||||
export const MyGroups = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectGroup, setSelectGroup] = useState(GroupTypeEnum.CreatedGroup);
|
||||
|
||||
const joinedGroupList = useContactStore((state) => state.groupList);
|
||||
const { userID } = useUserStore((state) => state.selfInfo);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelectGroup(Number(value));
|
||||
};
|
||||
|
||||
const filterGroup = joinedGroupList.filter((group) => {
|
||||
if (selectGroup === GroupTypeEnum.JoinedGroup) {
|
||||
return group.creatorUserID !== userID;
|
||||
} else if (selectGroup === GroupTypeEnum.CreatedGroup) {
|
||||
return group.creatorUserID === userID;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const showGroupCard = useCallback((group: GroupItem) => {
|
||||
emit("OPEN_GROUP_CARD", group);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<div className="m-5.5 flex flex-row justify-between">
|
||||
<p className="text-base font-extrabold">{t("placeholder.myGroup")}</p>
|
||||
<Select
|
||||
defaultValue={String(selectGroup)}
|
||||
popupClassName="p-0"
|
||||
style={{ width: 200 }}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{
|
||||
value: String(GroupTypeEnum.CreatedGroup),
|
||||
label: t("placeholder.myCreated"),
|
||||
},
|
||||
{
|
||||
value: String(GroupTypeEnum.JoinedGroup),
|
||||
label: t("placeholder.myJoined"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="box-border flex-1 overflow-y-auto px-2 pb-3">
|
||||
<Virtuoso
|
||||
className="h-full overflow-x-hidden"
|
||||
data={filterGroup}
|
||||
itemContent={(_, group) => (
|
||||
<GroupListItem
|
||||
key={group.groupID}
|
||||
source={group}
|
||||
showGroupCard={showGroupCard}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ApplicationHandleResult } from "@openim/wasm-client-sdk";
|
||||
import { FriendApplicationItem } from "@openim/wasm-client-sdk/lib/types/entity";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
import ApplicationItem, { AccessFunction } from "@/components/ApplicationItem";
|
||||
import { IMSDK } from "@/layout/MainContentWrap";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useContactStore } from "@/store/contact";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
import { calcApplicationBadge } from "@/utils/imCommon";
|
||||
|
||||
export const NewFriends = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentUserID = useUserStore((state) => state.selfInfo.userID);
|
||||
|
||||
const recvFriendApplicationList = useContactStore(
|
||||
(state) => state.recvFriendApplicationList,
|
||||
);
|
||||
const sendFriendApplicationList = useContactStore(
|
||||
(state) => state.sendFriendApplicationList,
|
||||
);
|
||||
const updateRecvFriendApplication = useContactStore(
|
||||
(state) => state.updateRecvFriendApplication,
|
||||
);
|
||||
const updateSendFriendApplication = useContactStore(
|
||||
(state) => state.updateSendFriendApplication,
|
||||
);
|
||||
|
||||
const friendApplicationList = sortArray(
|
||||
recvFriendApplicationList.concat(sendFriendApplicationList),
|
||||
);
|
||||
|
||||
const onAccept = useCallback(
|
||||
async (application: FriendApplicationItem, isRecv: boolean) => {
|
||||
try {
|
||||
await IMSDK.acceptFriendApplication({
|
||||
toUserID: application.fromUserID,
|
||||
handleMsg: "",
|
||||
});
|
||||
const newApplication = {
|
||||
...application,
|
||||
handleResult: ApplicationHandleResult.Agree,
|
||||
};
|
||||
if (isRecv) {
|
||||
updateRecvFriendApplication(newApplication);
|
||||
} else {
|
||||
updateSendFriendApplication(newApplication);
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onReject = useCallback(
|
||||
async (application: FriendApplicationItem, isRecv: boolean) => {
|
||||
try {
|
||||
await IMSDK.refuseFriendApplication({
|
||||
toUserID: application.fromUserID,
|
||||
handleMsg: "",
|
||||
});
|
||||
const newApplication = {
|
||||
...application,
|
||||
handleResult: ApplicationHandleResult.Reject,
|
||||
};
|
||||
if (isRecv) {
|
||||
updateRecvFriendApplication(newApplication);
|
||||
} else {
|
||||
updateSendFriendApplication(newApplication);
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackToast({ error });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<p className="m-5.5 text-base font-extrabold">{t("placeholder.newFriends")}</p>
|
||||
<div className="flex-1 pb-3">
|
||||
<Virtuoso
|
||||
className="h-full overflow-x-hidden"
|
||||
data={friendApplicationList}
|
||||
itemContent={(_, item) => (
|
||||
<ApplicationItem
|
||||
key={`${
|
||||
currentUserID === item.fromUserID ? item.toUserID : item.fromUserID
|
||||
}${item.createTime}`}
|
||||
source={item}
|
||||
currentUserID={currentUserID}
|
||||
onAccept={onAccept as AccessFunction}
|
||||
onReject={onReject as AccessFunction}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sortArray = (list: FriendApplicationItem[]) => {
|
||||
list.sort((a, b) => {
|
||||
if (a.handleResult === 0 && b.handleResult !== 0) {
|
||||
return -1;
|
||||
} else if (b.handleResult === 0 && a.handleResult !== 0) {
|
||||
return 1;
|
||||
}
|
||||
return b.createTime - a.createTime;
|
||||
});
|
||||
return list;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button, Form, Input } from "antd";
|
||||
import axios from "axios";
|
||||
import { t } from "i18next";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
import { setIMProfile } from "@/utils/storage";
|
||||
|
||||
interface LoginResult {
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: {
|
||||
userID: string;
|
||||
nickname: string;
|
||||
imToken: string;
|
||||
expireTimeSeconds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const LoginForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: { staffNo: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: res } = await axios.post<LoginResult>(
|
||||
`${import.meta.env.VITE_ACCOUNT_URL}/api/login`,
|
||||
{
|
||||
staffNo: values.staffNo.trim(),
|
||||
password: values.password,
|
||||
platformID: window.electronAPI?.getPlatform() ?? 5,
|
||||
},
|
||||
);
|
||||
if (res.code !== 0 || !res.data) {
|
||||
feedbackToast({
|
||||
msg: res.msg || t("toast.accessFailed"),
|
||||
error: res.msg || "login failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { userID, imToken } = res.data;
|
||||
setIMProfile({ chatToken: imToken, imToken, userID });
|
||||
navigate("/chat");
|
||||
} catch (error) {
|
||||
feedbackToast({ msg: "登录失败,请检查网络后重试", error });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="text-xl font-medium">{t("placeholder.welcome")}</div>
|
||||
</div>
|
||||
<Form
|
||||
className="mt-8"
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
labelCol={{ prefixCls: "custom-form-item" }}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("placeholder.staffNo")}
|
||||
name="staffNo"
|
||||
rules={[{ required: true, message: t("toast.inputStaffNo") }]}
|
||||
>
|
||||
<Input allowClear placeholder={t("toast.inputStaffNo")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("placeholder.password")}
|
||||
name="password"
|
||||
rules={[{ required: true, message: t("toast.inputPassword") }]}
|
||||
>
|
||||
<Input.Password allowClear placeholder={t("toast.inputPassword")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-4 mt-10">
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
{t("placeholder.login")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
@@ -0,0 +1,18 @@
|
||||
.login {
|
||||
:global {
|
||||
.ant-form-item-label {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-method-tab {
|
||||
:global {
|
||||
.ant-tabs-nav {
|
||||
margin: 24px 0 12px 0;
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { t } from "i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import login_bg from "@/assets/images/login/login_bg.png";
|
||||
import WindowControlBar from "@/components/WindowControlBar";
|
||||
import { APP_NAME, APP_VERSION, SDK_VERSION } from "@/config";
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import styles from "./index.module.scss";
|
||||
import LoginForm from "./LoginForm";
|
||||
|
||||
export const Login = () => {
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
|
||||
feedbackToast({ msg: t("toast.copySuccess") });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div className="app-drag relative h-10 bg-[var(--top-search-bar)]">
|
||||
<WindowControlBar />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<LeftBar />
|
||||
<div
|
||||
className={`${styles.login} mr-14 h-[450px] w-[350px] rounded-md bg-white p-11`}
|
||||
style={{ boxShadow: "0 0 30px rgba(0,0,0,.1)" }}
|
||||
>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-3 right-3 flex cursor-pointer flex-col items-center text-xs"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<div className="text-[var(--sub-text)]">{`${APP_NAME} ${APP_VERSION}`}</div>
|
||||
<div className="text-[var(--sub-text)]">{SDK_VERSION}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LeftBar = () => {
|
||||
return (
|
||||
<div className="flex min-h-[420]">
|
||||
<div className="mr-14 text-center">
|
||||
<div className="text-2xl">{t("placeholder.title")}</div>
|
||||
<span className="text-sm text-gray-400">{t("placeholder.subTitle")}</span>
|
||||
<img src={login_bg} alt="login_bg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user