build: format the code with prettier
This commit is contained in:
@@ -1,42 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import IconAdd from '@/assets/icons/add.person.svg';
|
||||
import IconBlock from '@/assets/icons/block.svg';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import { useUpdateContactStatusMutation } from '../../../app/services/user';
|
||||
import { ContactAction } from '../../../types/user';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import IconAdd from "@/assets/icons/add.person.svg";
|
||||
import IconBlock from "@/assets/icons/block.svg";
|
||||
import { useUpdateContactStatusMutation } from "../../../app/services/user";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import { ContactAction } from "../../../types/user";
|
||||
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
|
||||
const AddContactTip = (props: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const [updateContactStatus] = useUpdateContactStatusMutation();
|
||||
const { targetUser, enableContact } = useAppSelector(store => {
|
||||
return { targetUser: store.users.byId[props.uid], enableContact: store.server.contact_verification_enable };
|
||||
});
|
||||
const handleContactStatus = (action: ContactAction) => {
|
||||
updateContactStatus({ target_uid: props.uid, action });
|
||||
};
|
||||
const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`;
|
||||
if (!targetUser || !enableContact) return null;
|
||||
if (targetUser.status == "added") return null;
|
||||
const blocked = targetUser.status == "blocked";
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-100 dark:bg-slate-600">
|
||||
<h3 className='text-gray-700 dark:text-gray-300 text-sm font-semibold'>{blocked ? t("contact_block_tip") : t("contact_tip")}</h3>
|
||||
<ul className='flex gap-4'>
|
||||
{!blocked && <li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
|
||||
<IconAdd className="fill-primary-400" />
|
||||
<span>{t("add_contact")}</span>
|
||||
</li>}
|
||||
<li className={itemClass} onClick={blocked ? handleContactStatus.bind(null, "unblock") : handleContactStatus.bind(null, "block")}>
|
||||
<IconBlock className="stroke-primary-400" />
|
||||
<span>{blocked ? t("unblock") : t("block")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
uid: number;
|
||||
};
|
||||
|
||||
export default AddContactTip;
|
||||
const AddContactTip = (props: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const [updateContactStatus] = useUpdateContactStatusMutation();
|
||||
const { targetUser, enableContact } = useAppSelector((store) => {
|
||||
return {
|
||||
targetUser: store.users.byId[props.uid],
|
||||
enableContact: store.server.contact_verification_enable
|
||||
};
|
||||
});
|
||||
const handleContactStatus = (action: ContactAction) => {
|
||||
updateContactStatus({ target_uid: props.uid, action });
|
||||
};
|
||||
const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`;
|
||||
if (!targetUser || !enableContact) return null;
|
||||
if (targetUser.status == "added") return null;
|
||||
const blocked = targetUser.status == "blocked";
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-100 dark:bg-slate-600">
|
||||
<h3 className="text-gray-700 dark:text-gray-300 text-sm font-semibold">
|
||||
{blocked ? t("contact_block_tip") : t("contact_tip")}
|
||||
</h3>
|
||||
<ul className="flex gap-4">
|
||||
{!blocked && (
|
||||
<li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
|
||||
<IconAdd className="fill-primary-400" />
|
||||
<span>{t("add_contact")}</span>
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
className={itemClass}
|
||||
onClick={
|
||||
blocked
|
||||
? handleContactStatus.bind(null, "unblock")
|
||||
: handleContactStatus.bind(null, "block")
|
||||
}
|
||||
>
|
||||
<IconBlock className="stroke-primary-400" />
|
||||
<span>{blocked ? t("unblock") : t("block")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddContactTip;
|
||||
|
||||
@@ -4,152 +4,224 @@ import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import IconCallOff from '@/assets/icons/call.off.svg';
|
||||
import IconCallAnswer from '@/assets/icons/call.svg';
|
||||
import IconMicOff from '@/assets/icons/mic.off.svg';
|
||||
// import IconMic from '@/assets/icons/mic.on.svg';
|
||||
// import IconCamera from '@/assets/icons/camera.svg';
|
||||
// import IconCameraOff from '@/assets/icons/camera.off.svg';
|
||||
// import IconScreen from '@/assets/icons/share.screen.svg';
|
||||
import { updateCallInfo } from "@/app/slices/voice";
|
||||
import { useVoice } from "@/components/Voice";
|
||||
import { playAgoraVideo } from "@/utils";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import { useVoice } from "@/components/Voice";
|
||||
import Operations from "@/components/Voice/Operations";
|
||||
import { playAgoraVideo } from "@/utils";
|
||||
import IconCallOff from "@/assets/icons/call.off.svg";
|
||||
import IconCallAnswer from "@/assets/icons/call.svg";
|
||||
import IconMicOff from "@/assets/icons/mic.off.svg";
|
||||
|
||||
type VoicingMember = {
|
||||
id: number,
|
||||
muted: boolean,
|
||||
video: boolean,
|
||||
speaking: boolean,
|
||||
name: string,
|
||||
avatar?: string
|
||||
}
|
||||
id: number;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
speaking: boolean;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
type BlockProps = {
|
||||
onlyToSelf: boolean,
|
||||
sendByMe: boolean;
|
||||
connected: boolean;
|
||||
from: VoicingMember,
|
||||
to: VoicingMember
|
||||
}
|
||||
onlyToSelf: boolean;
|
||||
sendByMe: boolean;
|
||||
connected: boolean;
|
||||
from: VoicingMember;
|
||||
to: VoicingMember;
|
||||
};
|
||||
const VoicingBlocks = ({ onlyToSelf, sendByMe, connected, from, to }: BlockProps) => {
|
||||
const blocks = [from, to].map(({ id, speaking, name, avatar, video, muted }, idx) => {
|
||||
const showWaiting = idx == 1 && !connected && !sendByMe && !onlyToSelf;
|
||||
const showToWaiting = idx == 1 && !connected && sendByMe;
|
||||
const isMyself = sendByMe ? idx == 0 : idx == 1;
|
||||
const hiddenFrom = onlyToSelf && idx == 0;
|
||||
return <div key={id} className={clsx("group relative flex-center", video ? "w-[500px] h-[280px] overflow-hidden rounded" : "", hiddenFrom && "hidden")}>
|
||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity", showToWaiting && "animate-pulse", showWaiting && "opacity-40")}>
|
||||
{speaking && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", "w-[86px] h-[86px]")}></div>}
|
||||
{showToWaiting && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-400", "w-[88px] h-[88px]")}></div>}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className={clsx("z-20 w-full h-full rounded-full object-cover")}
|
||||
src={avatar}
|
||||
name={name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-30 absolute left-0 top-0 w-full h-full" id={`CAMERA_${id}`}>
|
||||
{/* camera video */}
|
||||
</div>
|
||||
{video && <span className={clsx("text-gray-300 bg-black/50 rounded absolute z-40", "left-1 bottom-1 py-1 px-2 text-xs")} title={name}>
|
||||
{name}
|
||||
</span>}
|
||||
{muted && !isMyself && <span className={clsx("bg-black/50 rounded absolute z-40 right-1 bottom-1 p-1", video && "invisible group-hover:visible")} title={name}>
|
||||
<IconMicOff className="w-4 h-4 fill-gray-300" />
|
||||
</span>}
|
||||
</div>;
|
||||
}
|
||||
const blocks = [from, to].map(({ id, speaking, name, avatar, video, muted }, idx) => {
|
||||
const showWaiting = idx == 1 && !connected && !sendByMe && !onlyToSelf;
|
||||
const showToWaiting = idx == 1 && !connected && sendByMe;
|
||||
const isMyself = sendByMe ? idx == 0 : idx == 1;
|
||||
const hiddenFrom = onlyToSelf && idx == 0;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={clsx(
|
||||
"group relative flex-center",
|
||||
video ? "w-[500px] h-[280px] overflow-hidden rounded" : "",
|
||||
hiddenFrom && "hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-20 h-20 flex shrink-0 relative transition-opacity",
|
||||
showToWaiting && "animate-pulse",
|
||||
showWaiting && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{speaking && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking",
|
||||
"w-[86px] h-[86px]"
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
{showToWaiting && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-400",
|
||||
"w-[88px] h-[88px]"
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className={clsx("z-20 w-full h-full rounded-full object-cover")}
|
||||
src={avatar}
|
||||
name={name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-30 absolute left-0 top-0 w-full h-full" id={`CAMERA_${id}`}>
|
||||
{/* camera video */}
|
||||
</div>
|
||||
{video && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-gray-300 bg-black/50 rounded absolute z-40",
|
||||
"left-1 bottom-1 py-1 px-2 text-xs"
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
{muted && !isMyself && (
|
||||
<span
|
||||
className={clsx(
|
||||
"bg-black/50 rounded absolute z-40 right-1 bottom-1 p-1",
|
||||
video && "invisible group-hover:visible"
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<IconMicOff className="w-4 h-4 fill-gray-300" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <>{blocks}</>;
|
||||
});
|
||||
return <>{blocks}</>;
|
||||
};
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
uid: number;
|
||||
};
|
||||
const DMVoice = ({ uid }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
const { voice: {
|
||||
callingFrom, callingTo, voicingMembers
|
||||
}, userData, loginUser } = useAppSelector(store => {
|
||||
return {
|
||||
voice: store.voice,
|
||||
userData: store.users.byId,
|
||||
loginUser: store.authData.user
|
||||
};
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
const {
|
||||
voice: { callingFrom, callingTo, voicingMembers },
|
||||
userData,
|
||||
loginUser
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
voice: store.voice,
|
||||
userData: store.users.byId,
|
||||
loginUser: store.authData.user
|
||||
};
|
||||
});
|
||||
const { leave, joinVoice, joining } = useVoice({ id: callingTo, context: "dm" });
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach((id) => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
const { leave, joinVoice, joining } = useVoice({ id: callingTo, context: "dm" });
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach(id => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
}, [voicingMembers.ids]);
|
||||
if (![callingFrom, callingTo].includes(uid)) return null;
|
||||
}, [voicingMembers.ids]);
|
||||
if (![callingFrom, callingTo].includes(uid)) return null;
|
||||
|
||||
const { name: fromUsername, avatar: fromAvatar } = userData[callingFrom];
|
||||
const { name: toUsername, avatar: toAvatar, uid: toUid } = userData[callingTo];
|
||||
const sendByMe = loginUser?.uid !== toUid;
|
||||
const onlyToSelf = voicingMembers.ids.length == 1 && voicingMembers.ids[0] == callingTo;
|
||||
const handleCancel = () => {
|
||||
console.log('cancel');
|
||||
if (sendByMe || voicingMembers.ids.length == 2 || onlyToSelf) {
|
||||
leave();
|
||||
}
|
||||
dispatch(updateCallInfo({ from: 0, to: 0 }));
|
||||
};
|
||||
const handleAnswer = () => {
|
||||
joinVoice();
|
||||
};
|
||||
const connected = voicingMembers.ids.length == 2;
|
||||
// const { muted, shareScreen, video } = voicingInfo ?? {} as VoicingInfo;
|
||||
const { speakingVolume: toSpeakingVol = 0, muted: toMuted = false, video: toVideo = false, shareScreen: toScreen = false } = voicingMembers.byId[callingTo] ?? {};
|
||||
const toSpeaking = toSpeakingVol > 50;
|
||||
const { speakingVolume: fromSpeakingVol = 0, muted: fromMuted = false, video: fromVideo = false, shareScreen: fromScreen = false } = voicingMembers.byId[callingFrom] ?? {};
|
||||
const fromSpeaking = fromSpeakingVol > 50;
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-200 dark:bg-slate-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<VoicingBlocks onlyToSelf={onlyToSelf} sendByMe={sendByMe} connected={connected} from={{
|
||||
id: callingFrom,
|
||||
muted: fromMuted,
|
||||
video: fromVideo || fromScreen,
|
||||
speaking: fromSpeaking,
|
||||
name: fromUsername,
|
||||
avatar: fromAvatar
|
||||
}} to={{
|
||||
muted: toMuted,
|
||||
id: callingTo,
|
||||
video: toVideo || toScreen,
|
||||
speaking: toSpeaking,
|
||||
name: toUsername,
|
||||
avatar: toAvatar
|
||||
}} />
|
||||
</div>
|
||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||
{(sendByMe || connected || onlyToSelf) && <Tooltip tip={"Leave"} placement="top">
|
||||
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded'>
|
||||
<IconCallOff className="w-6 h-6" />
|
||||
</button>
|
||||
</Tooltip>}
|
||||
{!sendByMe && !connected && !onlyToSelf &&
|
||||
<Tooltip tip={"Answer"} placement="top">
|
||||
<button disabled={joining} onClick={handleAnswer} className='flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded'>
|
||||
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
{connected && <>
|
||||
<Operations id={callingTo} context="dm" mode="dm" />
|
||||
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const { name: fromUsername, avatar: fromAvatar } = userData[callingFrom];
|
||||
const { name: toUsername, avatar: toAvatar, uid: toUid } = userData[callingTo];
|
||||
const sendByMe = loginUser?.uid !== toUid;
|
||||
const onlyToSelf = voicingMembers.ids.length == 1 && voicingMembers.ids[0] == callingTo;
|
||||
const handleCancel = () => {
|
||||
console.log("cancel");
|
||||
if (sendByMe || voicingMembers.ids.length == 2 || onlyToSelf) {
|
||||
leave();
|
||||
}
|
||||
dispatch(updateCallInfo({ from: 0, to: 0 }));
|
||||
};
|
||||
const handleAnswer = () => {
|
||||
joinVoice();
|
||||
};
|
||||
const connected = voicingMembers.ids.length == 2;
|
||||
// const { muted, shareScreen, video } = voicingInfo ?? {} as VoicingInfo;
|
||||
const {
|
||||
speakingVolume: toSpeakingVol = 0,
|
||||
muted: toMuted = false,
|
||||
video: toVideo = false,
|
||||
shareScreen: toScreen = false
|
||||
} = voicingMembers.byId[callingTo] ?? {};
|
||||
const toSpeaking = toSpeakingVol > 50;
|
||||
const {
|
||||
speakingVolume: fromSpeakingVol = 0,
|
||||
muted: fromMuted = false,
|
||||
video: fromVideo = false,
|
||||
shareScreen: fromScreen = false
|
||||
} = voicingMembers.byId[callingFrom] ?? {};
|
||||
const fromSpeaking = fromSpeakingVol > 50;
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-200 dark:bg-slate-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<VoicingBlocks
|
||||
onlyToSelf={onlyToSelf}
|
||||
sendByMe={sendByMe}
|
||||
connected={connected}
|
||||
from={{
|
||||
id: callingFrom,
|
||||
muted: fromMuted,
|
||||
video: fromVideo || fromScreen,
|
||||
speaking: fromSpeaking,
|
||||
name: fromUsername,
|
||||
avatar: fromAvatar
|
||||
}}
|
||||
to={{
|
||||
muted: toMuted,
|
||||
id: callingTo,
|
||||
video: toVideo || toScreen,
|
||||
speaking: toSpeaking,
|
||||
name: toUsername,
|
||||
avatar: toAvatar
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||
{(sendByMe || connected || onlyToSelf) && (
|
||||
<Tooltip tip={"Leave"} placement="top">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded"
|
||||
>
|
||||
<IconCallOff className="w-6 h-6" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sendByMe && !connected && !onlyToSelf && (
|
||||
<Tooltip tip={"Answer"} placement="top">
|
||||
<button
|
||||
disabled={joining}
|
||||
onClick={handleAnswer}
|
||||
className="flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded"
|
||||
>
|
||||
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{connected && (
|
||||
<>
|
||||
<Operations id={callingTo} context="dm" mode="dm" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DMVoice;
|
||||
export default DMVoice;
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// import { useEffect } from "react";
|
||||
import { ChatPrefixes } from "@/app/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatPrefixes } from "@/app/config";
|
||||
import { ChatContext } from "@/types/common";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
name: string
|
||||
context: ChatContext;
|
||||
name: string;
|
||||
};
|
||||
const DnDTip = ({ context, name }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`z-50 flex-center absolute left-0 top-0 w-full h-full bg-black/50`}
|
||||
>
|
||||
<div className={`z-50 flex-center absolute left-0 top-0 w-full h-full bg-black/50`}>
|
||||
<div className={`p-4 drop-shadow-md rounded-lg bg-primary-300`}>
|
||||
<div className="p-4 pt-16 border-2 border-dashed border-primary-300 rounded-md flex flex-col items-center text-white">
|
||||
<h4 className="text-xl font-semibold">{`${t("send_to")} ${ChatPrefixes[context]}${name}`}</h4>
|
||||
<h4 className="text-xl font-semibold">{`${t("send_to")} ${
|
||||
ChatPrefixes[context]
|
||||
}${name}`}</h4>
|
||||
<span className="text-sm">Photos accept jpg, png, max size limit to 10M.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
// type Props = {};
|
||||
|
||||
const LicenseUpgradeTip = () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// import { useEffect } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { resetAuthData } from "@/app/slices/auth.data";
|
||||
import Button from "@/components/styled/Button";
|
||||
import useLogout from "@/hooks/useLogout";
|
||||
|
||||
type Props = {
|
||||
placement?: "session" | "chat"
|
||||
placement?: "session" | "chat";
|
||||
};
|
||||
|
||||
const LoginTip = ({ placement = "chat" }: Props) => {
|
||||
@@ -23,14 +25,21 @@ const LoginTip = ({ placement = "chat" }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-between bg-slate-200/80 dark:bg-gray-800 rounded-lg p-4 border border-solid border-gray-200 dark:border-gray-500",
|
||||
placement == "session" ? "!w-[96%] md:hidden fixed bottom-2 left-1/2 -translate-x-1/2" : "w-full"
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center justify-between bg-slate-200/80 dark:bg-gray-800 rounded-lg p-4 border border-solid border-gray-200 dark:border-gray-500",
|
||||
placement == "session"
|
||||
? "!w-[96%] md:hidden fixed bottom-2 left-1/2 -translate-x-1/2"
|
||||
: "w-full"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs md:text-base text-gray-400 dark:text-gray-100">
|
||||
<i className="mr-2 text-xs md:text-lg ">👋</i>
|
||||
{t("sign_in_tip")}
|
||||
</span>
|
||||
<Button onClick={handleSignIn} className="small">{ct("action.login")}</Button>
|
||||
<Button onClick={handleSignIn} className="small">
|
||||
{ct("action.login")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import clsx from 'clsx';
|
||||
// import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import getUnreadCount from '../utils';
|
||||
import { ChatContext } from '../../../types/common';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import { ChatContext } from "../../../types/common";
|
||||
import getUnreadCount from "../utils";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
id: number,
|
||||
scrollToBottom?: () => void
|
||||
}
|
||||
context: ChatContext;
|
||||
id: number;
|
||||
scrollToBottom?: () => void;
|
||||
};
|
||||
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
||||
const NewMessageBottomTip = ({ context, id, scrollToBottom }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const {
|
||||
readIndex,
|
||||
mids,
|
||||
messageData,
|
||||
loginUid,
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
readIndex: context == "channel" ? store.footprint.readChannels[id] : store.footprint.readUsers[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
loginUid: store.authData.user?.uid ?? 0,
|
||||
data: context == "channel" ? store.channels.byId[id] : undefined,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const { unreads = 0 } = getUnreadCount({
|
||||
mids,
|
||||
readIndex,
|
||||
messageData,
|
||||
loginUid
|
||||
});
|
||||
console.log("unreads", unreads);
|
||||
const { t } = useTranslation("chat");
|
||||
const { readIndex, mids, messageData, loginUid } = useAppSelector((store) => {
|
||||
return {
|
||||
readIndex:
|
||||
context == "channel" ? store.footprint.readChannels[id] : store.footprint.readUsers[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
loginUid: store.authData.user?.uid ?? 0,
|
||||
data: context == "channel" ? store.channels.byId[id] : undefined,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const { unreads = 0 } = getUnreadCount({
|
||||
mids,
|
||||
readIndex,
|
||||
messageData,
|
||||
loginUid
|
||||
});
|
||||
console.log("unreads", unreads);
|
||||
|
||||
|
||||
return (
|
||||
<aside className={clsx(`z-[999] absolute bottom-20 right-4
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
`z-[999] absolute bottom-20 right-4
|
||||
justify-between text-xs
|
||||
rounded-full py-1 px-3 text-white
|
||||
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
|
||||
unreads > 0 ? "flex" : "hidden")}>
|
||||
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
|
||||
</aside>
|
||||
);
|
||||
unreads > 0 ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMessageBottomTip;
|
||||
export default NewMessageBottomTip;
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { triggerScrollHeight } from './MessageFeed';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { triggerScrollHeight } from "./MessageFeed";
|
||||
|
||||
type Props = {
|
||||
count: number,
|
||||
queryKey: string
|
||||
}
|
||||
count: number;
|
||||
queryKey: string;
|
||||
};
|
||||
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
||||
const NewMessageBottomTip = ({ count, queryKey }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation("chat");
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
const { scrollHeight, scrollTop, offsetHeight } = container;
|
||||
const deltaNum = scrollHeight - scrollTop - offsetHeight;
|
||||
const showTheTip = deltaNum > triggerScrollHeight && count > 0;
|
||||
console.log("show the tip", showTheTip);
|
||||
setVisible(showTheTip);
|
||||
}
|
||||
}, [queryKey, count]);
|
||||
const handleMarkRead = () => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
// scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<aside className={clsx(`sticky top-0
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation("chat");
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
const { scrollHeight, scrollTop, offsetHeight } = container;
|
||||
const deltaNum = scrollHeight - scrollTop - offsetHeight;
|
||||
const showTheTip = deltaNum > triggerScrollHeight && count > 0;
|
||||
console.log("show the tip", showTheTip);
|
||||
setVisible(showTheTip);
|
||||
}
|
||||
}, [queryKey, count]);
|
||||
const handleMarkRead = () => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
// scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
`sticky top-0
|
||||
justify-between text-xs
|
||||
w-[95%] rounded-b-lg px-3 py-1 text-white z-10
|
||||
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
|
||||
visible ? "flex" : "hidden")}>
|
||||
<span> {t("new_msg", { num: count })}</span>
|
||||
<button onClick={handleMarkRead}>
|
||||
{t("mark_read")}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
visible ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<span> {t("new_msg", { num: count })}</span>
|
||||
<button onClick={handleMarkRead}>{t("mark_read")}</button>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMessageBottomTip;
|
||||
export default NewMessageBottomTip;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { FC, useState } from "react";
|
||||
import { useKey } from "rooks";
|
||||
import toast from "react-hot-toast";
|
||||
import useDeleteMessage from "@/hooks/useDeleteMessage";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import { useKey } from "rooks";
|
||||
|
||||
import { updateSelectMessages } from "@/app/slices/ui";
|
||||
import IconForward from "@/assets/icons/forward.svg";
|
||||
import IconBookmark from "@/assets/icons/bookmark.svg";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import IconClose from "@/assets/icons/close.circle.svg";
|
||||
import ForwardModal from "@/components/ForwardModal";
|
||||
import DeleteMessageConfirmModal from "@/components/DeleteMessageConfirm";
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import DeleteMessageConfirmModal from "@/components/DeleteMessageConfirm";
|
||||
import ForwardModal from "@/components/ForwardModal";
|
||||
import useDeleteMessage from "@/hooks/useDeleteMessage";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import IconBookmark from "@/assets/icons/bookmark.svg";
|
||||
import IconClose from "@/assets/icons/close.circle.svg";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import IconForward from "@/assets/icons/forward.svg";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext;
|
||||
@@ -66,10 +67,17 @@ const Operations: FC<Props> = ({ context, id }) => {
|
||||
<button className={optClass} onClick={handleFav}>
|
||||
<IconBookmark />
|
||||
</button>
|
||||
<button className={optClass} disabled={!canDel} onClick={toggleDeleteModal.bind(null, false)}>
|
||||
<button
|
||||
className={optClass}
|
||||
disabled={!canDel}
|
||||
onClick={toggleDeleteModal.bind(null, false)}
|
||||
>
|
||||
<IconDelete />
|
||||
</button>
|
||||
<IconClose className="cursor-pointer absolute right-5 top-1/2 -translate-y-1/2" onClick={handleClose} />
|
||||
<IconClose
|
||||
className="cursor-pointer absolute right-5 top-1/2 -translate-y-1/2"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
{forwardModalVisible && <ForwardModal mids={mids} closeModal={toggleForwardModal} />}
|
||||
{deleteModalVisible && (
|
||||
|
||||
@@ -1,54 +1,60 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Waveform } from '@uiball/loaders';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { Waveform } from "@uiball/loaders";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import EditIcon from "@/assets/icons/edit.svg";
|
||||
|
||||
type ChannelHeaderProps = {
|
||||
cid: number
|
||||
}
|
||||
cid: number;
|
||||
};
|
||||
const ChannelHeader = ({ cid }: ChannelHeaderProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation("chat");
|
||||
const { data, loginUser } = useAppSelector(store => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
data: store.channels.byId[cid]
|
||||
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="pt-14 px-1 md:px-0 flex flex-col items-start gap-2">
|
||||
<h2 className="font-bold text-4xl dark:text-white">{t("welcome_channel", { name: data?.name })}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name: data?.name })} </p>
|
||||
{loginUser?.is_admin && (
|
||||
<NavLink to={`/setting/channel/${cid}/overview?f=${pathname}`} className="flex items-center gap-1 bg-clip-text text-fill-transparent bg-gradient-to-r from-blue-500 to-primary-400 ">
|
||||
<EditIcon className="w-4 h-4 fill-blue-500" />
|
||||
{t("edit_channel")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation("chat");
|
||||
const { data, loginUser } = useAppSelector((store) => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
data: store.channels.byId[cid]
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="pt-14 px-1 md:px-0 flex flex-col items-start gap-2">
|
||||
<h2 className="font-bold text-4xl dark:text-white">
|
||||
{t("welcome_channel", { name: data?.name })}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name: data?.name })} </p>
|
||||
{loginUser?.is_admin && (
|
||||
<NavLink
|
||||
to={`/setting/channel/${cid}/overview?f=${pathname}`}
|
||||
className="flex items-center gap-1 bg-clip-text text-fill-transparent bg-gradient-to-r from-blue-500 to-primary-400 "
|
||||
>
|
||||
<EditIcon className="w-4 h-4 fill-blue-500" />
|
||||
{t("edit_channel")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
context?: {
|
||||
id: number,
|
||||
isChannel: boolean,
|
||||
loadingMore: boolean
|
||||
}
|
||||
}
|
||||
context?: {
|
||||
id: number;
|
||||
isChannel: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
};
|
||||
const CustomHeader = ({ context }: Props) => {
|
||||
if (!context) return null;
|
||||
const { id, isChannel, loadingMore } = context;
|
||||
return <>
|
||||
{isChannel ? <ChannelHeader cid={id} /> : null}
|
||||
<div className={clsx("mt-2 w-full py-2 ", loadingMore ? "flex-center" : "hidden")} >
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
</>;
|
||||
if (!context) return null;
|
||||
const { id, isChannel, loadingMore } = context;
|
||||
return (
|
||||
<>
|
||||
{isChannel ? <ChannelHeader cid={id} /> : null}
|
||||
<div className={clsx("mt-2 w-full py-2 ", loadingMore ? "flex-center" : "hidden")}>
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomHeader;
|
||||
export default CustomHeader;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
const CustomList = forwardRef(({ style, ...props }, ref) => {
|
||||
// @ts-ignore
|
||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
||||
// @ts-ignore
|
||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
|
||||
export default CustomList;
|
||||
export default CustomList;
|
||||
|
||||
@@ -1,147 +1,154 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
// import clsx from 'clsx';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
import { useDebounce } from 'rooks';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useDebounce } from "rooks";
|
||||
|
||||
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from '@/app/services/message';
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { renderMessageFragment } from '../../utils';
|
||||
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from "@/app/services/message";
|
||||
import { updateHistoryMark } from "@/app/slices/footprint";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import { renderMessageFragment } from "../../utils";
|
||||
import NewMessageBottomTip from "../NewMessageBottomTip";
|
||||
import CustomList from './CustomList';
|
||||
import CustomHeader from './CustomHeader';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateHistoryMark } from '@/app/slices/footprint';
|
||||
import { ChatContext } from '@/types/common';
|
||||
import CustomHeader from "./CustomHeader";
|
||||
import CustomList from "./CustomList";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
id: number
|
||||
}
|
||||
context: ChatContext;
|
||||
id: number;
|
||||
};
|
||||
// const firstMsgIndex = 10000;
|
||||
// let prevMids: number[] = [];
|
||||
const VirtualMessageFeed = ({ context, id }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery();
|
||||
const vList = useRef<VirtuosoHandle | null>(null);
|
||||
const [updateReadIndex] = useReadMessageMutation();
|
||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
||||
const {
|
||||
historyMid = "",
|
||||
mids = [],
|
||||
selects,
|
||||
messageData,
|
||||
loginUser,
|
||||
footprint
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
historyMid: context == "dm" ? store.footprint.historyUsers[id] : store.footprint.historyChannels[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
footprint: store.footprint,
|
||||
loginUser: store.authData.user,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] =
|
||||
useLazyLoadMoreMessagesQuery();
|
||||
const vList = useRef<VirtuosoHandle | null>(null);
|
||||
const [updateReadIndex] = useReadMessageMutation();
|
||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
||||
const {
|
||||
historyMid = "",
|
||||
mids = [],
|
||||
selects,
|
||||
messageData,
|
||||
loginUser,
|
||||
footprint
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
historyMid:
|
||||
context == "dm" ? store.footprint.historyUsers[id] : store.footprint.historyChannels[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
footprint: store.footprint,
|
||||
loginUser: store.authData.user,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
if (historyData.length == 0) {
|
||||
// 到顶了
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: "reached" }));
|
||||
} else {
|
||||
// 记录最新的mid
|
||||
const [{ mid }] = historyData;
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
||||
}
|
||||
}
|
||||
}, [isSuccess, historyData, mids, context, id]);
|
||||
// useEffect(() => {
|
||||
// console.log("diff mids", prevMids, mids);
|
||||
// const newCount = mids.length - prevMids.length;
|
||||
// setFirstItemIndex((prev) => prev - newCount);
|
||||
// }, [mids]);
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
if (historyData.length == 0) {
|
||||
// 到顶了
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: "reached" }));
|
||||
} else {
|
||||
// 记录最新的mid
|
||||
const [{ mid }] = historyData;
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
||||
}
|
||||
}
|
||||
}, [isSuccess, historyData, mids, context, id]);
|
||||
// useEffect(() => {
|
||||
// console.log("diff mids", prevMids, mids);
|
||||
// const newCount = mids.length - prevMids.length;
|
||||
// setFirstItemIndex((prev) => prev - newCount);
|
||||
// }, [mids]);
|
||||
|
||||
// 加载更多
|
||||
const handleTopStateChange = (isTop: boolean) => {
|
||||
console.log("reach top ", isTop);
|
||||
if (isTop) {
|
||||
if (historyMid === "reached") return;
|
||||
let lastMid = mids.slice(0, 1)[0];
|
||||
if (historyMid) {
|
||||
lastMid = +historyMid;
|
||||
}
|
||||
// prevMids = mids;
|
||||
loadMoreMessage({ context, id, mid: lastMid });
|
||||
}
|
||||
};
|
||||
// 自动跟随
|
||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
||||
const ts = new Date().getTime();
|
||||
// tricky
|
||||
const isSentByMyself = ts - lastMid < 1000;
|
||||
if (isAtBottom || isSentByMyself) {
|
||||
return isAtBottom ? "smooth" : true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 滚动到底部
|
||||
const handleScrollBottom = useCallback(() => {
|
||||
const vl = vList!.current;
|
||||
if (vl) {
|
||||
vl.scrollToIndex(mids.length - 1);
|
||||
}
|
||||
}, [mids]);
|
||||
const handleBottomStateChange = (bottom: boolean) => {
|
||||
setAtBottom(bottom);
|
||||
};
|
||||
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
|
||||
return <>
|
||||
<Virtuoso
|
||||
// logLevel={LogLevel.DEBUG}
|
||||
overscan={50}
|
||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||
className='px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll'
|
||||
ref={vList}
|
||||
components={{
|
||||
List: CustomList,
|
||||
Header: CustomHeader,
|
||||
}}
|
||||
// firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={mids.length - 1}
|
||||
// startReached={handleLoadMore}
|
||||
data={mids}
|
||||
atTopThreshold={context == "channel" ? 160 : 0}
|
||||
atTopStateChange={handleTopStateChange}
|
||||
atBottomStateChange={handleBottomStateChange}
|
||||
atBottomThreshold={400}
|
||||
followOutput={handleFollowOutput}
|
||||
itemContent={(idx, mid) => {
|
||||
// 计算出真正的index
|
||||
// const idx = index - firstItemIndex;
|
||||
const curr = messageData[mid];
|
||||
if (!curr) return <div className='w-full h-[1px] invisible'></div>;
|
||||
const isFirst = idx == 0;
|
||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
||||
const read = curr?.from_uid == loginUser?.uid || mid <= readIndex;
|
||||
return renderMessageFragment({
|
||||
selectMode: !!selects,
|
||||
updateReadIndex: updateReadDebounced,
|
||||
read,
|
||||
prev,
|
||||
curr,
|
||||
contextId: id,
|
||||
context
|
||||
});
|
||||
}} />
|
||||
{!atBottom && <NewMessageBottomTip context={context} id={id} scrollToBottom={handleScrollBottom} />}
|
||||
// 加载更多
|
||||
const handleTopStateChange = (isTop: boolean) => {
|
||||
console.log("reach top ", isTop);
|
||||
if (isTop) {
|
||||
if (historyMid === "reached") return;
|
||||
let lastMid = mids.slice(0, 1)[0];
|
||||
if (historyMid) {
|
||||
lastMid = +historyMid;
|
||||
}
|
||||
// prevMids = mids;
|
||||
loadMoreMessage({ context, id, mid: lastMid });
|
||||
}
|
||||
};
|
||||
// 自动跟随
|
||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
||||
const ts = new Date().getTime();
|
||||
// tricky
|
||||
const isSentByMyself = ts - lastMid < 1000;
|
||||
if (isAtBottom || isSentByMyself) {
|
||||
return isAtBottom ? "smooth" : true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 滚动到底部
|
||||
const handleScrollBottom = useCallback(() => {
|
||||
const vl = vList!.current;
|
||||
if (vl) {
|
||||
vl.scrollToIndex(mids.length - 1);
|
||||
}
|
||||
}, [mids]);
|
||||
const handleBottomStateChange = (bottom: boolean) => {
|
||||
setAtBottom(bottom);
|
||||
};
|
||||
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
|
||||
return (
|
||||
<>
|
||||
<Virtuoso
|
||||
// logLevel={LogLevel.DEBUG}
|
||||
overscan={50}
|
||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||
className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll"
|
||||
ref={vList}
|
||||
components={{
|
||||
List: CustomList,
|
||||
Header: CustomHeader
|
||||
}}
|
||||
// firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={mids.length - 1}
|
||||
// startReached={handleLoadMore}
|
||||
data={mids}
|
||||
atTopThreshold={context == "channel" ? 160 : 0}
|
||||
atTopStateChange={handleTopStateChange}
|
||||
atBottomStateChange={handleBottomStateChange}
|
||||
atBottomThreshold={400}
|
||||
followOutput={handleFollowOutput}
|
||||
itemContent={(idx, mid) => {
|
||||
// 计算出真正的index
|
||||
// const idx = index - firstItemIndex;
|
||||
const curr = messageData[mid];
|
||||
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
||||
const isFirst = idx == 0;
|
||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
||||
const read = curr?.from_uid == loginUser?.uid || mid <= readIndex;
|
||||
return renderMessageFragment({
|
||||
selectMode: !!selects,
|
||||
updateReadIndex: updateReadDebounced,
|
||||
read,
|
||||
prev,
|
||||
curr,
|
||||
contextId: id,
|
||||
context
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!atBottom && (
|
||||
<NewMessageBottomTip context={context} id={id} scrollToBottom={handleScrollBottom} />
|
||||
)}
|
||||
</>
|
||||
;
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualMessageFeed;
|
||||
export default VirtualMessageFeed;
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { useRef, useEffect, FC, ReactElement } from "react";
|
||||
import { FC, ReactElement, useEffect, useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { NativeTypes } from "react-dnd-html5-backend";
|
||||
import clsx from "clsx";
|
||||
import { toast } from "react-hot-toast";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
||||
import Send from "@/components/Send";
|
||||
import Operations from "./Operations";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import LoginTip from "./LoginTip";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import LicenseUpgradeTip from "./LicenseOutdatedTip";
|
||||
import DnDTip from "./DnDTip";
|
||||
import IconWarning from '@/assets/icons/warning.svg';
|
||||
import ImagePreview from "@/components/ImagePreview";
|
||||
|
||||
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||
import DMVoice from "./DMVoicing";
|
||||
import AddContactTip from "./AddContactTip";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import ImagePreview from "@/components/ImagePreview";
|
||||
import Send from "@/components/Send";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import IconWarning from "@/assets/icons/warning.svg";
|
||||
import AddContactTip from "./AddContactTip";
|
||||
import DMVoice from "./DMVoicing";
|
||||
import DnDTip from "./DnDTip";
|
||||
import LicenseUpgradeTip from "./LicenseOutdatedTip";
|
||||
import LoginTip from "./LoginTip";
|
||||
import Operations from "./Operations";
|
||||
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||
|
||||
interface Props {
|
||||
readonly?: boolean;
|
||||
@@ -62,10 +60,10 @@ const Layout: FC<Props> = ({
|
||||
// console.log("iii", inputMode);
|
||||
if (inputMode !== "text") {
|
||||
toast("DnD not allowed in this input mode", {
|
||||
icon: <IconWarning className="w-5 h-5" />,
|
||||
icon: <IconWarning className="w-5 h-5" />
|
||||
});
|
||||
return;
|
||||
};
|
||||
}
|
||||
if (files.length) {
|
||||
const filesData = files.map((file) => {
|
||||
const { size, type, name } = file;
|
||||
@@ -99,8 +97,11 @@ const Layout: FC<Props> = ({
|
||||
<section ref={drop} className={`relative h-full w-full rounded-r-2xl flex`}>
|
||||
<main className="flex flex-col flex-1">
|
||||
{header}
|
||||
<div className="w-full h-full flex items-start justify-between relative" >
|
||||
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
|
||||
<div className="w-full h-full flex items-start justify-between relative">
|
||||
<div
|
||||
className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full"
|
||||
ref={messagesContainer}
|
||||
>
|
||||
{context == "dm" && <DMVoice uid={to} />}
|
||||
{context == "dm" && <AddContactTip uid={to} />}
|
||||
{/* 消息流 */}
|
||||
@@ -121,15 +122,23 @@ const Layout: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{aside && <div className={clsx("flex z-50 p-3 absolute right-0 top-14 md:right-0 md:top-0 md:translate-x-full flex-col")}>
|
||||
{aside}
|
||||
</div>}
|
||||
{users && <div className="hidden md:block">{users}</div>}
|
||||
{voice && <div className="z-10 max-md:absolute max-md:right-11 max-md:top-14 max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">{voice}</div>}
|
||||
{!readonly && inputMode == "text" && isActive && (
|
||||
<DnDTip context={context} name={name} />
|
||||
{aside && (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex z-50 p-3 absolute right-0 top-14 md:right-0 md:top-0 md:translate-x-full flex-col"
|
||||
)}
|
||||
>
|
||||
{aside}
|
||||
</div>
|
||||
)}
|
||||
</section >
|
||||
{users && <div className="hidden md:block">{users}</div>}
|
||||
{voice && (
|
||||
<div className="z-10 max-md:absolute max-md:right-11 max-md:top-14 max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">
|
||||
{voice}
|
||||
</div>
|
||||
)}
|
||||
{!readonly && inputMode == "text" && isActive && <DnDTip context={context} name={name} />}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user