新增 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:
KIMI
2026-08-09 09:11:06 +08:00
parent b95159f7eb
commit 7ce40e356f
284 changed files with 31599 additions and 7 deletions
@@ -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;
+25
View File
@@ -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>
);
};
+13
View File
@@ -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;
};
+13
View File
@@ -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;
};
+91
View File
@@ -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;
}
}
}
+55
View File
@@ -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>
);
};