build: format the code with prettier

This commit is contained in:
Tristan Yang
2023-05-19 16:31:28 +08:00
parent 7afc132bbb
commit 5bd0183651
259 changed files with 7627 additions and 5607 deletions
+53 -38
View File
@@ -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;
+202 -130
View File
@@ -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;
+9 -7
View File
@@ -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 = () => {
+15 -6
View File
@@ -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>
);
};
+40 -40
View File
@@ -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;
+40 -37
View File
@@ -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;
+19 -11
View File
@@ -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;
+37 -28
View File
@@ -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>
</>
);
};