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
-1
View File
@@ -1,4 +1,3 @@
export default function NotFoundPage() {
return <div>404 page</div>;
}
+67 -61
View File
@@ -1,68 +1,74 @@
import { useEffect, FC } from 'react';
import { FC, useEffect } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
import { useLoginMutation } from "@/app/services/auth";
import toast from "react-hot-toast";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import { useTranslation } from 'react-i18next';
import StyledButton from '../../components/styled/Button';
import StyledButton from "../../components/styled/Button";
export type GithubLoginSource = "widget" | "webapp"
export type GithubLoginSource = "widget" | "webapp";
type Props = {
code: string, from?: GithubLoginSource
}
code: string;
from?: GithubLoginSource;
};
const GithubCallback: FC<Props> = ({ code, from = "webapp" }) => {
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
//拿本地存的magic token
const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN);
const [login, { isLoading, isSuccess, error }] = useLoginMutation();
useEffect(() => {
if (code) {
login({
magic_token,
code,
type: "github"
});
}
}, [code]);
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.login"));
// 通知widget
if (from == 'widget') {
localStorage.setItem("widget", `${new Date().getTime()}`);
}
// webapp 跳回首页
if (from == 'webapp') {
location.href = "/";
}
}
}, [isSuccess, from]);
useEffect(() => {
if (error) {
console.log(error);
// todo: why?
switch ((error as FetchBaseQueryError).status) {
case 410:
toast.error(
"No associated account found, please contact user admin for an invitation link to join."
);
break;
default:
toast.error("Something Error");
break;
}
}
}, [error]);
const handleClose = () => {
window.close();
};
if (error) return <span className='text-red-500 text-lg'>Something Error</span>;
return <section className='flex-center flex-col gap-3'>
<StyledButton onClick={handleClose}>{ct("action.close")}</StyledButton>
{isSuccess && from == 'widget' && <h1>{t("github_cb_tip")}</h1>}
<span className='text-3xl text-green-600 font-bold'>{isLoading ? t("github_logging_in") : t("github_login_success")}</span>
</section>;
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
//拿本地存的magic token
const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN);
const [login, { isLoading, isSuccess, error }] = useLoginMutation();
useEffect(() => {
if (code) {
login({
magic_token,
code,
type: "github"
});
}
}, [code]);
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.login"));
// 通知widget
if (from == "widget") {
localStorage.setItem("widget", `${new Date().getTime()}`);
}
// webapp 跳回首页
if (from == "webapp") {
location.href = "/";
}
}
}, [isSuccess, from]);
useEffect(() => {
if (error) {
console.log(error);
// todo: why?
switch ((error as FetchBaseQueryError).status) {
case 410:
toast.error(
"No associated account found, please contact user admin for an invitation link to join."
);
break;
default:
toast.error("Something Error");
break;
}
}
}, [error]);
const handleClose = () => {
window.close();
};
if (error) return <span className="text-red-500 text-lg">Something Error</span>;
return (
<section className="flex-center flex-col gap-3">
<StyledButton onClick={handleClose}>{ct("action.close")}</StyledButton>
{isSuccess && from == "widget" && <h1>{t("github_cb_tip")}</h1>}
<span className="text-3xl text-green-600 font-bold">
{isLoading ? t("github_logging_in") : t("github_login_success")}
</span>
</section>
);
};
export default GithubCallback;
export default GithubCallback;
+10 -5
View File
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { useLazyGetGeneratedLicenseQuery } from "@/app/services/server";
import useLicense from "@/hooks/useLicense";
import Button from "@/components/styled/Button";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLazyGetGeneratedLicenseQuery } from "@/app/services/server";
import Button from "@/components/styled/Button";
import useLicense from "@/hooks/useLicense";
type Props = {
sid: string;
@@ -31,7 +32,11 @@ const PaymentSuccess = ({ sid }: Props) => {
};
return (
<section className="flex flex-col items-center bg-slate-100 dark:bg-slate-800 rounded-2xl w-4/5 md:w-[512px] p-6">
<img className="w-28 h-28" src="https://s.voce.chat/web_client/assets/img/check.png" alt="check icon" />
<img
className="w-28 h-28"
src="https://s.voce.chat/web_client/assets/img/check.png"
alt="check icon"
/>
<h1 className="font-bold text-3xl pt-5">{t("payment_success")}</h1>
<p className="text-lg pb-7 mt-2 text-gray-400 dark:text-gray-600">
{upserting ? t("tip_renewing") : ""}
+7 -5
View File
@@ -1,13 +1,15 @@
import { DOMAttributes, ReactNode } from "react";
import { useParams } from "react-router-dom";
import PaymentSuccess from "./PaymentSuccess";
import GithubCallback, { GithubLoginSource } from "./GithubCallback";
import PaymentSuccess from "./PaymentSuccess";
const StyledWrapper = ({ children }: DOMAttributes<HTMLDivElement> & { children?: ReactNode }) => {
return <div className="flex-center dark:bg-gray-700 dark:text-white w-screen h-screen break-words leading-normal">
{children}
</div>;
return (
<div className="flex-center dark:bg-gray-700 dark:text-white w-screen h-screen break-words leading-normal">
{children}
</div>
);
};
// type Props = {
// type: "payment_success";
+58 -52
View File
@@ -1,58 +1,64 @@
import { useRef, useState } from 'react';
import { ViewportList } from 'react-viewport-list';
import { useTranslation } from 'react-i18next';
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ViewportList } from "react-viewport-list";
import InviteModal from "@/components/InviteModal";
import User from "@/components/User";
import IconAdd from "@/assets/icons/add.svg";
import InviteModal from "@/components/InviteModal";
type Props = {
membersVisible: boolean,
uids: number[],
addVisible: boolean,
cid: number,
ownerId: number
}
const Members = ({ uids, addVisible, ownerId, cid, membersVisible }: Props) => {
const { t } = useTranslation("chat");
const ref = useRef<HTMLDivElement | null>(null);
const [addMemberModalVisible, setAddMemberModalVisible] = useState(false);
const toggleAddVisible = () => {
setAddMemberModalVisible((prev) => !prev);
};
return (
<>
{addMemberModalVisible && <InviteModal cid={cid} closeModal={toggleAddVisible} />}
<div ref={ref} className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${membersVisible ? "flex" : "hidden"}`}>
{addVisible && (
<div className="cursor-pointer flex items-center justify-start gap-1 select-none rounded-lg p-2.5 md:hover:bg-gray-500/10" onClick={toggleAddVisible}>
<IconAdd className="w-6 h-6 dark:fill-slate-300" />
<div className="font-semibold text-sm text-gray-600 dark:text-gray-50">{t("add_channel_members")}</div>
</div>
)}
<ViewportList
initialPrerender={15}
viewportRef={ref}
items={uids}
>
{(uid: number) => {
return (
<User
enableContextMenu={true}
cid={cid}
owner={ownerId == uid}
key={uid}
uid={uid}
dm
popover
/>
);
}}
</ViewportList>
</div>
</>
);
membersVisible: boolean;
uids: number[];
addVisible: boolean;
cid: number;
ownerId: number;
};
export default Members;
const Members = ({ uids, addVisible, ownerId, cid, membersVisible }: Props) => {
const { t } = useTranslation("chat");
const ref = useRef<HTMLDivElement | null>(null);
const [addMemberModalVisible, setAddMemberModalVisible] = useState(false);
const toggleAddVisible = () => {
setAddMemberModalVisible((prev) => !prev);
};
return (
<>
{addMemberModalVisible && <InviteModal cid={cid} closeModal={toggleAddVisible} />}
<div
ref={ref}
className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${
membersVisible ? "flex" : "hidden"
}`}
>
{addVisible && (
<div
className="cursor-pointer flex items-center justify-start gap-1 select-none rounded-lg p-2.5 md:hover:bg-gray-500/10"
onClick={toggleAddVisible}
>
<IconAdd className="w-6 h-6 dark:fill-slate-300" />
<div className="font-semibold text-sm text-gray-600 dark:text-gray-50">
{t("add_channel_members")}
</div>
</div>
)}
<ViewportList initialPrerender={15} viewportRef={ref} items={uids}>
{(uid: number) => {
return (
<User
enableContextMenu={true}
cid={cid}
owner={ownerId == uid}
key={uid}
uid={uid}
dm
popover
/>
);
}}
</ViewportList>
</div>
</>
);
};
export default Members;
+19 -7
View File
@@ -1,9 +1,10 @@
import { FC, FormEvent } from "react";
import usePinMessage from "@/hooks/usePinMessage";
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
import IconClose from "@/assets/icons/close.svg";
import { useTranslation } from "react-i18next";
import PinnedMessage from "@/components/PinnedMessage";
import usePinMessage from "@/hooks/usePinMessage";
import IconClose from "@/assets/icons/close.svg";
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
type Props = {
id: number;
@@ -19,21 +20,32 @@ const PinList: FC<Props> = ({ id }: Props) => {
const noPins = pins.length == 0;
return (
<div className="p-4 drop-shadow-md overflow-y-scroll min-w-[320px] md:min-w-[486px] md:max-h-[90vh] rounded-xl bg-gray-50 dark:bg-gray-800">
<h4 className=" text-gray-600 dark:text-gray-400 mb-4 font-semibold">{t("pinned_msg")}({pins.length})</h4>
<h4 className=" text-gray-600 dark:text-gray-400 mb-4 font-semibold">
{t("pinned_msg")}({pins.length})
</h4>
{noPins ? (
<div className="flex flex-col items-center gap-2 w-full p-4">
<IconSurprise />
<div className="w-60 font-semibold text-gray-500 dark:text-gray-300 text-center">{t("pin_empty_tip")}</div>
<div className="w-60 font-semibold text-gray-500 dark:text-gray-300 text-center">
{t("pin_empty_tip")}
</div>
</div>
) : (
<ul className="flex flex-col gap-2">
{pins.map((data) => {
return (
<li key={data.mid} className="group relative border border-solid border-slate-100 dark:border-slate-600 rounded-md ">
<li
key={data.mid}
className="group relative border border-solid border-slate-100 dark:border-slate-600 rounded-md "
>
<PinnedMessage data={data} />
<div className="invisible group-hover:visible flex items-center gap-1 absolute top-1 right-1 p-1 border border-solid border-black/10 dark:border-gray-500 rounded-md">
{canPin && (
<button className="flex bg-none border-none" data-mid={data.mid} onClick={handleUnpin}>
<button
className="flex bg-none border-none"
data-mid={data.mid}
onClick={handleUnpin}
>
<IconClose className="fill-slate-900 dark:fill-slate-300" />
</button>
)}
+90 -86
View File
@@ -1,25 +1,26 @@
import { useEffect, memo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
import { memo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
import PinList from "./PinList";
import FavList from "../FavList";
import { updateChannelVisibleAside } from "@/app/slices/footprint";
import { updateRememberedNavs } from "@/app/slices/ui";
import { useAppSelector } from "@/app/store";
import ChannelIcon from "@/components/ChannelIcon";
import GoBackNav from "@/components/GoBackNav";
import ServerVersionChecker from "@/components/ServerVersionChecker";
import Tooltip from "@/components/Tooltip";
import Layout from "../Layout";
import IconFav from "@/assets/icons/bookmark.svg";
import IconPeople from "@/assets/icons/people.svg";
import IconPin from "@/assets/icons/pin.svg";
import { useAppSelector } from "@/app/store";
import GoBackNav from "@/components/GoBackNav";
import Members from "./Members";
import FavList from "../FavList";
import Layout from "../Layout";
import VoiceChat from "../VoiceChat";
import { updateChannelVisibleAside } from "@/app/slices/footprint";
import Dashboard from "../VoiceChat/Dashboard";
import ServerVersionChecker from "@/components/ServerVersionChecker";
import Members from "./Members";
import PinList from "./PinList";
type Props = {
cid?: number;
dropFiles?: File[];
@@ -30,12 +31,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
const navigate = useNavigate();
const dispatch = useDispatch();
const {
userIds,
data,
loginUser,
visibleAside
} = useAppSelector((store) => {
const { userIds, data, loginUser, visibleAside } = useAppSelector((store) => {
return {
loginUser: store.authData.user,
userIds: store.users.ids,
@@ -57,83 +53,91 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
}, [pathname]);
const toggleMembersVisible = () => {
dispatch(updateChannelVisibleAside({
id: cid,
aside: visibleAside !== "members" ? "members" : null
}));
dispatch(
updateChannelVisibleAside({
id: cid,
aside: visibleAside !== "members" ? "members" : null
})
);
};
if (!data) return null;
const { name, description, is_public, members = [], owner } = data;
const memberIds = is_public ? userIds : members.slice(0).sort((n) => (n == owner ? -1 : 0));
const addVisible = loginUser?.is_admin || owner == loginUser?.uid;
const pinCount = data?.pinned_messages?.length || 0;
const toolClass = `relative cursor-pointer hidden md:block`;
return <Layout
to={cid}
context="channel"
dropFiles={dropFiles}
aside={
<ul className="flex flex-col gap-6">
<Tooltip tip={t("pin")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 150]}
interactive
trigger="click"
content={<PinList id={cid} />}
>
<li className={`${toolClass}`}>
{pinCount > 0 ? <span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">{pinCount}</span> : null}
<IconPin className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<ServerVersionChecker version="0.3.5" empty={true}>
<VoiceChat context={`channel`} id={cid} />
</ServerVersionChecker>
<Tooltip tip={t("fav")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 164]}
interactive
trigger="click"
content={<FavList cid={cid} />}
>
<li className={`${toolClass}`}>
<IconFav className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<li
className={`${toolClass}`}
onClick={toggleMembersVisible}
>
<Tooltip tip={t("channel_members")} placement="left">
<IconPeople className={visibleAside == "members" ? "fill-gray-600" : ""} />
return (
<Layout
to={cid}
context="channel"
dropFiles={dropFiles}
aside={
<ul className="flex flex-col gap-6">
<Tooltip tip={t("pin")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 150]}
interactive
trigger="click"
content={<PinList id={cid} />}
>
<li className={`${toolClass}`}>
{pinCount > 0 ? (
<span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">
{pinCount}
</span>
) : null}
<IconPin className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
</li>
</ul>
}
header={
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
<GoBackNav />
<div className="flex items-center gap-1">
<ChannelIcon personal={!is_public} />
<span className="text-gray-800 dark:text-white">{name}</span>
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
</div>
</header>
}
users={
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={visibleAside == "members"} />
}
voice={
<Dashboard visible={visibleAside == "voice"} id={cid} context="channel" />
}
/>;
<ServerVersionChecker version="0.3.5" empty={true}>
<VoiceChat context={`channel`} id={cid} />
</ServerVersionChecker>
<Tooltip tip={t("fav")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 164]}
interactive
trigger="click"
content={<FavList cid={cid} />}
>
<li className={`${toolClass}`}>
<IconFav className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<li className={`${toolClass}`} onClick={toggleMembersVisible}>
<Tooltip tip={t("channel_members")} placement="left">
<IconPeople className={visibleAside == "members" ? "fill-gray-600" : ""} />
</Tooltip>
</li>
</ul>
}
header={
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
<GoBackNav />
<div className="flex items-center gap-1">
<ChannelIcon personal={!is_public} />
<span className="text-gray-800 dark:text-white">{name}</span>
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
</div>
</header>
}
users={
<Members
uids={memberIds}
addVisible={addVisible}
cid={cid}
ownerId={owner}
membersVisible={visibleAside == "members"}
/>
}
voice={<Dashboard visible={visibleAside == "voice"} id={cid} context="channel" />}
/>
);
}
export default memo(ChannelChat, (prev, next) => prev.cid == next.cid);
+8 -6
View File
@@ -1,15 +1,17 @@
import { FC, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
import FavList from "../FavList";
import Tooltip from "@/components/Tooltip";
import FavIcon from "@/assets/icons/bookmark.svg";
import User from "@/components/User";
import Layout from "../Layout";
import { useAppSelector } from "@/app/store";
import GoBackNav from "@/components/GoBackNav";
import ServerVersionChecker from "@/components/ServerVersionChecker";
import Tooltip from "@/components/Tooltip";
import User from "@/components/User";
import FavIcon from "@/assets/icons/bookmark.svg";
import FavList from "../FavList";
import Layout from "../Layout";
import VoiceChat from "../VoiceChat";
type Props = {
uid: number;
dropFiles?: File[];
@@ -18,7 +20,7 @@ const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
const navigate = useNavigate();
const { currUser } = useAppSelector((store) => {
return {
currUser: store.users.byId[uid],
currUser: store.users.byId[uid]
};
});
useEffect(() => {
+14 -6
View File
@@ -1,9 +1,10 @@
import { MouseEvent, FC } from "react";
import { FC, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import FavoredMessage from "@/components/Message/FavoredMessage";
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
import IconRemove from "@/assets/icons/close.svg";
import useFavMessage from "@/hooks/useFavMessage";
import IconRemove from "@/assets/icons/close.svg";
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
type Props = { cid?: number; uid?: number };
const FavList: FC<Props> = ({ cid = null, uid = null }) => {
@@ -17,17 +18,24 @@ const FavList: FC<Props> = ({ cid = null, uid = null }) => {
const noFavs = favorites.length == 0;
return (
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-xl min-w-[500px] max-h-[500px] overflow-auto drop-shadow-[0px_25px_50px_rgba(31,_41,_55,_0.25)]">
<h4 className="font-bold text-gray-600 dark:text-gray-400 mb-4">{t('fav_msg')}({favorites.length})</h4>
<h4 className="font-bold text-gray-600 dark:text-gray-400 mb-4">
{t("fav_msg")}({favorites.length})
</h4>
{noFavs ? (
<div className="flex flex-col gap-2 w-full items-center p-4">
<IconSurprise />
<div className="w-60 text-gray-600 dark:text-gray-400 text-center font-bold">{t("fav_empty_tip")}</div>
<div className="w-60 text-gray-600 dark:text-gray-400 text-center font-bold">
{t("fav_empty_tip")}
</div>
</div>
) : (
<ul className="flex flex-col gap-2">
{favorites.map(({ id }) => {
return (
<li key={id} className="relative border border-solid border-slate-200 dark:border-gray-600 rounded-md group">
<li
key={id}
className="relative border border-solid border-slate-200 dark:border-gray-600 rounded-md group"
>
<FavoredMessage id={id} />
<div className="flex items-center absolute top-2 right-2 border border-solid border-gray-300 dark:border-gray-600 rounded-md overflow-hidden invisible group-hover:visible">
<button className="flex-center w-6 h-6 p-1" data-id={id} onClick={handleRemove}>
+16 -4
View File
@@ -2,12 +2,14 @@
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { BASE_ORIGIN } from "@/app/config";
import { resetAuthData } from "@/app/slices/auth.data";
import { useAppSelector } from "@/app/store";
import QRCode from "@/components/QRCode";
import Button from "@/components/styled/Button";
import useLogout from "@/hooks/useLogout";
// type Props = {};
const GuestBlankPlaceholder = () => {
@@ -23,13 +25,23 @@ const GuestBlankPlaceholder = () => {
};
return (
<section className="flex flex-col items-center text-center">
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">{t("welcome", { name: serverName })}</h2>
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">
{t("welcome", { name: serverName })}
</h2>
<div className="flex flex-col gap-2">
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">{t("guest_login_tip")}</span>
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">
{t("guest_login_tip")}
</span>
<div className="w-44 h-44 self-center mb-4">
<QRCode level="Q" size={1200} link={`https://voce.chat/login?s=${encodeURIComponent(BASE_ORIGIN)}`} />
<QRCode
level="Q"
size={1200}
link={`https://voce.chat/login?s=${encodeURIComponent(BASE_ORIGIN)}`}
/>
</div>
<Button onClick={handleSignIn} className="small">{t("sign_in")}</Button>
<Button onClick={handleSignIn} className="small">
{t("sign_in")}
</Button>
</div>
</section>
);
+3 -3
View File
@@ -1,10 +1,10 @@
// import { useState, useEffect } from "react";
// import { NavLink } from "react-router-dom";
// import { useTranslation } from "react-i18next";
import ChannelIcon from "@/components/ChannelIcon";
import Layout from "../Layout";
import { useAppSelector } from "@/app/store";
import ChannelIcon from "@/components/ChannelIcon";
import GoBackNav from "@/components/GoBackNav";
import Layout from "../Layout";
type Props = {
cid?: number;
@@ -13,7 +13,7 @@ export default function GuestChannelChat({ cid = 0 }: Props) {
// const { t } = useTranslation("chat");
const { data } = useAppSelector((store) => {
return {
data: store.channels.byId[cid],
data: store.channels.byId[cid]
};
});
if (!data) return null;
+31 -8
View File
@@ -1,11 +1,12 @@
// @ts-nocheck
import { useState, useEffect, FC } from "react";
import clsx from "clsx";
import { renderPreviewMessage } from "../../chat/utils";
import Avatar from "@/components/Avatar";
import { FC, useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import Avatar from "@/components/Avatar";
import { fromNowTime } from "@/utils";
import { renderPreviewMessage } from "../../chat/utils";
interface IProps {
id: number;
@@ -39,13 +40,33 @@ const Session: FC<IProps> = ({ id, mid }) => {
return (
<li className="session">
<NavLink className={({ isActive: linkActive }) => clsx(`nav flex gap-2 rounded-lg p-2 w-full md:hover:bg-gray-500/20`, linkActive && "bg-gray-500/20")} to={`/chat/channel/${id}`}>
<NavLink
className={({ isActive: linkActive }) =>
clsx(
`nav flex gap-2 rounded-lg p-2 w-full md:hover:bg-gray-500/20`,
linkActive && "bg-gray-500/20"
)
}
to={`/chat/channel/${id}`}
>
<div className="flex shrink-0">
<Avatar width={40} height={40} className="icon rounded-full" type="channel" name={name} src={icon} />
<Avatar
width={40}
height={40}
className="icon rounded-full"
type="channel"
name={name}
src={icon}
/>
</div>
<div className="w-full flex flex-col justify-between">
<div className="flex items-center justify-between">
<span className={clsx(`flex items-center gap-2 font-semibold text-sm text-gray-500 dark:text-white truncate`, previewMsg.created_at && "max-w-[190px]")}>
<span
className={clsx(
`flex items-center gap-2 font-semibold text-sm text-gray-500 dark:text-white truncate`,
previewMsg.created_at && "max-w-[190px]"
)}
>
{name}
</span>
<span className="text-xs text-gray-600 max-w-[80px] truncate">
@@ -53,7 +74,9 @@ const Session: FC<IProps> = ({ id, mid }) => {
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 w-36 truncate">{renderPreviewMessage(previewMsg)}</span>
<span className="text-xs text-gray-500 w-36 truncate">
{renderPreviewMessage(previewMsg)}
</span>
</div>
</div>
</NavLink>
+4 -3
View File
@@ -1,8 +1,9 @@
import { FC } from "react";
import { useState, useEffect } from "react";
import Session from "./Session";
import { FC, useEffect, useState } from "react";
import { useAppSelector } from "@/app/store";
import LoginTip from "../Layout/LoginTip";
import Session from "./Session";
export interface ChatSession {
key: string;
id: number;
+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>
</>
);
};
+2 -3
View File
@@ -1,11 +1,10 @@
import { FC } from "react";
import { Waveform } from "@uiball/loaders";
type Props = {
};
type Props = {};
const LoadMore: FC<Props> = () => {
return (
<div data-load-more className="mt-2 flex-center w-full py-2" >
<div data-load-more className="mt-2 flex-center w-full py-2">
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
</div>
);
+137 -87
View File
@@ -1,92 +1,142 @@
// import React from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { useAppSelector } from '../../app/store';
import User from '../../components/User';
import IconMic from '@/assets/icons/mic.on.svg';
import IconMicOff from '@/assets/icons/mic.off.svg';
import IconCallOff from '@/assets/icons/headphone.svg';
import IconSoundOn from '@/assets/icons/sound.on.svg';
import IconSoundOff from '@/assets/icons/sound.off.svg';
import IconCamera from '@/assets/icons/camera.svg';
import IconScreen from '@/assets/icons/share.screen.svg';
import { useVoice } from '../../components/Voice';
import Signal from '../../components/Signal';
import Tooltip from '../../components/Tooltip';
import { ChatContext } from '../../types/common';
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import IconCamera from "@/assets/icons/camera.svg";
import IconCallOff from "@/assets/icons/headphone.svg";
import IconMicOff from "@/assets/icons/mic.off.svg";
import IconMic from "@/assets/icons/mic.on.svg";
import IconScreen from "@/assets/icons/share.screen.svg";
import IconSoundOff from "@/assets/icons/sound.off.svg";
import IconSoundOn from "@/assets/icons/sound.on.svg";
import { useAppSelector } from "../../app/store";
import Signal from "../../components/Signal";
import Tooltip from "../../components/Tooltip";
import User from "../../components/User";
import { useVoice } from "../../components/Voice";
import { ChatContext } from "../../types/common";
type Props = {
id: number,
context?: ChatContext
}
const RTCWidget = ({ id, context = "channel" }: Props) => {
const { t } = useTranslation("chat");
const { leave, voicingInfo, setMute, setDeafen, joining = true, openCamera, closeCamera, startShareScreen, stopShareScreen } = useVoice({ context, id });
const { callFrom, callTo, loginUser, channelData, userData } = useAppSelector(store => {
return {
callFrom: store.voice.callingFrom,
callTo: store.voice.callingTo,
userData: store.users.byId,
channelData: store.channels.byId,
loginUser: store.authData.user,
};
});
if (!loginUser || !voicingInfo || joining) return null;
// const name = voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : userData[voicingInfo.id]?.name;
// if (!name) return null;
const isReConnecting = voicingInfo.connectionState == "RECONNECTING";
const targetDMUsername = callFrom == loginUser.uid ? userData[callTo]?.name : userData[callFrom]?.name;
return (
<div className='bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm'>
<div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2">
<div className="flex justify-between items-center">
<div className="flex flex-1 items-center gap-1">
<Signal strength={voicingInfo.downlinkNetworkQuality} />
<div className="flex flex-col">
<span className={clsx('text-green-800 font-bold', isReConnecting && `text-red-500`)}>{isReConnecting ? `Voice Reconnecting...` : `Voice Connected`}</span>
<span className='text-gray-600 dark:text-gray-400 text-xs truncate max-w-[170px]' >
{voicingInfo.context == "channel" ? "Channel" : "DM"} / {voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : targetDMUsername}
</span>
</div>
</div>
<Tooltip tip={t("leave_voice")} placement="top">
<IconCallOff onClick={leave} role="button" className="fill-red-600" />
</Tooltip>
</div>
<div className={clsx("flex items-center justify-around gap-2 mt-3 text-sm font-semibold text-gray-800 dark:text-gray-200")}>
<button onClick={voicingInfo.video ? closeCamera : openCamera} className={clsx('rounded-lg py-2 px-5 flex items-center gap-1', voicingInfo.video ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 ")}>
<IconCamera className={clsx("dark:fill-gray-200 w-6 h-6", voicingInfo.video ? "fill-gray-200" : "fill-gray-800")} />
<span>Video</span>
</button>
<button onClick={voicingInfo.shareScreen ? stopShareScreen : startShareScreen} className={clsx('rounded-lg py-2 px-5 flex items-center gap-1', voicingInfo.shareScreen ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 ")}>
<IconScreen className={clsx("dark:fill-gray-200 w-6 h-6", voicingInfo.shareScreen ? "fill-gray-200" : "fill-gray-800")} />
<span>Screen</span>
</button>
</div>
</div>
<div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-3">
<User uid={loginUser.uid} compact />
<div className="flex flex-col">
<span className='dark:text-white text-sm font-bold'>{loginUser.name}</span>
<span className='text-gray-400 text-xs'>#{loginUser.uid}</span>
</div>
</div>
<div className="flex gap-2 px-1">
<Tooltip tip={voicingInfo.deafen ? t("undeafen") : t("deafen")} placement="top">
{voicingInfo.deafen ? <IconSoundOff role="button" onClick={setDeafen.bind(null, false)} /> : <IconSoundOn role="button" onClick={setDeafen.bind(null, true)} />}
</Tooltip>
<Tooltip tip={voicingInfo.muted ? t("unmute") : t("mute")} placement="top">
{voicingInfo.muted ?
<IconMicOff className="w-6 h-6" onClick={setMute.bind(null, false)} role="button" />
:
<IconMic className="w-6 h-6" onClick={setMute.bind(null, true)} role="button" />}
</Tooltip>
</div>
</div>
</div>
);
id: number;
context?: ChatContext;
};
export default RTCWidget;
const RTCWidget = ({ id, context = "channel" }: Props) => {
const { t } = useTranslation("chat");
const {
leave,
voicingInfo,
setMute,
setDeafen,
joining = true,
openCamera,
closeCamera,
startShareScreen,
stopShareScreen
} = useVoice({ context, id });
const { callFrom, callTo, loginUser, channelData, userData } = useAppSelector((store) => {
return {
callFrom: store.voice.callingFrom,
callTo: store.voice.callingTo,
userData: store.users.byId,
channelData: store.channels.byId,
loginUser: store.authData.user
};
});
if (!loginUser || !voicingInfo || joining) return null;
// const name = voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : userData[voicingInfo.id]?.name;
// if (!name) return null;
const isReConnecting = voicingInfo.connectionState == "RECONNECTING";
const targetDMUsername =
callFrom == loginUser.uid ? userData[callTo]?.name : userData[callFrom]?.name;
return (
<div className="bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm">
<div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2">
<div className="flex justify-between items-center">
<div className="flex flex-1 items-center gap-1">
<Signal strength={voicingInfo.downlinkNetworkQuality} />
<div className="flex flex-col">
<span className={clsx("text-green-800 font-bold", isReConnecting && `text-red-500`)}>
{isReConnecting ? `Voice Reconnecting...` : `Voice Connected`}
</span>
<span className="text-gray-600 dark:text-gray-400 text-xs truncate max-w-[170px]">
{voicingInfo.context == "channel" ? "Channel" : "DM"} /{" "}
{voicingInfo.context == "channel"
? channelData[voicingInfo.id]?.name
: targetDMUsername}
</span>
</div>
</div>
<Tooltip tip={t("leave_voice")} placement="top">
<IconCallOff onClick={leave} role="button" className="fill-red-600" />
</Tooltip>
</div>
<div
className={clsx(
"flex items-center justify-around gap-2 mt-3 text-sm font-semibold text-gray-800 dark:text-gray-200"
)}
>
<button
onClick={voicingInfo.video ? closeCamera : openCamera}
className={clsx(
"rounded-lg py-2 px-5 flex items-center gap-1",
voicingInfo.video ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 "
)}
>
<IconCamera
className={clsx(
"dark:fill-gray-200 w-6 h-6",
voicingInfo.video ? "fill-gray-200" : "fill-gray-800"
)}
/>
<span>Video</span>
</button>
<button
onClick={voicingInfo.shareScreen ? stopShareScreen : startShareScreen}
className={clsx(
"rounded-lg py-2 px-5 flex items-center gap-1",
voicingInfo.shareScreen
? "bg-green-700 text-gray-200"
: "bg-white/50 dark:bg-black/50 "
)}
>
<IconScreen
className={clsx(
"dark:fill-gray-200 w-6 h-6",
voicingInfo.shareScreen ? "fill-gray-200" : "fill-gray-800"
)}
/>
<span>Screen</span>
</button>
</div>
</div>
<div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-3">
<User uid={loginUser.uid} compact />
<div className="flex flex-col">
<span className="dark:text-white text-sm font-bold">{loginUser.name}</span>
<span className="text-gray-400 text-xs">#{loginUser.uid}</span>
</div>
</div>
<div className="flex gap-2 px-1">
<Tooltip tip={voicingInfo.deafen ? t("undeafen") : t("deafen")} placement="top">
{voicingInfo.deafen ? (
<IconSoundOff role="button" onClick={setDeafen.bind(null, false)} />
) : (
<IconSoundOn role="button" onClick={setDeafen.bind(null, true)} />
)}
</Tooltip>
<Tooltip tip={voicingInfo.muted ? t("unmute") : t("mute")} placement="top">
{voicingInfo.muted ? (
<IconMicOff className="w-6 h-6" onClick={setMute.bind(null, false)} role="button" />
) : (
<IconMic className="w-6 h-6" onClick={setMute.bind(null, true)} role="button" />
)}
</Tooltip>
</div>
</div>
</div>
);
};
export default RTCWidget;
+62 -56
View File
@@ -1,16 +1,22 @@
import { FC, ReactElement } from "react";
import Tippy from "@tippyjs/react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useNavigate, useLocation, useMatch } from "react-router-dom";
import { usePinChatMutation, useUnpinChatMutation, useUpdateMuteSettingMutation } from "@/app/services/user";
import { useLocation, useMatch, useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
import { useReadMessageMutation } from "@/app/services/message";
import {
usePinChatMutation,
useUnpinChatMutation,
useUpdateMuteSettingMutation
} from "@/app/services/user";
import { removeUserSession } from "@/app/slices/message.user";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import ContextMenu, { Item } from "@/components/ContextMenu";
import useUserOperation from "@/hooks/useUserOperation";
import { useAppSelector } from "@/app/store";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
import { compareVersion } from "@/utils";
type Props = {
context: ChatContext;
pinned: boolean;
@@ -93,57 +99,57 @@ const SessionContextMenu: FC<Props> = ({
const items =
context == "dm"
? [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("action.mark_read"),
handler: handleReadAll
},
{
title: t("setting"),
handler: handleDMSetting
},
canCopyEmail && {
title: t("action.copy_email"),
handler: copyEmail
},
{
title: t("action.hide_session"),
danger: true,
handler: handleRemoveSession
}
]
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("action.mark_read"),
handler: handleReadAll
},
{
title: t("setting"),
handler: handleDMSetting
},
canCopyEmail && {
title: t("action.copy_email"),
handler: copyEmail
},
{
title: t("action.hide_session"),
danger: true,
handler: handleRemoveSession
}
]
: [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("setting"),
underline: true,
handler: handleChannelSetting
},
{
title: t("action.mark_read"),
// underline: true
handler: handleReadAll
},
{
title: channelMuted ? t("action.unmute") : t("action.mute"),
handler: handleChannelMute
},
canInviteChannel && {
title: t("action.invite_people"),
handler: setInviteChannelId.bind(null, id)
},
canDeleteChannel && {
title: t("action.delete_channel"),
danger: true,
handler: deleteChannel.bind(null, id)
}
];
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("setting"),
underline: true,
handler: handleChannelSetting
},
{
title: t("action.mark_read"),
// underline: true
handler: handleReadAll
},
{
title: channelMuted ? t("action.unmute") : t("action.mute"),
handler: handleChannelMute
},
canInviteChannel && {
title: t("action.invite_people"),
handler: setInviteChannelId.bind(null, id)
},
canDeleteChannel && {
title: t("action.delete_channel"),
danger: true,
handler: deleteChannel.bind(null, id)
}
];
return (
<Tippy
interactive
+72 -37
View File
@@ -1,21 +1,22 @@
// @ts-nocheck
import { useState, useEffect, FC } from "react";
import clsx from "clsx";
import { FC, useEffect, useState } from "react";
import { useDrop } from "react-dnd";
import { NativeTypes } from "react-dnd-html5-backend";
import { useNavigate, NavLink, useMatch } from "react-router-dom";
import ContextMenu from "./ContextMenu";
import getUnreadCount, { renderPreviewMessage } from "../utils";
import User from "@/components/User";
import { NavLink, useMatch, useNavigate } from "react-router-dom";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import Avatar from "@/components/Avatar";
import User from "@/components/User";
import useContextMenu from "@/hooks/useContextMenu";
import useUploadFile from "@/hooks/useUploadFile";
import { fromNowTime } from "@/utils";
import IconLock from "@/assets/icons/lock.svg";
import IconMute from "@/assets/icons/mute.svg";
import IconVoicing from "@/assets/icons/voicing.svg";
import useContextMenu from "@/hooks/useContextMenu";
import useUploadFile from "@/hooks/useUploadFile";
import { useAppSelector } from "@/app/store";
import { fromNowTime } from "@/utils";
import { ChatContext } from "@/types/common";
import getUnreadCount, { renderPreviewMessage } from "../utils";
import ContextMenu from "./ContextMenu";
interface IProps {
type?: ChatContext;
@@ -66,23 +67,31 @@ const Session: FC<IProps> = ({
mid: number;
is_public: boolean;
}>();
const { callingFrom, callingTo, messageData, userData, channelData, readIndex, loginUid, mids, muted, voiceList } = useAppSelector(
(store) => {
return {
callingFrom: store.voice.callingFrom,
callingTo: store.voice.callingTo,
voiceList: store.voice.list,
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
loginUid: store.authData.user?.uid || 0,
readIndex:
type == "dm" ? store.footprint.readUsers[id] : store.footprint.readChannels[id],
messageData: store.message,
userData: store.users.byId,
channelData: store.channels.byId,
muted: type == "dm" ? store.footprint.muteUsers[id] : store.footprint.muteChannels[id]
};
}
);
const {
callingFrom,
callingTo,
messageData,
userData,
channelData,
readIndex,
loginUid,
mids,
muted,
voiceList
} = useAppSelector((store) => {
return {
callingFrom: store.voice.callingFrom,
callingTo: store.voice.callingTo,
voiceList: store.voice.list,
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
loginUid: store.authData.user?.uid || 0,
readIndex: type == "dm" ? store.footprint.readUsers[id] : store.footprint.readChannels[id],
messageData: store.message,
userData: store.users.byId,
channelData: store.channels.byId,
muted: type == "dm" ? store.footprint.muteUsers[id] : store.footprint.muteChannels[id]
};
});
useEffect(() => {
const tmp = type == "dm" ? userData[id] : channelData[id];
@@ -106,9 +115,12 @@ const Session: FC<IProps> = ({
messageData,
loginUid
});
const isVoicing = type == "channel" ? voiceList.some((item) => {
return item.context == type && item.id === id;
}) : (id == callingFrom || id == callingTo);
const isVoicing =
type == "channel"
? voiceList.some((item) => {
return item.context == type && item.id === id;
})
: id == callingFrom || id == callingTo;
console.log("unreads", unreads, isCurrentPath);
return (
@@ -125,7 +137,14 @@ const Session: FC<IProps> = ({
>
<NavLink
ref={drop}
className={({ isActive: linkActive }) => clsx(`nav flex gap-2 rounded-lg p-2 w-full`, isActive && "shadow-[inset_0_0_0_2px_#52edff]", linkActive && "bg-gray-500/20", pinned ? "md:hover:bg-gray-300/20" : "md:hover:bg-gray-500/20")}
className={({ isActive: linkActive }) =>
clsx(
`nav flex gap-2 rounded-lg p-2 w-full`,
isActive && "shadow-[inset_0_0_0_2px_#52edff]",
linkActive && "bg-gray-500/20",
pinned ? "md:hover:bg-gray-300/20" : "md:hover:bg-gray-500/20"
)
}
to={navPath}
onContextMenu={handleContextMenuEvent}
>
@@ -147,7 +166,12 @@ const Session: FC<IProps> = ({
<div className="w-full flex flex-col justify-between overflow-hidden">
<div className="flex items-center justify-between ">
<span className={clsx(`flex items-center gap-1`)}>
<i className={clsx("not-italic font-semibold text-sm text-gray-500 dark:text-white max-w-[120px] truncate", !previewMsg.created_at && "max-w-[190px]")}>
<i
className={clsx(
"not-italic font-semibold text-sm text-gray-500 dark:text-white max-w-[120px] truncate",
!previewMsg.created_at && "max-w-[190px]"
)}
>
{name}
</i>
{!is_public && <IconLock className="dark:fill-gray-400" />}
@@ -157,10 +181,21 @@ const Session: FC<IProps> = ({
</span>
</div>
<div className="flex items-center justify-between">
<span className={clsx("text-xs text-gray-500 truncate", unreads > 0 ? `w-36` : ``)}>{renderPreviewMessage(previewMsg)}</span>
{(unreads > 0 && !isCurrentPath) ? <strong className={clsx(`text-white px-1.5 py-[3px] font-bold text-[10px] leading-[10px] rounded-[10px]`, muted ? "bg-black/10 dark:bg-gray-500" : "bg-primary-400")}>
{unreads > 99 ? "99+" : unreads}
</strong> : (muted && <IconMute className="w-3 h-3 fill-black/10 dark:fill-gray-500" />)}
<span className={clsx("text-xs text-gray-500 truncate", unreads > 0 ? `w-36` : ``)}>
{renderPreviewMessage(previewMsg)}
</span>
{unreads > 0 && !isCurrentPath ? (
<strong
className={clsx(
`text-white px-1.5 py-[3px] font-bold text-[10px] leading-[10px] rounded-[10px]`,
muted ? "bg-black/10 dark:bg-gray-500" : "bg-primary-400"
)}
>
{unreads > 99 ? "99+" : unreads}
</strong>
) : (
muted && <IconMute className="w-3 h-3 fill-black/10 dark:fill-gray-500" />
)}
</div>
</div>
</NavLink>
+70 -63
View File
@@ -1,12 +1,13 @@
import { FC, memo, useRef } from "react";
import { useState, useEffect } from "react";
import { FC, memo, useEffect, useRef, useState } from "react";
import { ViewportList } from "react-viewport-list";
import Session from "./Session";
import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
import InviteModal from "@/components/InviteModal";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import { PinChatTargetChannel, PinChatTargetUser } from "@/types/sse";
import InviteModal from "@/components/InviteModal";
import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
import Session from "./Session";
export interface ChatSession {
type: ChatContext;
id: number;
@@ -37,7 +38,7 @@ const SessionList: FC<Props> = ({ tempSession }) => {
});
useEffect(() => {
// const pinDMs=
// const pinDMs=
const getSessionObj = (id: number, type: "dm" | "channel") => {
const mids = type == "dm" ? userMessage[id] : channelMessage[id];
if (!mids || mids.length == 0) {
@@ -47,45 +48,53 @@ const SessionList: FC<Props> = ({ tempSession }) => {
const mid = [...mids].sort((a, b) => +a - +b).pop();
return { id, mid, type } as ChatSession;
};
const pinTmps = pins.map((p) => {
const { target } = p;
if ("uid" in target) {
return getSessionObj((target as PinChatTargetUser).uid, "dm");
}
if ("gid" in target) {
return getSessionObj((target as PinChatTargetChannel).gid, "channel");
}
return null;
}).filter((p) => !!p);
const channelPinIds = pins.map(p => {
if (p.target.gid) {
return p.target.gid;
}
return null;
}).filter(id => !!id);
const dmPinIds = pins.map(p => {
if (p.target.uid) {
return p.target.uid;
}
return null;
}).filter(id => !!id);
const cSessions = channelIDs.filter(id => {
return !channelPinIds.includes(id);
}).map((id) => {
return getSessionObj(id, "channel");
});
const uSessions = DMs.filter(id => {
const pinTmps = pins
.map((p) => {
const { target } = p;
if ("uid" in target) {
return getSessionObj((target as PinChatTargetUser).uid, "dm");
}
if ("gid" in target) {
return getSessionObj((target as PinChatTargetChannel).gid, "channel");
}
return null;
})
.filter((p) => !!p);
const channelPinIds = pins
.map((p) => {
if (p.target.gid) {
return p.target.gid;
}
return null;
})
.filter((id) => !!id);
const dmPinIds = pins
.map((p) => {
if (p.target.uid) {
return p.target.uid;
}
return null;
})
.filter((id) => !!id);
const cSessions = channelIDs
.filter((id) => {
return !channelPinIds.includes(id);
})
.map((id) => {
return getSessionObj(id, "channel");
});
const uSessions = DMs.filter((id) => {
return !dmPinIds.includes(id);
}).map((id) => {
return getSessionObj(id, "dm");
});
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => {
const { mid: aMid = 0 } = a;
const { mid: bMid = 0 } = b;
return bMid - aMid;
});
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort(
(a, b) => {
const { mid: aMid = 0 } = a;
const { mid: bMid = 0 } = b;
return bMid - aMid;
}
);
// console.log("before qqqq", temps);
const newSessions = tempSession ? [tempSession, ...temps] : temps;
// console.log("qqqq", newSessions);
@@ -105,29 +114,27 @@ const SessionList: FC<Props> = ({ tempSession }) => {
return (
<>
{pinSessions.length ? <ul className="flex flex-col gap-0.5 py-1 px-2 bg-primary-500/10">
{pinSessions.map((p) => {
const { type, id, mid = 0 } = p;
const key = `${type}_${id}`;
return (
<Session
key={key}
type={type}
pinned={true}
id={id}
mid={mid}
setInviteChannelId={setInviteChannelId}
setDeleteChannelId={setDeleteId}
/>
);
})}
</ul> : null}
{pinSessions.length ? (
<ul className="flex flex-col gap-0.5 py-1 px-2 bg-primary-500/10">
{pinSessions.map((p) => {
const { type, id, mid = 0 } = p;
const key = `${type}_${id}`;
return (
<Session
key={key}
type={type}
pinned={true}
id={id}
mid={mid}
setInviteChannelId={setInviteChannelId}
setDeleteChannelId={setDeleteId}
/>
);
})}
</ul>
) : null}
<ul ref={ref} className="flex flex-1 flex-col gap-0.5 p-2 overflow-auto">
<ViewportList
initialPrerender={10}
viewportRef={ref}
items={sessions}
>
<ViewportList initialPrerender={10} viewportRef={ref} items={sessions}>
{(s) => {
const { type, id, mid = 0 } = s;
const key = `${type}_${id}`;
+17 -11
View File
@@ -1,20 +1,26 @@
// import { useEffect } from 'react';
// import { useDispatch } from 'react-redux';
import VoiceManagement from './VoiceManagement';
import { useVoice } from '../../../components/Voice';
import { ChatContext } from '../../../types/common';
import { useVoice } from "../../../components/Voice";
import { ChatContext } from "../../../types/common";
import VoiceManagement from "./VoiceManagement";
type Props = {
visible: boolean,
context?: ChatContext,
id: number,
}
visible: boolean;
context?: ChatContext;
id: number;
};
const Dashboard = ({ context = "channel", id, visible }: Props) => {
const { voicingInfo } = useVoice({ id, context });
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 border border-black/10 md:border-none md:shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
<VoiceManagement id={id} context={context} info={voicingInfo} />
</div>;
return (
<div
className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 border border-black/10 md:border-none md:shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${
visible ? "flex" : "hidden"
}`}
>
<VoiceManagement id={id} context={context} info={voicingInfo} />
</div>
);
};
export default Dashboard;
export default Dashboard;
+123 -105
View File
@@ -1,110 +1,128 @@
import clsx from 'clsx';
import { useEffect } from 'react';
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import clsx from "clsx";
import { VoicingInfo, updatePin } from '../../../app/slices/voice';
import { useAppSelector } from '../../../app/store';
import Avatar from '../../../components/Avatar';
import IconMic from '@/assets/icons/mic.on.svg';
import IconMicOff from '@/assets/icons/mic.off.svg';
import IconFullscreen from '@/assets/icons/fullscreen.svg';
import { playAgoraVideo } from '@/utils';
import { ChatContext } from '../../../types/common';
import Operations from '@/components/Voice/Operations';
import { updateChannelVisibleAside } from '@/app/slices/footprint';
import { useDispatch } from 'react-redux';
import { updateChannelVisibleAside } from "@/app/slices/footprint";
import Operations from "@/components/Voice/Operations";
import { playAgoraVideo } from "@/utils";
import IconFullscreen from "@/assets/icons/fullscreen.svg";
import IconMicOff from "@/assets/icons/mic.off.svg";
import IconMic from "@/assets/icons/mic.on.svg";
import { updatePin, VoicingInfo } from "../../../app/slices/voice";
import { useAppSelector } from "../../../app/store";
import Avatar from "../../../components/Avatar";
import { ChatContext } from "../../../types/common";
type Props = {
id: number,
context: ChatContext,
info: VoicingInfo | null,
}
const VoiceManagement = ({ id, context, info, }: Props) => {
const dispatch = useDispatch();
const { userData, voicingMembers } = useAppSelector(store => {
return {
userData: store.users.byId,
voicingMembers: store.voice.voicingMembers
};
});
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach(id => {
playAgoraVideo(id);
});
}, [voicingMembers.ids]);
const handleFullscreen = (uid?: number) => {
if (context == "channel") {
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
if (uid) {
dispatch(updatePin({ uid, action: "pin" }));
}
}
};
if (!info) return null;
const nameClass = clsx(`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`);
const members = voicingMembers.ids;
const membersData = voicingMembers.byId;
if (info.joining) {
return <div className='w-full h-full flex-center p-1 text-sm text-gray-600 dark:text-gray-400'>
Connecting to voice channel...
</div>;
}
if (info.connectionState == "RECONNECTING") {
return <div className='w-full h-full flex-center flex-col gap-1 p-1 '>
<span className='text-red-300'>
Reconnecting...
</span>
<span className='text-xs text-red-500'>Please check network connection!</span>
</div>;
}
return (
<div className='w-full h-full py-2 flex flex-col'>
<ul className='flex grow flex-col'>
{members.map((uid) => {
const curr = userData[uid];
if (!curr) return null;
const { muted, speakingVolume = 0 } = membersData[uid];
const speaking = speakingVolume > 50;
return <li key={uid} className='pb-4'>
<div className="flex items-center justify-between gap-6 pb-2">
<div className="flex items-center gap-2 transition-opacity">
<div className={clsx("w-8 h-8 flex shrink-0 relative")}>
{speaking && <div className="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-[36px] h-[36px]"></div>}
<Avatar
width={32}
height={32}
className="w-full h-full rounded-full object-cover z-20"
src={curr.avatar}
name={curr.name}
alt="avatar"
/>
</div>
<span className={nameClass} title={curr?.name}>
{curr?.name}
</span>
</div>
<div className="flex items-center gap-2">
{/* {deafen ? <IconHeadphoneOff className="w-4" /> : <IconHeadphone className="w-4" />} */}
{muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />}
</div>
</div>
<div className="overflow-hidden group rounded relative" onDoubleClick={handleFullscreen.bind(null, uid)}>
<div id={`CAMERA_${uid}`} className="w-[98%] m-auto">
{/* camera placeholder */}
</div>
<button onClick={handleFullscreen.bind(null, uid)} className="invisible group-hover:visible w-5 h-5 p-1 bg-black/40 top-1 right-1.5 absolute rounded">
<IconFullscreen className="w-full h-full fill-white" />
</button>
</div>
</li>;
})}
</ul>
<div className="grid grid-rows-2 grid-cols-4 gap-2">
<Operations id={id} context={context} />
</div>
</div>
);
id: number;
context: ChatContext;
info: VoicingInfo | null;
};
export default VoiceManagement;
const VoiceManagement = ({ id, context, info }: Props) => {
const dispatch = useDispatch();
const { userData, voicingMembers } = useAppSelector((store) => {
return {
userData: store.users.byId,
voicingMembers: store.voice.voicingMembers
};
});
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach((id) => {
playAgoraVideo(id);
});
}, [voicingMembers.ids]);
const handleFullscreen = (uid?: number) => {
if (context == "channel") {
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
if (uid) {
dispatch(updatePin({ uid, action: "pin" }));
}
}
};
if (!info) return null;
const nameClass = clsx(
`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`
);
const members = voicingMembers.ids;
const membersData = voicingMembers.byId;
if (info.joining) {
return (
<div className="w-full h-full flex-center p-1 text-sm text-gray-600 dark:text-gray-400">
Connecting to voice channel...
</div>
);
}
if (info.connectionState == "RECONNECTING") {
return (
<div className="w-full h-full flex-center flex-col gap-1 p-1 ">
<span className="text-red-300">Reconnecting...</span>
<span className="text-xs text-red-500">Please check network connection!</span>
</div>
);
}
return (
<div className="w-full h-full py-2 flex flex-col">
<ul className="flex grow flex-col">
{members.map((uid) => {
const curr = userData[uid];
if (!curr) return null;
const { muted, speakingVolume = 0 } = membersData[uid];
const speaking = speakingVolume > 50;
return (
<li key={uid} className="pb-4">
<div className="flex items-center justify-between gap-6 pb-2">
<div className="flex items-center gap-2 transition-opacity">
<div className={clsx("w-8 h-8 flex shrink-0 relative")}>
{speaking && (
<div className="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-[36px] h-[36px]"></div>
)}
<Avatar
width={32}
height={32}
className="w-full h-full rounded-full object-cover z-20"
src={curr.avatar}
name={curr.name}
alt="avatar"
/>
</div>
<span className={nameClass} title={curr?.name}>
{curr?.name}
</span>
</div>
<div className="flex items-center gap-2">
{/* {deafen ? <IconHeadphoneOff className="w-4" /> : <IconHeadphone className="w-4" />} */}
{muted ? (
<IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" />
) : (
<IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />
)}
</div>
</div>
<div
className="overflow-hidden group rounded relative"
onDoubleClick={handleFullscreen.bind(null, uid)}
>
<div id={`CAMERA_${uid}`} className="w-[98%] m-auto">
{/* camera placeholder */}
</div>
<button
onClick={handleFullscreen.bind(null, uid)}
className="invisible group-hover:visible w-5 h-5 p-1 bg-black/40 top-1 right-1.5 absolute rounded"
>
<IconFullscreen className="w-full h-full fill-white" />
</button>
</div>
</li>
);
})}
</ul>
<div className="grid grid-rows-2 grid-cols-4 gap-2">
<Operations id={id} context={context} />
</div>
</div>
);
};
export default VoiceManagement;
+81 -70
View File
@@ -1,77 +1,88 @@
// import React from 'react';
// import Tippy from '@tippyjs/react';
// import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { updateChannelVisibleAside, updateDMVisibleAside } from '../../../app/slices/footprint';
import { useAppSelector } from '../../../app/store';
import IconHeadphone from '@/assets/icons/headphone.svg';
import Tooltip from '../../../components/Tooltip';
import { useVoice } from '../../../components/Voice';
import { useGetAgoraStatusQuery } from '../../../app/services/server';
import { ChatContext } from '../../../types/common';
import { updateCallInfo } from '../../../app/slices/voice';
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useGetAgoraStatusQuery } from "@/app/services/server";
import { updateChannelVisibleAside, updateDMVisibleAside } from "@/app/slices/footprint";
import { updateCallInfo } from "@/app/slices/voice";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import Tooltip from "@/components/Tooltip";
import { useVoice } from "@/components/Voice";
import IconHeadphone from "@/assets/icons/headphone.svg";
type Props = {
context?: ChatContext
id: number,
}
const VoiceChat = ({ id, context = "channel" }: Props) => {
const { joinVoice, joined, joining = false, joinedAtThisContext } = useVoice({ id, context });
const dispatch = useDispatch();
const { loginUser, voiceList, visibleAside } = useAppSelector(store => {
return {
loginUser: store.authData.user,
contextData: context == "channel" ? store.channels.byId[id] : store.users.byId[id],
voiceList: store.voice.list,
visibleAside: context == "channel" ? store.footprint.channelAsides[id] : null,
};
});
const { data: enabled } = useGetAgoraStatusQuery();
const { t } = useTranslation("chat");
const toggleDashboard = () => {
const data = {
id,
aside: visibleAside == "voice" ? null : "voice" as const
};
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
};
const handleJoin = () => {
if (joining || joined) {
alert("You have joined another channel, please leave first!");
return;
}
joinVoice();
const data = {
id,
aside: "voice" as const
};
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
// 实时显示calling box
if (!joinedAtThisContext && context == "dm") {
dispatch(updateCallInfo({ from: loginUser?.uid ?? 0, to: id, calling: false }));
}
};
if (!loginUser || !enabled) return null;
const visible = visibleAside == "voice";
const memberCount = voiceList.find((v) => v.context == context && v.id == id)?.memberCount ?? 0;
const badgeClass = `absolute -top-2 -right-2 w-4 h-4 rounded-full bg-primary-400 text-white `;
return (
<Tooltip disabled={visible} tip={t("voice")} placement="left">
<li className={`relative group`} >
<IconHeadphone className={visible ? "fill-gray-600" : "fill-gray-500"} role="button" onClick={joinedAtThisContext ? toggleDashboard : handleJoin} />
{visible ?
null
: <>
{memberCount > 0 && <span className={`${badgeClass} flex-center font-bold text-[10px] group-hover:invisible`}>{memberCount}</span>}
<span className={`${badgeClass} text-xs flex-center invisible group-hover:visible`}>
<em className='not-italic'>+</em>
</span>
</>}
</li>
</Tooltip>
);
context?: ChatContext;
id: number;
};
export default VoiceChat;
const VoiceChat = ({ id, context = "channel" }: Props) => {
const { joinVoice, joined, joining = false, joinedAtThisContext } = useVoice({ id, context });
const dispatch = useDispatch();
const { loginUser, voiceList, visibleAside } = useAppSelector((store) => {
return {
loginUser: store.authData.user,
contextData: context == "channel" ? store.channels.byId[id] : store.users.byId[id],
voiceList: store.voice.list,
visibleAside: context == "channel" ? store.footprint.channelAsides[id] : null
};
});
const { data: enabled } = useGetAgoraStatusQuery();
const { t } = useTranslation("chat");
const toggleDashboard = () => {
const data = {
id,
aside: visibleAside == "voice" ? null : ("voice" as const)
};
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
};
const handleJoin = () => {
if (joining || joined) {
alert("You have joined another channel, please leave first!");
return;
}
joinVoice();
const data = {
id,
aside: "voice" as const
};
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
// 实时显示calling box
if (!joinedAtThisContext && context == "dm") {
dispatch(updateCallInfo({ from: loginUser?.uid ?? 0, to: id, calling: false }));
}
};
if (!loginUser || !enabled) return null;
const visible = visibleAside == "voice";
const memberCount = voiceList.find((v) => v.context == context && v.id == id)?.memberCount ?? 0;
const badgeClass = `absolute -top-2 -right-2 w-4 h-4 rounded-full bg-primary-400 text-white `;
return (
<Tooltip disabled={visible} tip={t("voice")} placement="left">
<li className={`relative group`}>
<IconHeadphone
className={visible ? "fill-gray-600" : "fill-gray-500"}
role="button"
onClick={joinedAtThisContext ? toggleDashboard : handleJoin}
/>
{visible ? null : (
<>
{memberCount > 0 && (
<span
className={`${badgeClass} flex-center font-bold text-[10px] group-hover:invisible`}
>
{memberCount}
</span>
)}
<span className={`${badgeClass} text-xs flex-center invisible group-hover:visible`}>
<em className="not-italic">+</em>
</span>
</>
)}
</li>
</Tooltip>
);
};
export default VoiceChat;
+180 -122
View File
@@ -1,126 +1,184 @@
import { useEffect, useState, MouseEvent } from 'react';
import { useDispatch } from 'react-redux';
import clsx from 'clsx';
import IconMic from '@/assets/icons/mic.on.svg';
import IconMicOff from '@/assets/icons/mic.off.svg';
import IconPin from '@/assets/icons/pin.svg';
import IconExitScreen from '@/assets/icons/fullscreen.exit.svg';
import { ChatContext } from '../../types/common';
import { useAppSelector } from '../../app/store';
import Avatar from '../../components/Avatar';
import { playAgoraVideo } from '../../utils';
import Tooltip from '../../components/Tooltip';
import { useVoice } from '../../components/Voice';
import { updatePin } from '../../app/slices/voice';
import Operations from '@/components/Voice/Operations';
import { MouseEvent, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import clsx from "clsx";
import Operations from "@/components/Voice/Operations";
import IconExitScreen from "@/assets/icons/fullscreen.exit.svg";
import IconMicOff from "@/assets/icons/mic.off.svg";
import IconMic from "@/assets/icons/mic.on.svg";
import IconPin from "@/assets/icons/pin.svg";
import { updatePin } from "../../app/slices/voice";
import { useAppSelector } from "../../app/store";
import Avatar from "../../components/Avatar";
import Tooltip from "../../components/Tooltip";
import { useVoice } from "../../components/Voice";
import { ChatContext } from "../../types/common";
import { playAgoraVideo } from "../../utils";
type Props = {
context: ChatContext,
id: number
}
const VoiceFullscreen = ({ id, context }: Props) => {
const dispatch = useDispatch();
const [speakingUid, setSpeakingUid] = useState(0);
const { voicingInfo, exitFullscreen } = useVoice({ id, context });
const { name, userData, voicingMembers } = useAppSelector(store => {
return {
userData: store.users.byId,
voicingMembers: store.voice.voicingMembers,
name: context == "channel" ? store.channels.byId[id].name : store.users.byId[id].name
};
});
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach((id) => {
playAgoraVideo(id);
const { speakingVolume = 0 } = voicingMembers.byId[id];
const speaking = speakingVolume > 50;
if (speaking) {
setSpeakingUid(id);
}
});
}, [voicingMembers]);
const handleDoubleClick = (evt: MouseEvent<HTMLLIElement>) => {
const uid = evt.currentTarget.dataset.uid;
if (uid) {
dispatch(updatePin({ uid: +uid, action: "pin" }));
}
};
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
const uid = evt.currentTarget.dataset.uid ?? "";
const action = evt.currentTarget.dataset.action ?? "pin";
if (action == "unpin") {
dispatch(updatePin({ uid: +uid, action }));
} else {
dispatch(updatePin({ uid: +uid, action: "pin" }));
}
};
if (!voicingInfo) return null;
const pinUid = voicingMembers.pin;
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
const members = voicingMembers.ids;
const membersData = voicingMembers.byId;
const hasPin = typeof pinUid !== "undefined";
return (
<div className='h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl'>
{/* top */}
<div className="px-7 py-6 flex justify-between">
<span className='text-sm font-semibold'>{_name}</span>
<div className="flex gap-4">
<IconExitScreen role="button" onClick={exitFullscreen} className="fill-gray-200" />
</div>
</div>
{/* middle */}
<ul className='flex grow justify-center items-end relative gap-2'>
{members.map((uid) => {
const curr = userData[uid];
if (!curr) return null;
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
const speaking = speakingVolume > 50;
const special = hasPin ? pinUid == uid : uid == speakingUid;
const disablePin = special && !hasPin;
return <li key={uid} data-uid={uid} onDoubleClick={handleDoubleClick} className={clsx('bg-gray-700 group', special ? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center" : "relative rounded-lg py-1.5 px-12")}>
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
{speaking && <div className={clsx("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]")}></div>}
<Avatar
width={80}
height={80}
className="w-full h-full rounded-full object-cover z-20"
src={curr.avatar}
name={curr.name}
alt="avatar"
/>
</div>
{shareScreen ? <div className={clsx("w-1 h-1 absolute z-40 rounded-full bg-green-700/60", special ? "top-2 left-2" : "top-1 left-1 px-2")} /> : null}
{!disablePin &&
<Tooltip tip={"Remove pin"} disabled={!special} placement="right">
<button data-uid={uid} data-action={special ? "unpin" : "pin"} role={"button"} onClick={handlePin} className={clsx("absolute left-1 top-1 z-40 rounded bg-black/50", special ? "px-2 py-0.5" : "px-1 invisible group-hover:visible")}>
<IconPin className={clsx(special ? "w-4 fill-green-600" : "w-3 fill-gray-200")} />
</button>
</Tooltip>}
<span className={clsx("text-gray-300 bg-black/50 rounded-lg absolute z-40", special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs")} title={curr?.name}>
{curr?.name}
</span>
<div className={clsx("flex items-center gap-2 absolute z-40 rounded bg-black/50", special ? "bottom-2 right-2 px-2 py-0.5" : "bottom-1 right-1 px-2")}>
{muted ? <IconMicOff className={clsx("fill-gray-200", special ? "w-4" : "w-3")} /> : <IconMic className={clsx("fill-gray-200 ", special ? "w-4" : "w-3")} />}
</div>
<div id={`CAMERA_${uid}`} className={clsx("absolute top-0 left-0 z-30 w-full h-full overflow-hidden m-auto", special ? "" : "rounded")}>
{/* camera placeholder */}
</div>
</li>;
})}
</ul>
{/* bottom */}
<div className='py-4 flex justify-center gap-2'>
<Operations id={id} context={context} mode="fullscreen" />
</div>
</div>
);
context: ChatContext;
id: number;
};
export default VoiceFullscreen;
const VoiceFullscreen = ({ id, context }: Props) => {
const dispatch = useDispatch();
const [speakingUid, setSpeakingUid] = useState(0);
const { voicingInfo, exitFullscreen } = useVoice({ id, context });
const { name, userData, voicingMembers } = useAppSelector((store) => {
return {
userData: store.users.byId,
voicingMembers: store.voice.voicingMembers,
name: context == "channel" ? store.channels.byId[id].name : store.users.byId[id].name
};
});
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach((id) => {
playAgoraVideo(id);
const { speakingVolume = 0 } = voicingMembers.byId[id];
const speaking = speakingVolume > 50;
if (speaking) {
setSpeakingUid(id);
}
});
}, [voicingMembers]);
const handleDoubleClick = (evt: MouseEvent<HTMLLIElement>) => {
const uid = evt.currentTarget.dataset.uid;
if (uid) {
dispatch(updatePin({ uid: +uid, action: "pin" }));
}
};
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
const uid = evt.currentTarget.dataset.uid ?? "";
const action = evt.currentTarget.dataset.action ?? "pin";
if (action == "unpin") {
dispatch(updatePin({ uid: +uid, action }));
} else {
dispatch(updatePin({ uid: +uid, action: "pin" }));
}
};
if (!voicingInfo) return null;
const pinUid = voicingMembers.pin;
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
const members = voicingMembers.ids;
const membersData = voicingMembers.byId;
const hasPin = typeof pinUid !== "undefined";
return (
<div className="h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl">
{/* top */}
<div className="px-7 py-6 flex justify-between">
<span className="text-sm font-semibold">{_name}</span>
<div className="flex gap-4">
<IconExitScreen role="button" onClick={exitFullscreen} className="fill-gray-200" />
</div>
</div>
{/* middle */}
<ul className="flex grow justify-center items-end relative gap-2">
{members.map((uid) => {
const curr = userData[uid];
if (!curr) return null;
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
const speaking = speakingVolume > 50;
const special = hasPin ? pinUid == uid : uid == speakingUid;
const disablePin = special && !hasPin;
return (
<li
key={uid}
data-uid={uid}
onDoubleClick={handleDoubleClick}
className={clsx(
"bg-gray-700 group",
special
? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center"
: "relative rounded-lg py-1.5 px-12"
)}
>
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
{speaking && (
<div
className={clsx(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking",
special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]"
)}
></div>
)}
<Avatar
width={80}
height={80}
className="w-full h-full rounded-full object-cover z-20"
src={curr.avatar}
name={curr.name}
alt="avatar"
/>
</div>
{shareScreen ? (
<div
className={clsx(
"w-1 h-1 absolute z-40 rounded-full bg-green-700/60",
special ? "top-2 left-2" : "top-1 left-1 px-2"
)}
/>
) : null}
{!disablePin && (
<Tooltip tip={"Remove pin"} disabled={!special} placement="right">
<button
data-uid={uid}
data-action={special ? "unpin" : "pin"}
role={"button"}
onClick={handlePin}
className={clsx(
"absolute left-1 top-1 z-40 rounded bg-black/50",
special ? "px-2 py-0.5" : "px-1 invisible group-hover:visible"
)}
>
<IconPin
className={clsx(special ? "w-4 fill-green-600" : "w-3 fill-gray-200")}
/>
</button>
</Tooltip>
)}
<span
className={clsx(
"text-gray-300 bg-black/50 rounded-lg absolute z-40",
special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs"
)}
title={curr?.name}
>
{curr?.name}
</span>
<div
className={clsx(
"flex items-center gap-2 absolute z-40 rounded bg-black/50",
special ? "bottom-2 right-2 px-2 py-0.5" : "bottom-1 right-1 px-2"
)}
>
{muted ? (
<IconMicOff className={clsx("fill-gray-200", special ? "w-4" : "w-3")} />
) : (
<IconMic className={clsx("fill-gray-200 ", special ? "w-4" : "w-3")} />
)}
</div>
<div
id={`CAMERA_${uid}`}
className={clsx(
"absolute top-0 left-0 z-30 w-full h-full overflow-hidden m-auto",
special ? "" : "rounded"
)}
>
{/* camera placeholder */}
</div>
</li>
);
})}
</ul>
{/* bottom */}
<div className="py-4 flex justify-center gap-2">
<Operations id={id} context={context} mode="fullscreen" />
</div>
</div>
);
};
export default VoiceFullscreen;
+50 -26
View File
@@ -2,19 +2,19 @@ import { memo, useState } from "react";
import { useMatch, useParams } from "react-router-dom";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import BlankPlaceholder from "@/components/BlankPlaceholder";
import ChannelModal from "@/components/ChannelModal";
import ErrorCatcher from "@/components/ErrorCatcher";
import Server from "@/components/Server";
import UsersModal from "@/components/UsersModal";
import ChannelChat from "./ChannelChat";
import DMChat from "./DMChat";
import UsersModal from "@/components/UsersModal";
import ChannelModal from "@/components/ChannelModal";
import SessionList from "./SessionList";
import { useAppSelector } from "@/app/store";
import GuestBlankPlaceholder from "./GuestBlankPlaceholder";
import GuestChannelChat from "./GuestChannelChat";
import GuestSessionList from "./GuestSessionList";
import ErrorCatcher from "@/components/ErrorCatcher";
import RTCWidget from "./RTCWidget";
import SessionList from "./SessionList";
import VoiceFullscreen from "./VoiceFullscreen";
function ChatPage() {
@@ -24,12 +24,19 @@ function ChatPage() {
const [channelModalVisible, setChannelModalVisible] = useState(false);
const [usersModalVisible, setUsersModalVisible] = useState(false);
const { channel_id = 0, user_id = 0 } = useParams();
const { sessionUids, isGuest, aside = "", callingTo } = useAppSelector((store) => {
const {
sessionUids,
isGuest,
aside = "",
callingTo
} = useAppSelector((store) => {
return {
callingTo: store.voice.callingTo,
isGuest: store.authData.guest,
sessionUids: store.userMessage.ids,
aside: channel_id ? store.footprint.channelAsides[+channel_id] : store.footprint.dmAsides[store.voice.callingTo]
aside: channel_id
? store.footprint.channelAsides[+channel_id]
: store.footprint.dmAsides[store.voice.callingTo]
};
});
const toggleUsersModalVisible = () => {
@@ -39,16 +46,18 @@ function ChatPage() {
setChannelModalVisible((prev) => !prev);
};
const toggleSessionList = () => {
setSessionListVisible(prev => !prev);
setSessionListVisible((prev) => !prev);
};
const tmpSession = user_id == 0 ? undefined :
sessionUids.findIndex((i) => i == +user_id) == -1
const tmpSession =
user_id == 0
? undefined
: sessionUids.findIndex((i) => i == +user_id) == -1
? {
mid: 0,
unread: 0,
id: +user_id,
type: "dm" as const
}
mid: 0,
unread: 0,
id: +user_id,
type: "dm" as const
}
: undefined;
// console.log("temp uid", tmpUid);
const placeholderVisible = channel_id == 0 && user_id == 0;
@@ -66,24 +75,39 @@ function ChatPage() {
<ChannelModal closeModal={toggleChannelModalVisible} personal={true} />
)}
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
<div className={clsx(`flex h-screen md:h-full md:pt-2 md:pb-2.5 md:pr-1`, isGuest ? "guest-container md:px-1" : "md:pr-12")}>
{sessionListVisible && <div onClick={toggleSessionList} className="z-30 fixed top-0 left-4 w-screen h-screen bg-black/50 transition-all backdrop-blur-sm"></div>}
<div className={clsx("left-container pb-14 md:pb-0 flex-col md:rounded-l-2xl w-full h-screen md:h-full md:max-w-[250px] md:min-w-[268px] shadow-[rgb(0_0_0_/_10%)_-1px_0px_0px_inset] bg-white dark:!bg-gray-800",
isMainPath ? "flex" : "hidden md:flex"
)}>
<div
className={clsx(
`flex h-screen md:h-full md:pt-2 md:pb-2.5 md:pr-1`,
isGuest ? "guest-container md:px-1" : "md:pr-12"
)}
>
{sessionListVisible && (
<div
onClick={toggleSessionList}
className="z-30 fixed top-0 left-4 w-screen h-screen bg-black/50 transition-all backdrop-blur-sm"
></div>
)}
<div
className={clsx(
"left-container pb-14 md:pb-0 flex-col md:rounded-l-2xl w-full h-screen md:h-full md:max-w-[250px] md:min-w-[268px] shadow-[rgb(0_0_0_/_10%)_-1px_0px_0px_inset] bg-white dark:!bg-gray-800",
isMainPath ? "flex" : "hidden md:flex"
)}
>
<Server readonly={isGuest} />
{isGuest ? <GuestSessionList /> : <SessionList tempSession={tmpSession} />}
<RTCWidget id={+contextId} context={context} />
</div>
<div className={clsx(`right-container md:rounded-r-2xl w-full bg-white dark:!bg-gray-700`, placeholderVisible && "h-full flex-center", isMainPath && "hidden md:flex")}>
<div
className={clsx(
`right-container md:rounded-r-2xl w-full bg-white dark:!bg-gray-700`,
placeholderVisible && "h-full flex-center",
isMainPath && "hidden md:flex"
)}
>
{voiceFullscreenVisible && <VoiceFullscreen id={contextId} context={context} />}
{placeholderVisible && (isGuest ? <GuestBlankPlaceholder /> : <BlankPlaceholder />)}
{channelChatVisible &&
(isGuest ? (
<GuestChannelChat cid={+channel_id} />
) : (
<ChannelChat cid={+channel_id} />
))}
(isGuest ? <GuestChannelChat cid={+channel_id} /> : <ChannelChat cid={+channel_id} />)}
{dmChatVisible && <DMChat uid={+user_id} />}
</div>
</div>
+18 -10
View File
@@ -1,16 +1,17 @@
import React from "react";
import dayjs from "dayjs";
import { useDispatch } from "react-redux";
import { isImage } from "@/utils";
import dayjs from "dayjs";
import { ContentTypes } from "@/app/config";
import Checkbox from "@/components/styled/Checkbox";
import Divider from "@/components/Divider";
import Message from "@/components/Message";
import { updateSelectMessages } from "@/app/slices/ui";
import { useAppSelector } from "@/app/store";
import LinkifyText from "@/components/LinkifyText";
import i18n from "../../i18n";
import { ChatContext } from "@/types/common";
import Divider from "@/components/Divider";
import LinkifyText from "@/components/LinkifyText";
import Message from "@/components/Message";
import Checkbox from "@/components/styled/Checkbox";
import { isImage } from "@/utils";
import i18n from "../../i18n";
export function getUnreadCount({
mids = [],
@@ -105,12 +106,19 @@ const MessageWrapper = ({ selectMode = false, context, id, mid, divider, childre
return (
<div className={`group flex flex-col items-start gap-2 relative w-full `} {...rest}>
{divider}
<div className={`w-full flex items-center ${selectMode ? "group-hover:bg-slate-100 dark:group-hover:bg-slate-900" : ""}`}>
<div
className={`w-full flex items-center ${
selectMode ? "group-hover:bg-slate-100 dark:group-hover:bg-slate-900" : ""
}`}
>
{selectMode && <Checkbox className="!ml-2" checked={selected} />}
{children}
</div>
{selectMode && (
<div className="absolute left-0 top-0 w-full h-full cursor-pointer" onClick={selectMode ? toggleSelect : undefined}></div>
<div
className="absolute left-0 top-0 w-full h-full cursor-pointer"
onClick={selectMode ? toggleSelect : undefined}
></div>
)}
</div>
);
@@ -140,7 +148,7 @@ export const renderMessageFragment = ({
const local_id = curr.properties?.local_id;
let divider = null;
let time = dayjs(created_at).format("YYYY/MM/DD");
if (!prev && typeof prev !== 'undefined') {
if (!prev && typeof prev !== "undefined") {
// 首条信息
divider = time;
} else if (prev) {
+38 -18
View File
@@ -1,19 +1,19 @@
import { useState, useEffect, MouseEvent } from "react";
import dayjs from "dayjs";
import { MouseEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import dayjs from "dayjs";
import { ContentTypes } from "@/app/config";
import { Favorite } from "@/app/slices/favorites";
import { useAppSelector } from "@/app/store";
import FavoredMessage from "@/components/Message/FavoredMessage";
import IconAudio from "@/assets/icons/file.audio.svg";
import IconVideo from "@/assets/icons/file.video.svg";
import IconUnknown from "@/assets/icons/file.unknown.svg";
import IconImage from "@/assets/icons/file.image.svg";
import useFavMessage from "@/hooks/useFavMessage";
import IconChannel from "@/assets/icons/channel.svg";
import IconRemove from "@/assets/icons/close.svg";
import { ContentTypes } from "@/app/config";
import { useAppSelector } from "@/app/store";
import { Favorite } from "@/app/slices/favorites";
import useFavMessage from "@/hooks/useFavMessage";
import IconAudio from "@/assets/icons/file.audio.svg";
import IconImage from "@/assets/icons/file.image.svg";
import IconUnknown from "@/assets/icons/file.unknown.svg";
import IconVideo from "@/assets/icons/file.video.svg";
type filter = "audio" | "video" | "image" | "";
@@ -127,11 +127,16 @@ function FavsPage() {
return (
<li
key={f}
className={clsx(f == filter && 'bg-[rgba(116,_127,_141,_0.2)]', `cursor-pointer flex items-center gap-2 p-2 rounded-lg md:hover:bg-[rgba(116,_127,_141,_0.2)]`)}
className={clsx(
f == filter && "bg-[rgba(116,_127,_141,_0.2)]",
`cursor-pointer flex items-center gap-2 p-2 rounded-lg md:hover:bg-[rgba(116,_127,_141,_0.2)]`
)}
onClick={handleFilter.bind(null, f as filter)}
>
{icon}
<span className="hidden md:block font-bold text-sm text-gray-600 dark:text-gray-100">{title}</span>
<span className="hidden md:block font-bold text-sm text-gray-600 dark:text-gray-100">
{title}
</span>
</li>
);
})}
@@ -145,11 +150,22 @@ function FavsPage() {
source: { gid, uid }
}
] = messages;
const tip = <span className={clsx("inline-flex items-center gap-1 mr-2")}>
{gid ? <><IconChannel className="w-3 h-3" /> {channelData[gid]?.name}</> : <>
From <strong className="font-bold text-gray-800 dark:text-gray-100">{userData[uid]?.name}</strong>
</>}
</span>;
const tip = (
<span className={clsx("inline-flex items-center gap-1 mr-2")}>
{gid ? (
<>
<IconChannel className="w-3 h-3" /> {channelData[gid]?.name}
</>
) : (
<>
From{" "}
<strong className="font-bold text-gray-800 dark:text-gray-100">
{userData[uid]?.name}
</strong>
</>
)}
</span>
);
return (
<div className="max-w-[600px] flex flex-col gap-1" key={id}>
<h4 className="inline-flex items-center text-xs text-gray-400">
@@ -158,7 +174,11 @@ function FavsPage() {
</h4>
<div className="relative group">
<FavoredMessage key={id} id={id} />
<button className="absolute top-2 right-2 flex-center w-6 h-6 p-1 border border-solid border-slate-200 dark:border-slate-700 rounded invisible group-hover:visible" data-id={id} onClick={handleRemove} >
<button
className="absolute top-2 right-2 flex-center w-6 h-6 p-1 border border-solid border-slate-200 dark:border-slate-700 rounded invisible group-hover:visible"
data-id={id}
onClick={handleRemove}
>
<IconRemove className="fill-slate-900 dark:fill-slate-100" />
</button>
</div>
+1
View File
@@ -1,5 +1,6 @@
// import { useEffect } from "react";
import { Navigate } from "react-router-dom";
import { useGuestLoginQuery } from "../app/services/auth";
import { useAppSelector } from "../app/store";
+2
View File
@@ -1,7 +1,9 @@
import { FC } from "react";
import { NavLink, useLocation } from "react-router-dom";
import Tooltip from "@/components/Tooltip";
import IconSetting from "@/assets/icons/setting.svg";
type Props = {};
const Menu: FC<Props> = () => {
const { pathname } = useLocation();
+84 -68
View File
@@ -1,79 +1,95 @@
import clsx from 'clsx';
import React from 'react';
import { NavLink, useLocation, useMatch } from 'react-router-dom';
import { useAppSelector } from '../../app/store';
import React from "react";
import { NavLink, useLocation, useMatch } from "react-router-dom";
import clsx from "clsx";
import ChatIcon from "@/assets/icons/chat.svg";
import UserIcon from "@/assets/icons/user.svg";
import SettingIcon from "@/assets/icons/setting.svg";
import UserIcon from "@/assets/icons/user.svg";
import { useAppSelector } from "../../app/store";
// type Props = {}
const MobileNavs = () => {
const isHomePath = useMatch(`/`);
const { pathname } = useLocation();
const isChatHomePath = useMatch(`/chat`);
const isDMChat = useMatch(`/chat/dm/:user_id`);
// const isSettingPage = useMatch(`/setting`);
const isChannelChat = useMatch(`/chat/channel/:channel_id`);
const {
ui: {
rememberedNavs: { chat: chatPath, user: userPath }
}
} = useAppSelector((store) => {
return {
ui: store.ui,
loginUid: store.authData.user?.uid,
guest: store.authData.guest
};
});
const isHomePath = useMatch(`/`);
const { pathname } = useLocation();
const isChatHomePath = useMatch(`/chat`);
const isDMChat = useMatch(`/chat/dm/:user_id`);
// const isSettingPage = useMatch(`/setting`);
const isChannelChat = useMatch(`/chat/channel/:channel_id`);
const {
ui: {
rememberedNavs: { chat: chatPath, user: userPath }
}
} = useAppSelector((store) => {
return {
ui: store.ui,
loginUid: store.authData.user?.uid,
guest: store.authData.guest
};
});
const linkClass = `flex`;
const isChatPage = isHomePath || pathname.startsWith("/chat");
const isChattingPage = !!isDMChat || !!isChannelChat;
// console.log("rrr", isDMChat, isChannelChat);
const linkClass = `flex`;
const isChatPage = isHomePath || pathname.startsWith("/chat");
const isChattingPage = !!isDMChat || !!isChannelChat;
// console.log("rrr", isDMChat, isChannelChat);
// 有点绕
const chatNav = isChatHomePath ? "/chat" : chatPath || "/chat";
const userNav = userPath || "/users";
return (
<ul className={clsx('flex justify-around py-2 fixed bottom-0 left-0 w-full bg-gray-100 dark:bg-gray-800 md:hidden', isChattingPage && "hidden")}>
<li>
<NavLink
className={() => `${linkClass}`}
to={chatNav}
>
{({ isActive }) => {
const active = isActive || isChatPage;
return <div className='flex flex-col gap-1 items-center'>
<ChatIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Chats</span>
</div>;
}}
</NavLink>
</li>
<li>
<NavLink className={() => `${linkClass}`} to={userNav}>
{({ isActive: active }) => {
return <div className='flex flex-col gap-1 items-center'>
<UserIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Contacts</span>
</div>;
}}
</NavLink>
</li>
<li>
<NavLink className={() => `${linkClass}`} to={'/setting'}>
{({ isActive: active }) => {
return <div className='flex flex-col gap-1 items-center'>
<SettingIcon className={clsx("w-6 h-6", !active ? "fill-gray-500" : "fill-primary-500")} />
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Settings</span>
</div>;
}}
</NavLink>
</li>
</ul>
);
// 有点绕
const chatNav = isChatHomePath ? "/chat" : chatPath || "/chat";
const userNav = userPath || "/users";
return (
<ul
className={clsx(
"flex justify-around py-2 fixed bottom-0 left-0 w-full bg-gray-100 dark:bg-gray-800 md:hidden",
isChattingPage && "hidden"
)}
>
<li>
<NavLink className={() => `${linkClass}`} to={chatNav}>
{({ isActive }) => {
const active = isActive || isChatPage;
return (
<div className="flex flex-col gap-1 items-center">
<ChatIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
Chats
</span>
</div>
);
}}
</NavLink>
</li>
<li>
<NavLink className={() => `${linkClass}`} to={userNav}>
{({ isActive: active }) => {
return (
<div className="flex flex-col gap-1 items-center">
<UserIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
Contacts
</span>
</div>
);
}}
</NavLink>
</li>
<li>
<NavLink className={() => `${linkClass}`} to={"/setting"}>
{({ isActive: active }) => {
return (
<div className="flex flex-col gap-1 items-center">
<SettingIcon
className={clsx("w-6 h-6", !active ? "fill-gray-500" : "fill-primary-500")}
/>
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
Settings
</span>
</div>
);
}}
</NavLink>
</li>
</ul>
);
};
export default MobileNavs;
export default MobileNavs;
+9 -2
View File
@@ -1,7 +1,8 @@
import { FC } from "react";
import { NavLink, useLocation } from "react-router-dom";
import Avatar from "@/components/Avatar";
import { useAppSelector } from "@/app/store";
import Avatar from "@/components/Avatar";
interface Props {
uid: number;
@@ -16,7 +17,13 @@ const User: FC<Props> = ({ uid }) => {
<div className="px-3 py-2.5 invisible md:visible">
<NavLink to={`/setting/my_account?f=${pathname}`}>
<div className="w-8 h-8">
<Avatar className=" object-cover w-full h-full rounded-full" width={32} height={32} src={user.avatar} name={user.name} />
<Avatar
className=" object-cover w-full h-full rounded-full"
width={32}
height={32}
src={user.avatar}
name={user.name}
/>
</div>
</NavLink>
</div>
+67 -37
View File
@@ -1,27 +1,26 @@
import { memo, useEffect } from "react";
import { Outlet, NavLink, useLocation, useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { NavLink, Outlet, useLocation, useMatch } from "react-router-dom";
import User from "./User";
import Loading from "@/components/Loading";
import Menu from "./Menu";
import usePreload from "@/hooks/usePreload";
import Tooltip from "@/components/Tooltip";
import Notification from "@/components/Notification";
import Manifest from "@/components/Manifest";
import ChatIcon from "@/assets/icons/chat.svg";
import UserIcon from "@/assets/icons/user.svg";
import FavIcon from "@/assets/icons/bookmark.svg";
import FolderIcon from "@/assets/icons/folder.svg";
import { useAppSelector } from "@/app/store";
import MobileNavs from "./MobileNavs";
import { updateRememberedNavs } from "@/app/slices/ui";
import UnreadTabTip from "@/components/UnreadTabTip";
import { useAppSelector } from "@/app/store";
import Loading from "@/components/Loading";
import Manifest from "@/components/Manifest";
import Notification from "@/components/Notification";
import ReLoginModal from "@/components/ReLoginModal";
import Voice from "@/components/Voice";
import ServerVersionChecker from "@/components/ServerVersionChecker";
import Tooltip from "@/components/Tooltip";
import UnreadTabTip from "@/components/UnreadTabTip";
import Voice from "@/components/Voice";
import usePreload from "@/hooks/usePreload";
import FavIcon from "@/assets/icons/bookmark.svg";
import ChatIcon from "@/assets/icons/chat.svg";
import FolderIcon from "@/assets/icons/folder.svg";
import UserIcon from "@/assets/icons/user.svg";
import Menu from "./Menu";
import MobileNavs from "./MobileNavs";
import User from "./User";
function HomePage() {
const { t } = useTranslation();
@@ -69,45 +68,76 @@ function HomePage() {
<>
{roleChanged && <ReLoginModal />}
{!guest && <UnreadTabTip />}
{!guest && <ServerVersionChecker empty={true} version="0.3.5">
<Voice />
</ServerVersionChecker>}
{!guest && (
<ServerVersionChecker empty={true} version="0.3.5">
<Voice />
</ServerVersionChecker>
)}
<Manifest />
{!guest && <Notification />}
<div className={`vocechat-container flex w-screen h-screen bg-gray-200 dark:bg-gray-900`}>
{!guest && (
<div className={`hidden md:flex h-full flex-col items-center relative w-16 bg-gray-200 dark:bg-gray-900 transition-all`}>
<div
className={`hidden md:flex h-full flex-col items-center relative w-16 bg-gray-200 dark:bg-gray-900 transition-all`}
>
{loginUid && <User uid={loginUid} />}
<nav className="flex flex-col gap-1 px-3 py-6">
<NavLink
className={({ isActive }) => `${linkClass} ${(isActive || isChattingPage) ? "bg-primary-400 md:hover:bg-primary-400" : ""}`}
className={({ isActive }) =>
`${linkClass} ${
isActive || isChattingPage ? "bg-primary-400 md:hover:bg-primary-400" : ""
}`
}
to={chatNav}
>
{({ isActive }) => {
return <Tooltip tip={t("chat")}>
<ChatIcon className={(isActive || isChattingPage) ? "fill-white" : ""} />
</Tooltip>;
return (
<Tooltip tip={t("chat")}>
<ChatIcon className={isActive || isChattingPage ? "fill-white" : ""} />
</Tooltip>
);
}}
</NavLink>
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={userNav}>
<NavLink
className={({ isActive }) =>
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
}
to={userNav}
>
{({ isActive }) => {
return <Tooltip tip={t("members")}>
<UserIcon className={isActive ? "fill-white" : ""} />
</Tooltip>;
return (
<Tooltip tip={t("members")}>
<UserIcon className={isActive ? "fill-white" : ""} />
</Tooltip>
);
}}
</NavLink>
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={"/favs"}>
<NavLink
className={({ isActive }) =>
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
}
to={"/favs"}
>
{({ isActive }) => {
return <Tooltip tip={t("favs")}>
<FavIcon className={isActive ? "fill-white" : ""} />
</Tooltip>;
return (
<Tooltip tip={t("favs")}>
<FavIcon className={isActive ? "fill-white" : ""} />
</Tooltip>
);
}}
</NavLink>
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={"/files"}>
<NavLink
className={({ isActive }) =>
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
}
to={"/files"}
>
{({ isActive }) => {
return <Tooltip tip={t("files")}>
<FolderIcon className={isActive ? "fill-white" : ""} />
</Tooltip>;
return (
<Tooltip tip={t("files")}>
<FolderIcon className={isActive ? "fill-white" : ""} />
</Tooltip>
);
}}
</NavLink>
</nav>
+104 -24
View File
@@ -1,10 +1,20 @@
import { useEffect, lazy } from "react";
import { Route, Routes, HashRouter } from "react-router-dom";
import { Provider } from "react-redux";
import { lazy, useEffect } from "react";
import toast from "react-hot-toast";
import { Provider } from "react-redux";
import { HashRouter, Route, Routes } from "react-router-dom";
import { isEqual } from "lodash";
import Meta from "@/components/Meta";
import useDeviceToken from "@/components/Notification/useDeviceToken";
import RequireAuth from "@/components/RequireAuth";
import RequireNoAuth from "@/components/RequireNoAuth";
import RequireSingleTab from "@/components/RequireSingleTab";
import { vapidKey } from "../app/config";
import store, { useAppSelector } from "../app/store";
import NotFoundPage from "./404";
import InvitePrivate from "./invitePrivate";
import LazyIt from "./lazy";
// import Welcome from './Welcome'
// const HomePage = lazy(() => import("./home"));
// const ChatPage = lazy(() => import("./chat"));
@@ -25,15 +35,7 @@ const ResourceManagement = lazy(() => import("./resources"));
const GuestLogin = lazy(() => import("./guest"));
const ChatPage = lazy(() => import("./chat"));
const HomePage = lazy(() => import("./home"));
import RequireAuth from "@/components/RequireAuth";
import RequireNoAuth from "@/components/RequireNoAuth";
import Meta from "@/components/Meta";
import LazyIt from './lazy';
import store, { useAppSelector } from "../app/store";
import useDeviceToken from "@/components/Notification/useDeviceToken";
import { vapidKey } from "../app/config";
import RequireSingleTab from "@/components/RequireSingleTab";
import InvitePrivate from "./invitePrivate";
let toastId: string;
const PageRoutes = () => {
const {
@@ -57,10 +59,40 @@ const PageRoutes = () => {
return (
<HashRouter>
<Routes>
<Route path="/guest_login" element={<LazyIt><GuestLogin /></LazyIt>} />
<Route path="/invite_private/:channel_id" element={<LazyIt><RequireAuth><InvitePrivate /></RequireAuth></LazyIt>} />
<Route path="/cb/:type/:payload" element={<LazyIt><CallbackPage /></LazyIt>} />
<Route path="/oauth/:token" element={<LazyIt><OAuthPage /></LazyIt>} />
<Route
path="/guest_login"
element={
<LazyIt>
<GuestLogin />
</LazyIt>
}
/>
<Route
path="/invite_private/:channel_id"
element={
<LazyIt>
<RequireAuth>
<InvitePrivate />
</RequireAuth>
</LazyIt>
}
/>
<Route
path="/cb/:type/:payload"
element={
<LazyIt>
<CallbackPage />
</LazyIt>
}
/>
<Route
path="/oauth/:token"
element={
<LazyIt>
<OAuthPage />
</LazyIt>
}
/>
<Route
path="/login"
element={
@@ -91,8 +123,22 @@ const PageRoutes = () => {
</LazyIt>
}
>
<Route index element={<LazyIt><RegPage /></LazyIt>} />
<Route path="set_name/:from?" element={<LazyIt><RegWithUsernamePage /></LazyIt>} />
<Route
index
element={
<LazyIt>
<RegPage />
</LazyIt>
}
/>
<Route
path="set_name/:from?"
element={
<LazyIt>
<RegWithUsernamePage />
</LazyIt>
}
/>
</Route>
<Route
path="/email_login"
@@ -104,7 +150,14 @@ const PageRoutes = () => {
</LazyIt>
}
/>
<Route path="/onboarding" element={<LazyIt><OnboardingPage /></LazyIt>} />
<Route
path="/onboarding"
element={
<LazyIt>
<OnboardingPage />
</LazyIt>
}
/>
<Route
key={"main"}
@@ -129,9 +182,30 @@ const PageRoutes = () => {
</LazyIt>
}
/>
<Route path=":nav?" element={<LazyIt><SettingPage /></LazyIt>} />
<Route path="channel/:cid/:nav?" element={<LazyIt><SettingChannelPage /></LazyIt>} />
<Route path="dm/:uid/:nav?" element={<LazyIt><SettingDMPage /></LazyIt>} />
<Route
path=":nav?"
element={
<LazyIt>
<SettingPage />
</LazyIt>
}
/>
<Route
path="channel/:cid/:nav?"
element={
<LazyIt>
<SettingChannelPage />
</LazyIt>
}
/>
<Route
path="dm/:uid/:nav?"
element={
<LazyIt>
<SettingDMPage />
</LazyIt>
}
/>
</Route>
<Route
index
@@ -142,7 +216,14 @@ const PageRoutes = () => {
}
/>
<Route path="chat">
<Route index element={<LazyIt><ChatPage /></LazyIt>} />
<Route
index
element={
<LazyIt>
<ChatPage />
</LazyIt>
}
/>
<Route
path="channel/:channel_id"
element={
@@ -202,7 +283,6 @@ const PageRoutes = () => {
};
export default function ReduxRoutes() {
return (
<Provider store={store}>
<Meta />
+67 -56
View File
@@ -1,61 +1,72 @@
import { useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from '../../app/services/channel';
import StyledButton from '../../components/styled/Button';
import { useCheckMagicTokenValidMutation } from '../../app/services/auth';
import { useAppSelector } from '../../app/store';
import { useEffect } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useCheckMagicTokenValidMutation } from "../../app/services/auth";
import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from "../../app/services/channel";
import { useAppSelector } from "../../app/store";
import StyledButton from "../../components/styled/Button";
const InvitePrivate = () => {
const { channel_id } = useParams();
const server = useAppSelector(store => store.server);
const navigateTo = useNavigate();
const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation();
const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] = useLazyGetChannelQuery();
const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] =
useCheckMagicTokenValidMutation();
let [searchParams] = useSearchParams(new URLSearchParams(location.search));
const magic_token = searchParams.get("magic_token") ?? "";
useEffect(() => {
if (channel_id) {
fetchChannelInfo(+channel_id);
}
}, [channel_id]);
useEffect(() => {
if (magic_token) {
checkTokenInvalid(magic_token);
}
}, [magic_token]);
useEffect(() => {
if (data && isSuccess) {
// joinChannel(data)
navigateTo(`/chat/channel/${data.gid}`);
}
}, [isSuccess, data]);
const handleJoin = async () => {
const resp = await joinChannel({ magic_token });
if ("error" in resp) {
if (resp.error.originalStatus === 409) {
// alert("The invite link is invalid or expired");
}
}
};
if (!fetchChannelSuccess) return null;
return (
<div className="flex-center flex-col gap-4 h-screen overflow-x-hidden overflow-y-auto dark:bg-gray-700 dark:text-slate-100">
<div className="flex flex-col gap-4 items-center py-8 px-10 rounded-lg shadow-md bg-slate-100/30 dark:bg-gray-800 text-center">
<div className="flex flex-col items-center gap-4">
<img src={server.logo} className='w-20 h-20' alt="server logo" />
<h2 className="text-2xl font-bold">
{server.name}
</h2>
</div>
<span>
{checkingToken ? "Checking..." : isTokenValid ? <>You are invited to join private channel <strong className='text-primary-400'>#{channel?.name}</strong></> : "The invite link is invalid or expired"}
</span>
<StyledButton disabled={isLoading || checkingToken || !isTokenValid} onClick={handleJoin}>Join</StyledButton>
</div>
const { channel_id } = useParams();
const server = useAppSelector((store) => store.server);
const navigateTo = useNavigate();
const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation();
const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] =
useLazyGetChannelQuery();
const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] =
useCheckMagicTokenValidMutation();
let [searchParams] = useSearchParams(new URLSearchParams(location.search));
const magic_token = searchParams.get("magic_token") ?? "";
useEffect(() => {
if (channel_id) {
fetchChannelInfo(+channel_id);
}
}, [channel_id]);
useEffect(() => {
if (magic_token) {
checkTokenInvalid(magic_token);
}
}, [magic_token]);
useEffect(() => {
if (data && isSuccess) {
// joinChannel(data)
navigateTo(`/chat/channel/${data.gid}`);
}
}, [isSuccess, data]);
const handleJoin = async () => {
const resp = await joinChannel({ magic_token });
if ("error" in resp) {
if (resp.error.originalStatus === 409) {
// alert("The invite link is invalid or expired");
}
}
};
if (!fetchChannelSuccess) return null;
return (
<div className="flex-center flex-col gap-4 h-screen overflow-x-hidden overflow-y-auto dark:bg-gray-700 dark:text-slate-100">
<div className="flex flex-col gap-4 items-center py-8 px-10 rounded-lg shadow-md bg-slate-100/30 dark:bg-gray-800 text-center">
<div className="flex flex-col items-center gap-4">
<img src={server.logo} className="w-20 h-20" alt="server logo" />
<h2 className="text-2xl font-bold">{server.name}</h2>
</div>
);
<span>
{checkingToken ? (
"Checking..."
) : isTokenValid ? (
<>
You are invited to join private channel{" "}
<strong className="text-primary-400">#{channel?.name}</strong>
</>
) : (
"The invite link is invalid or expired"
)}
</span>
<StyledButton disabled={isLoading || checkingToken || !isTokenValid} onClick={handleJoin}>
Join
</StyledButton>
</div>
</div>
);
};
export default InvitePrivate;
export default InvitePrivate;
+4 -7
View File
@@ -1,16 +1,13 @@
import { ReactNode, Suspense, FC } from "react";
import { FC, ReactNode, Suspense } from "react";
import Loading from "@/components/Loading";
type Props = {
children?: ReactNode;
}
};
const Lazy: FC<Props> = ({ children }) => {
return (
<Suspense fallback={<Loading fullscreen={true} />}>
{children}
</Suspense>
);
return <Suspense fallback={<Loading fullscreen={true} />}>{children}</Suspense>;
};
export default Lazy;
+6 -1
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "react-router-dom";
import Button from "@/components/styled/Button";
export default function MagicLinkLogin() {
@@ -7,5 +8,9 @@ export default function MagicLinkLogin() {
navigate("/send_magic_link");
};
return <Button className="w-full" onClick={handleLogin}>Sign in with Magic Link</Button>;
return (
<Button className="w-full" onClick={handleLogin}>
Sign in with Magic Link
</Button>
);
}
+13 -7
View File
@@ -1,12 +1,14 @@
/* eslint-disable no-undef */
import { useState, useEffect, useRef } from "react";
import MetaMaskOnboarding from "@metamask/onboarding";
import { useLazyGetMetamaskNonceQuery } from "@/app/services/auth";
import metamaskSvg from "@/assets/icons/metamask.svg?url";
import { LoginCredential } from "@/types/auth";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import MetaMaskOnboarding from "@metamask/onboarding";
import { useLazyGetMetamaskNonceQuery } from "@/app/services/auth";
import { LoginCredential } from "@/types/auth";
import { AuthType } from "@/types/common";
import Button from "@/components/styled/Button";
import metamaskSvg from "@/assets/icons/metamask.svg?url";
// import toast from "react-hot-toast";
export default function MetamaskLoginButton({
@@ -14,7 +16,7 @@ export default function MetamaskLoginButton({
type = "login"
}: {
login: (params: LoginCredential) => void;
type?: AuthType
type?: AuthType;
}) {
const { t } = useTranslation("auth");
const [requesting, setRequesting] = useState(false);
@@ -93,7 +95,11 @@ export default function MetamaskLoginButton({
}
};
return (
<Button className="flex ghost flex-center gap-2" disabled={requesting} onClick={handleMetamaskLogin}>
<Button
className="flex ghost flex-center gap-2"
disabled={requesting}
onClick={handleMetamaskLogin}
>
<img className="w-6 h-6" src={metamaskSvg} alt="meta mask icon" />
{type == "login" ? t("login.metamask") : t("reg.metamask")}
</Button>
+8 -6
View File
@@ -1,16 +1,18 @@
/* eslint-disable no-undef */
import { FC, useState } from "react";
import StyledModal from "@/components/styled/Modal";
import { useTranslation } from "react-i18next";
import { OIDCConfig } from "@/types/auth";
import { AuthType } from "@/types/common";
import Modal from "@/components/Modal";
import StyledButton from "@/components/styled/Button";
import OidcLoginEntry from "./OidcLoginEntry";
import { OIDCConfig } from "@/types/auth";
import { useTranslation } from "react-i18next";
import { AuthType } from "@/types/common";
import Button from "@/components/styled/Button";
import StyledModal from "@/components/styled/Modal";
import OidcLoginEntry from "./OidcLoginEntry";
interface IProps {
issuers?: OIDCConfig[];
type?: AuthType
type?: AuthType;
}
const OidcLoginButton: FC<IProps> = ({ issuers, type = "login" }) => {
const { t } = useTranslation("auth");
+8 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, FC } from "react";
import { FC, useEffect } from "react";
import { useGetOpenidMutation } from "@/app/services/auth";
import Button from "@/components/styled/Button";
import { OIDCConfig } from "@/types/auth";
import Button from "@/components/styled/Button";
const OidcLoginEntry: FC<{ issuer: OIDCConfig }> = ({ issuer }) => {
const [getOpenId, { data, isLoading, isSuccess }] = useGetOpenidMutation();
@@ -21,7 +22,11 @@ const OidcLoginEntry: FC<{ issuer: OIDCConfig }> = ({ issuer }) => {
}, [data, isSuccess]);
return (
<Button className="flex flex-center gap-3" disabled={isLoading || isSuccess} onClick={handleSolidLogin}>
<Button
className="flex flex-center gap-3"
disabled={isLoading || isSuccess}
onClick={handleSolidLogin}
>
{!!issuer.favicon && <img src={issuer.favicon} className="w-6 h-6" alt="icon" />}
{isLoading ? `Loading...` : `Login with ${issuer.domain}`}
</Button>
+3 -1
View File
@@ -13,7 +13,9 @@ export default function SignUpLink() {
return (
<div className="flex gap-1 mt-7 text-sm text-slate-500 dark:text-gray-100 justify-center">
<span>{t("login.no_account")}</span>
<a className="text-primary-400 cursor-pointer" onClick={handleSignUp}>{t("sign_up")}</a>
<a className="text-primary-400 cursor-pointer" onClick={handleSignUp}>
{t("sign_up")}
</a>
</div>
);
}
+44 -43
View File
@@ -1,52 +1,53 @@
import { useEffect } from 'react';
import toast from 'react-hot-toast';
import { useEffect } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useGetLoginConfigQuery } from "@/app/services/server";
import GithubLoginButton from "@/components/GithubLoginButton";
import GoogleLoginButton from "@/components/GoogleLoginButton";
import useGithubAuthConfig from "@/hooks/useGithubAuthConfig";
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
import GoogleLoginButton from "@/components/GoogleLoginButton";
import GithubLoginButton from "@/components/GithubLoginButton";
import { useLoginMutation } from "../../app/services/auth";
import { AuthType } from "../../types/common";
import { LoginConfig } from "../../types/server";
import MetamaskLoginButton from "./MetamaskLoginButton";
import OidcLoginButton from "./OidcLoginButton";
import { useLoginMutation } from '../../app/services/auth';
import { LoginConfig } from '../../types/server';
import { AuthType } from '../../types/common';
import { useTranslation } from 'react-i18next';
type Props = {
type?: AuthType
}
const SocialLoginButtons = ({ type = "login" }: Props) => {
const { t: ct } = useTranslation();
const [login, { isSuccess }] = useLoginMutation();
const { config: githubAuthConfig } = useGithubAuthConfig();
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
const { clientId } = useGoogleAuthConfig();
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.login"));
// navigateTo("/");
}
}, [isSuccess]);
if (!loginConfigSuccess) return null;
const {
github: enableGithubLogin,
google: enableGoogleLogin,
metamask: enableMetamaskLogin,
oidc = [],
} = loginConfig as LoginConfig;
const googleLogin = enableGoogleLogin && !!clientId;
return (
<>
{googleLogin && <GoogleLoginButton type={type} clientId={clientId} />}
{enableGithubLogin && (
<GithubLoginButton type={type} client_id={githubAuthConfig?.client_id} />
)}
{enableMetamaskLogin && <MetamaskLoginButton type={type} login={login} />}
{oidc.length > 0 && <OidcLoginButton type={type} issuers={oidc} />}
</>
);
type?: AuthType;
};
export default SocialLoginButtons;
const SocialLoginButtons = ({ type = "login" }: Props) => {
const { t: ct } = useTranslation();
const [login, { isSuccess }] = useLoginMutation();
const { config: githubAuthConfig } = useGithubAuthConfig();
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
const { clientId } = useGoogleAuthConfig();
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.login"));
// navigateTo("/");
}
}, [isSuccess]);
if (!loginConfigSuccess) return null;
const {
github: enableGithubLogin,
google: enableGoogleLogin,
metamask: enableMetamaskLogin,
oidc = []
} = loginConfig as LoginConfig;
const googleLogin = enableGoogleLogin && !!clientId;
return (
<>
{googleLogin && <GoogleLoginButton type={type} clientId={clientId} />}
{enableGithubLogin && (
<GithubLoginButton type={type} client_id={githubAuthConfig?.client_id} />
)}
{enableMetamaskLogin && <MetamaskLoginButton type={type} login={login} />}
{oidc.length > 0 && <OidcLoginButton type={type} issuers={oidc} />}
</>
);
};
export default SocialLoginButtons;
+20 -15
View File
@@ -1,23 +1,23 @@
/* eslint-disable no-undef */
import { useState, useEffect, FormEvent, ChangeEvent } from "react";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import BASE_URL from "@/app/config";
import { useTranslation } from "react-i18next";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import Input from "@/components/styled/Input";
import Button from "@/components/styled/Button";
import MagicLinkLogin from "./MagicLinkLogin";
import SignUpLink from "./SignUpLink";
import BASE_URL from "@/app/config";
import { useLoginMutation } from "@/app/services/auth";
import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server";
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import { useTranslation } from "react-i18next";
import SocialLoginButtons from "./SocialLoginButtons";
import Divider from "@/components/Divider";
import { useAppSelector } from "@/app/store";
import Divider from "@/components/Divider";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
import MagicLinkLogin from "./MagicLinkLogin";
import SignUpLink from "./SignUpLink";
import SocialLoginButtons from "./SocialLoginButtons";
export default function LoginPage() {
const serverName = useAppSelector(store => store.server.name);
const serverName = useAppSelector((store) => store.server.name);
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
const { data: enableSMTP, isLoading: loadingSMTPStatus } = useGetSMTPStatusQuery();
@@ -129,8 +129,14 @@ export default function LoginPage() {
<div className="flex-center h-screen dark:bg-gray-700">
<div className="py-8 px-10 shadow-md rounded-xl">
<div className="flex-center flex-col pb-6">
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 mb-3 md:mb-7 rounded-full" />
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">{t("login.title", { name: serverName })}</h2>
<img
src={`${BASE_URL}/resource/organization/logo`}
alt="logo"
className="w-14 h-14 mb-3 md:mb-7 rounded-full"
/>
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">
{t("login.title", { name: serverName })}
</h2>
<span className="text-gray-400 dark:text-gray-100">{t("login.desc")}</span>
</div>
<form className="flex flex-col gap-5 w-80 md:min-w-[360px] " onSubmit={handleLogin}>
@@ -160,7 +166,6 @@ export default function LoginPage() {
</form>
{hasDivider && <Divider content="OR" />}
<div className="flex flex-col gap-4">
{enableMagicLink && <MagicLinkLogin />}
<SocialLoginButtons />
+4 -3
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { useLoginMutation } from "@/app/services/auth";
import toast from "react-hot-toast";
import { setAuthData } from "@/app/slices/auth.data";
import { useTranslation } from "react-i18next";
export default function OAuthPage() {
const { t: ct } = useTranslation();
+14 -10
View File
@@ -1,17 +1,16 @@
import React, { useState } from "react";
import { Helmet } from "react-helmet";
import { useWizard, Wizard } from 'react-use-wizard';
import WelcomePage from "./steps/welcome-page";
import ServerName from "./steps/server-name";
import AdminAccount from "./steps/admin-account";
import WhoCanSignUp from "./steps/who-can-sign-up";
import InviteLink from "./steps/invite-link";
import DonePage from "./steps/done-page";
import steps from "./steps";
import { useTranslation } from "react-i18next";
import { useWizard, Wizard } from "react-use-wizard";
import clsx from "clsx";
import steps from "./steps";
import AdminAccount from "./steps/admin-account";
import DonePage from "./steps/done-page";
import InviteLink from "./steps/invite-link";
import ServerName from "./steps/server-name";
import WelcomePage from "./steps/welcome-page";
import WhoCanSignUp from "./steps/who-can-sign-up";
const Navigator = () => {
const { activeStep, goToStep } = useWizard();
@@ -22,7 +21,12 @@ const Navigator = () => {
<div className="hidden md:flex absolute top-5 w-full justify-center gap-2 z-10">
{steps.map((stepToRender, indexToRender) => {
const clickable = canJumpTo.includes(stepToRender.name);
const itemClass = clsx(`text-sm text-gray-600`, clickable && "cursor-pointer md:hover:text-gray-500", indexToRender === activeStep && "font-bold text-black", indexToRender >= activeStep && "text-gray-400");
const itemClass = clsx(
`text-sm text-gray-600`,
clickable && "cursor-pointer md:hover:text-gray-500",
indexToRender === activeStep && "font-bold text-black",
indexToRender >= activeStep && "text-gray-400"
);
const nodeCls = `${itemClass}`;
return (
<React.Fragment key={indexToRender}>
@@ -1,53 +1,65 @@
import { useState, useEffect, ChangeEvent } from 'react';
import { ChangeEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import StyledInput from "@/components/styled/Input";
import StyledButton from "@/components/styled/Button";
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "@/app/services/server";
import InfoIcon from '@/assets/icons/info.svg';
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "@/app/services/server";
import StyledButton from "@/components/styled/Button";
import StyledInput from "@/components/styled/Input";
import InfoIcon from "@/assets/icons/info.svg";
type Props = {
refreshInviteLink: () => void
}
const UpdateFrontendURL = ({ refreshInviteLink }: Props) => {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
const { t: ct } = useTranslation();
const [updateUrl, { isSuccess, isLoading }] = useUpdateFrontendUrlMutation();
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
const [frontUrl, setFrontUrl] = useState(location.origin);
const handleUpdateUrl = (evt: ChangeEvent<HTMLInputElement>) => {
setFrontUrl(evt.target.value);
};
const updateFrontUrl = () => {
updateUrl(frontUrl);
};
useEffect(() => {
if (getUrlSuccess && data) {
setFrontUrl(data);
}
}, [getUrlSuccess]);
useEffect(() => {
if (isSuccess) {
refreshInviteLink();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
return (
<div className="absolute left-1/2 -translate-x-1/2 bottom-8 border-2 border-solid border-primary-300 dark:border-primary-700 bg-primary-25 dark:bg-primary-900 rounded-lg px-2 py-3 flex justify-start gap-4">
<InfoIcon />
<div className="flex flex-col items-start gap-2">
<span className="text-sm text-primary-700 dark:text-primary-300 mb-1">{t("update_domain_tip")}</span>
<div className="w-[300px] md:w-[400px] rounded flex gap-2">
<StyledInput type={"url"} className="!shadow-none md:!bg-transparent" placeholder="Frontend URL" value={frontUrl} onChange={handleUpdateUrl} />
<StyledButton disabled={!frontUrl || isLoading} onClick={updateFrontUrl} className="small ">
{ct("action.update")}
</StyledButton>
</div>
</div>
</div>
);
refreshInviteLink: () => void;
};
export default UpdateFrontendURL;
const UpdateFrontendURL = ({ refreshInviteLink }: Props) => {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
const { t: ct } = useTranslation();
const [updateUrl, { isSuccess, isLoading }] = useUpdateFrontendUrlMutation();
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
const [frontUrl, setFrontUrl] = useState(location.origin);
const handleUpdateUrl = (evt: ChangeEvent<HTMLInputElement>) => {
setFrontUrl(evt.target.value);
};
const updateFrontUrl = () => {
updateUrl(frontUrl);
};
useEffect(() => {
if (getUrlSuccess && data) {
setFrontUrl(data);
}
}, [getUrlSuccess]);
useEffect(() => {
if (isSuccess) {
refreshInviteLink();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
return (
<div className="absolute left-1/2 -translate-x-1/2 bottom-8 border-2 border-solid border-primary-300 dark:border-primary-700 bg-primary-25 dark:bg-primary-900 rounded-lg px-2 py-3 flex justify-start gap-4">
<InfoIcon />
<div className="flex flex-col items-start gap-2">
<span className="text-sm text-primary-700 dark:text-primary-300 mb-1">
{t("update_domain_tip")}
</span>
<div className="w-[300px] md:w-[400px] rounded flex gap-2">
<StyledInput
type={"url"}
className="!shadow-none md:!bg-transparent"
placeholder="Frontend URL"
value={frontUrl}
onChange={handleUpdateUrl}
/>
<StyledButton
disabled={!frontUrl || isLoading}
onClick={updateFrontUrl}
className="small "
>
{ct("action.update")}
</StyledButton>
</div>
</div>
</div>
);
};
export default UpdateFrontendURL;
+10 -8
View File
@@ -1,18 +1,19 @@
import { useEffect, useState, FC, useRef } from "react";
import { FC, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import StyledInput from "@/components/styled/Input";
import StyledButton from "@/components/styled/Button";
import { useWizard } from "react-use-wizard";
import { useLoginMutation } from "@/app/services/auth";
import {
useCreateAdminMutation,
useGetServerQuery,
useUpdateServerMutation
} from "@/app/services/server";
import { useLoginMutation } from "@/app/services/auth";
import { updateInitialized } from "@/app/slices/auth.data";
import { useAppSelector } from "@/app/store";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledButton from "@/components/styled/Button";
import StyledInput from "@/components/styled/Input";
type Props = {
serverName: string;
@@ -23,8 +24,9 @@ const AdminAccount: FC<Props> = ({ serverName }) => {
const formRef = useRef<HTMLFormElement | undefined>();
const loggedIn = useAppSelector((store) => !!store.authData.token);
const dispatch = useDispatch();
const [createAdmin, { isLoading: isSigningUp, isError: signUpError, isSuccess: signUpOk }] = useCreateAdminMutation();
const [login, { isLoading: isLoggingIn, isError: loginError, }] = useLoginMutation();
const [createAdmin, { isLoading: isSigningUp, isError: signUpError, isSuccess: signUpOk }] =
useCreateAdminMutation();
const [login, { isLoading: isLoggingIn, isError: loginError }] = useLoginMutation();
const { data: serverData } = useGetServerQuery();
const [updateServer, { isLoading: isUpdatingServer, isSuccess: isUpdatedServer }] =
useUpdateServerMutation();
+6 -3
View File
@@ -1,8 +1,8 @@
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import StyledButton from "@/components/styled/Button";
import PlayIcon from "@/assets/icons/play.svg?url";
import { Trans, useTranslation } from "react-i18next";
export default function DonePage({ serverName }: { serverName: string }) {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
@@ -17,7 +17,10 @@ export default function DonePage({ serverName }: { serverName: string }) {
<span className="font-bold" />
</Trans>
</span>
<StyledButton className="!w-32 h-auto flex flex-col items-center py-3" onClick={() => navigate("/")}>
<StyledButton
className="!w-32 h-auto flex flex-col items-center py-3"
onClick={() => navigate("/")}
>
<img className="mb-2" src={PlayIcon} alt="play icon" />
<span className="text-sm">{t("enter")}</span>
</StyledButton>
+14 -6
View File
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledInput from "@/components/styled/Input";
import StyledButton from "@/components/styled/Button";
import useInviteLink from "@/hooks/useInviteLink";
import ServerVersionChecker from "@/components/ServerVersionChecker";
import StyledButton from "@/components/styled/Button";
import StyledInput from "@/components/styled/Input";
import useInviteLink from "@/hooks/useInviteLink";
import UpdateFrontendURL from "./UpdateFrontendURL";
export default function InviteLink() {
@@ -19,8 +19,16 @@ export default function InviteLink() {
<span className="text-sm mb-10 text-gray-400 dark:text-gray-600">{t("last_tip")}</span>
<span className="text-sm text-gray-500 mb-2 font-semibold">{t("last_desc")}</span>
<div className="w-full md:w-[400px] rounded shadow-md flex border border-solid border-gray-100">
<StyledInput className="large !border-none !shadow-none" readOnly placeholder="Generating" value={link} />
<StyledButton onClick={copyLink} className="ghost small border_less !px-2 md:hover:!text-primary-600">
<StyledInput
className="large !border-none !shadow-none"
readOnly
placeholder="Generating"
value={link}
/>
<StyledButton
onClick={copyLink}
className="ghost small border_less !px-2 md:hover:!text-primary-600"
>
{linkCopied ? "Copied" : ct("action.copy")}
</StyledButton>
</div>
+4 -5
View File
@@ -1,10 +1,11 @@
import { FC } from "react";
import toast from "react-hot-toast";
import StyledInput from "@/components/styled/Input";
import StyledButton from "@/components/styled/Button";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledButton from "@/components/styled/Button";
import StyledInput from "@/components/styled/Input";
type Props = {
serverName: string;
setServerName: (name: string) => void;
@@ -16,9 +17,7 @@ const ServerName: FC<Props> = ({ serverName, setServerName }) => {
return (
<div className="h-full flex-center flex-col text-center w-[360px] m-auto dark:text-gray-200">
<span className="text-2xl mb-2 font-bold">{t("new_server")}</span>
<span className="text-sm mb-6 text-gray-400 dark:text-gray-600">
{t("server_desc")}
</span>
<span className="text-sm mb-6 text-gray-400 dark:text-gray-600">{t("server_desc")}</span>
<StyledInput
className="h-11 px-3.5 py-2.5 border-gray-300 rounded-lg shadow"
placeholder={t("placeholder_server")}
+7 -6
View File
@@ -1,8 +1,8 @@
import StyledButton from "@/components/styled/Button";
import PlayIcon from "@/assets/icons/play.svg?url";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledButton from "@/components/styled/Button";
import PlayIcon from "@/assets/icons/play.svg?url";
export default function WelcomePage() {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
@@ -10,10 +10,11 @@ export default function WelcomePage() {
return (
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
<span className="text-sm mb-6">
{t("welcome_desc")}
</span>
<StyledButton className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm" onClick={nextStep}>
<span className="text-sm mb-6">{t("welcome_desc")}</span>
<StyledButton
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
onClick={nextStep}
>
<img src={PlayIcon} alt="play icon" />
<span>{t("start")}</span>
</StyledButton>
+13 -12
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import StyledRadio from "@/components/styled/Radio";
import StyledButton from "@/components/styled/Button";
import { useGetLoginConfigQuery, useUpdateLoginConfigMutation } from "@/app/services/server";
import { WhoCanSignUp } from "@/types/server";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import { useGetLoginConfigQuery, useUpdateLoginConfigMutation } from "@/app/services/server";
import { WhoCanSignUp } from "@/types/server";
import StyledButton from "@/components/styled/Button";
import StyledRadio from "@/components/styled/Radio";
export default function SignUpSetting() {
const { t } = useTranslation("welcome");
const { t: st } = useTranslation("setting");
@@ -39,17 +40,18 @@ export default function SignUpSetting() {
if (isSuccess) nextStep();
}, [isSuccess]);
return (
<div className="h-full px-2 flex-center flex-col text-center w-full md:w-[512px] m-auto dark:text-gray-100">
<span className="font-bold text-2xl mb-2">{t("onboarding.invite_title")}</span>
<span className="text-sm mb-6">{t("onboarding.invite_desc")}</span>
{value && <StyledRadio
options={[st("overview.sign_up.everyone"), st("overview.sign_up.invite")]}
values={["EveryOne", "InvitationOnly"]}
value={value}
onChange={setValue}
/>}
{value && (
<StyledRadio
options={[st("overview.sign_up.everyone"), st("overview.sign_up.invite")]}
values={["EveryOne", "InvitationOnly"]}
value={value}
onChange={setValue}
/>
)}
<StyledButton
className="w-32 mt-6"
disabled={!value}
@@ -57,7 +59,6 @@ export default function SignUpSetting() {
// nextStep();
if (loginConfig !== undefined) {
if (loginConfig.who_can_sign_up !== value) {
updateLoginConfig({
...loginConfig,
who_can_sign_up: value
+3 -2
View File
@@ -1,9 +1,10 @@
export default function EmailNextTip() {
return (
<div className="flex flex-col items-center">
<div className="font-bold text-3xl text-gray-800 dark:text-white mt-3">Magic link Sent</div>
<p className="text-center text-gray-400 dark:text-gray-300 mb-6">Login to your email client, and continue next step</p>
<p className="text-center text-gray-400 dark:text-gray-300 mb-6">
Login to your email client, and continue next step
</p>
<p className="text-center text-gray-400 dark:text-gray-300">You can close this window now.</p>
</div>
);
+14 -8
View File
@@ -1,14 +1,18 @@
import { useState, useEffect, FC, FormEvent, ChangeEvent } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ChangeEvent, FC, FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import {
useCheckMagicTokenValidMutation,
useLoginMutation,
useRegisterMutation
} from "@/app/services/auth";
import { setAuthData } from "@/app/slices/auth.data";
import Input from "@/components/styled/Input";
import Button from "@/components/styled/Button";
import { useLoginMutation, useCheckMagicTokenValidMutation } from "@/app/services/auth";
import Input from "@/components/styled/Input";
import ExpiredTip from "./ExpiredTip";
import { useRegisterMutation } from "@/app/services/auth";
import { useMagicToken } from "./index";
const RegWithUsername: FC = () => {
@@ -100,7 +104,9 @@ const RegWithUsername: FC = () => {
return (
<>
<div className="flex-center flex-col pb-6 max-w-md">
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-100 mb-2">{t("reg.input_name")}</h2>
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-100 mb-2">
{t("reg.input_name")}
</h2>
<span className="text-gray-400 text-center dark:text-gray-100">
{t("reg.input_name_tip")}
</span>
+52 -34
View File
@@ -1,38 +1,43 @@
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import BASE_URL, { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
import Input from "@/components/styled/Input";
import Button from "@/components/styled/Button";
import { useLazyCheckEmailQuery, useRegisterMutation, useSendRegMagicLinkMutation } from "@/app/services/auth";
import EmailNextTip from "./EmailNextStepTip";
import SignInLink from "./SignInLink";
import Divider from "@/components/Divider";
import { useGetLoginConfigQuery } from "@/app/services/server";
import SocialLoginButtons from "../login/SocialLoginButtons";
import { setAuthData } from "@/app/slices/auth.data";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import BASE_URL, { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
import {
useLazyCheckEmailQuery,
useRegisterMutation,
useSendRegMagicLinkMutation
} from "@/app/services/auth";
import { useGetLoginConfigQuery } from "@/app/services/server";
import { setAuthData } from "@/app/slices/auth.data";
import { useAppSelector } from "@/app/store";
import Divider from "@/components/Divider";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import SocialLoginButtons from "../login/SocialLoginButtons";
import EmailNextTip from "./EmailNextStepTip";
import { useMagicToken } from "./index";
import SignInLink from "./SignInLink";
interface AuthForm {
name?: string,
name?: string;
email: string;
password: string;
confirmPassword: string;
}
export default function Register() {
const serverName = useAppSelector(store => store.server.name);
const serverName = useAppSelector((store) => store.server.name);
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
const [sendRegMagicLink, { isLoading: signingUp, data, isSuccess }] =
useSendRegMagicLinkMutation();
const dispatch = useDispatch();
const { token: magic_token } = useMagicToken();
const [register, { isLoading: registering, data: regData, isSuccess: regSuccess }] = useRegisterMutation();
const [register, { isLoading: registering, data: regData, isSuccess: regSuccess }] =
useRegisterMutation();
const [checkEmail, { isLoading: checkingEmail }] = useLazyCheckEmailQuery();
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
@@ -113,13 +118,13 @@ export default function Register() {
});
};
if (!loginConfigSuccess) return null;
const {
who_can_sign_up: whoCanSignUp
} = loginConfig;
const { who_can_sign_up: whoCanSignUp } = loginConfig;
// magic token 没有并且没有开放注册
if (whoCanSignUp !== "EveryOne" && !magic_token)
// todo: i18n
return <span className="dark:text-white">Sign up method is updated to Invitation Link Only</span>;
return (
<span className="dark:text-white">Sign up method is updated to Invitation Link Only</span>
);
const { name, email, password, confirmPassword } = input;
if (data?.mail_is_sent) return <EmailNextTip />;
const isLoading = registering || signingUp || checkingEmail;
@@ -127,23 +132,36 @@ export default function Register() {
return (
<>
<div className="flex-center flex-col pb-6">
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 md:mb-7 rounded-full" />
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">{t("reg.title", { name: serverName })}</h2>
<img
src={`${BASE_URL}/resource/organization/logo`}
alt="logo"
className="w-14 h-14 md:mb-7 rounded-full"
/>
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">
{t("reg.title", { name: serverName })}
</h2>
<span className="hidden md:block text-gray-400 dark:text-gray-100">{t("reg.desc")}</span>
</div>
<form className="flex flex-col gap-5 w-80 md:min-w-[360px]" onSubmit={handleReg} autoSave={"false"} autoComplete={"true"}>
<form
className="flex flex-col gap-5 w-80 md:min-w-[360px]"
onSubmit={handleReg}
autoSave={"false"}
autoComplete={"true"}
>
{/* 不存在 magic token */}
{!magic_token && <Input
className="large"
name="name"
value={name}
required
type="name"
placeholder={t("placeholder_name")}
data-type="name"
onChange={handleInput}
/>}
{!magic_token && (
<Input
className="large"
name="name"
value={name}
required
type="name"
placeholder={t("placeholder_name")}
data-type="name"
onChange={handleInput}
/>
)}
<Input
className="large"
name="email"
@@ -178,7 +196,7 @@ export default function Register() {
placeholder={t("placeholder_confirm_pwd")}
></Input>
<Button type="submit" disabled={isLoading}>
{isLoading ? t('signing_up') : t("sign_up")}
{isLoading ? t("signing_up") : t("sign_up")}
</Button>
</form>
<Divider content="OR" />
+11 -6
View File
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useSearchParams } from 'react-router-dom';
import { isMobile } from '../../utils';
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, useSearchParams } from "react-router-dom";
import { isMobile } from "../../utils";
export default function SignInLink({ token }: { token?: string }) {
const { t } = useTranslation("auth");
@@ -10,14 +11,18 @@ export default function SignInLink({ token }: { token?: string }) {
useEffect(() => {
// 移动端访问,则跳转
if (isMobile() && !!token && ctx == "app") {
location.href = `https://join.voce.chat/download?link=${encodeURIComponent(`${location.origin}?magic_token=${token}`)}`;
location.href = `https://join.voce.chat/download?link=${encodeURIComponent(
`${location.origin}?magic_token=${token}`
)}`;
}
}, [token, ctx]);
return (
<div className="flex gap-1 mt-7 text-sm text-slate-500 dark:text-gray-100 justify-center">
<span>{t("reg.have_account")}</span>
<NavLink to={"/login"} className="text-primary-400 cursor-pointer">{t("sign_in")}</NavLink>
<NavLink to={"/login"} className="text-primary-400 cursor-pointer">
{t("sign_in")}
</NavLink>
</div>
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Outlet, useOutletContext, useSearchParams } from "react-router-dom";
type ContextType = { token: string };
export default function RegContainer() {
const [token, setToken] = useState("");
@@ -20,4 +21,4 @@ export default function RegContainer() {
export function useMagicToken() {
return useOutletContext<ContextType>();
}
}
+15 -5
View File
@@ -1,8 +1,9 @@
import { FC } from "react";
import CheckSign from "@/assets/icons/check.sign.svg";
import ChannelIcon from "@/components/ChannelIcon";
import Search from "../Search";
import useFilteredChannels from "@/hooks/useFilteredChannels";
import CheckSign from "@/assets/icons/check.sign.svg";
import Search from "../Search";
type Props = {
select: number;
@@ -20,14 +21,23 @@ const Channel: FC<Props> = ({ select = 0, updateFilter }) => {
<Search embed={true} value={input} updateSearchValue={updateInput} />
</div>
<ul className="w-full flex flex-col gap-4 p-2">
<li className="relative cursor-pointer flex items-center gap-2" onClick={handleClick.bind(null, undefined)}>
<li
className="relative cursor-pointer flex items-center gap-2"
onClick={handleClick.bind(null, undefined)}
>
<ChannelIcon />
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">Any Channel</span>
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">
Any Channel
</span>
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{channels.map(({ gid, is_public, name }) => {
return (
<li key={gid} className="relative cursor-pointer flex items-center gap-2" onClick={handleClick.bind(null, gid)}>
<li
key={gid}
className="relative cursor-pointer flex items-center gap-2"
onClick={handleClick.bind(null, gid)}
>
<ChannelIcon personal={!is_public} />
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">{name}</span>
{select == gid && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
+13 -4
View File
@@ -1,6 +1,6 @@
import { FC } from "react";
import CheckSign from "@/assets/icons/check.sign.svg";
import CheckSign from "@/assets/icons/check.sign.svg";
export const Dates = {
today: {
@@ -32,15 +32,24 @@ const DateFilter: FC<Props> = ({ select = "", updateFilter }) => {
return (
<div className="p-3 bg-white dark:bg-gray-800 min-w-[200px] overflow-auto rounded-lg flex flex-col items-start relative drop-shadow">
<ul className="w-full flex flex-col gap-4">
<li className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, undefined)}>
<li
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, undefined)}
>
Any Time
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{Object.entries(Dates).map(([_key, { title }]) => {
return (
<li key={title} className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, _key)}>
<li
key={title}
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, _key)}
>
{title}
{select == _key && <CheckSign className="absolute right-0 -top-1/2 -translate-y-1/2" />}
{select == _key && (
<CheckSign className="absolute right-0 -top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
+10 -4
View File
@@ -1,8 +1,9 @@
import { FC } from "react";
import Search from "../Search";
import CheckSign from "@/assets/icons/check.sign.svg";
import User from "@/components/User";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import CheckSign from "@/assets/icons/check.sign.svg";
import Search from "../Search";
type Props = {
select: number;
@@ -20,7 +21,10 @@ const From: FC<Props> = ({ select = "", updateFilter }) => {
<Search embed={true} value={input} updateSearchValue={updateInput} />
</div>
<ul className="w-full flex flex-col">
<li className="relative cursor-pointer p-2.5 font-semibold text-sm text-gray-500" onClick={handleClick.bind(null, undefined)}>
<li
className="relative cursor-pointer p-2.5 font-semibold text-sm text-gray-500"
onClick={handleClick.bind(null, undefined)}
>
Anyone
{!select && <CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />}
</li>
@@ -28,7 +32,9 @@ const From: FC<Props> = ({ select = "", updateFilter }) => {
return (
<li key={uid} className="relative cursor-pointer" onClick={handleClick.bind(null, uid)}>
<User uid={uid} interactive={true} />
{select == uid && <CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />}
{select == uid && (
<CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
+21 -11
View File
@@ -1,13 +1,14 @@
import IconPdf from "@/assets/icons/file.pdf.svg";
import IconAudio from "@/assets/icons/file.audio.svg";
import IconVideo from "@/assets/icons/file.video.svg";
import IconUnknown from "@/assets/icons/file.unknown.svg";
import IconDoc from "@/assets/icons/file.doc.svg";
import IconCode from "@/assets/icons/file.code.svg";
import IconImage from "@/assets/icons/file.image.svg";
import CheckSign from "@/assets/icons/check.sign.svg";
import { FC } from "react";
import CheckSign from "@/assets/icons/check.sign.svg";
import IconAudio from "@/assets/icons/file.audio.svg";
import IconCode from "@/assets/icons/file.code.svg";
import IconDoc from "@/assets/icons/file.doc.svg";
import IconImage from "@/assets/icons/file.image.svg";
import IconPdf from "@/assets/icons/file.pdf.svg";
import IconUnknown from "@/assets/icons/file.unknown.svg";
import IconVideo from "@/assets/icons/file.video.svg";
export const FileTypes = {
doc: {
title: "Documents",
@@ -50,15 +51,24 @@ const Type: FC<Props> = ({ select = "", updateFilter }) => {
return (
<div className="p-3 bg-white dark:bg-gray-800 min-w-[180px] overflow-auto shadow-md rounded-lg flex flex-col items-start relative">
<ul className="w-full flex flex-col gap-4">
<li className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, undefined)}>
<li
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, undefined)}
>
Any Type
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{Object.entries(FileTypes).map(([type, { title, icon }]) => {
return (
<li key={title} className="relative cursor-pointer flex items-center gap-2 text-sm text-gray-500 dark:text-gray-300 font-semibold" onClick={handleClick.bind(null, type)}>
<li
key={title}
className="relative cursor-pointer flex items-center gap-2 text-sm text-gray-500 dark:text-gray-300 font-semibold"
onClick={handleClick.bind(null, type)}
>
{icon} {title}
{select == type && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
{select == type && (
<CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
+14 -18
View File
@@ -1,17 +1,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "@/app/store";
import Avatar from "@/components/Avatar";
import ArrowDown from "@/assets/icons/arrow.down.svg";
import FilterChannel from "./Channel";
import FilterDate, { Dates } from "./Date";
import FilterFrom from "./From";
import FilterChannel from "./Channel";
import FilterType, { FileTypes } from "./Type";
import ArrowDown from "@/assets/icons/arrow.down.svg";
import { useAppSelector } from "@/app/store";
const getClass = (selected: boolean) => {
return clsx(`cursor-pointer flex items-center gap-1 md:gap-2 shadow rounded-lg p-1 md:py-2 md:px-3 text-xs text-gray-900 dark:text-gray-200`, selected ? 'text-white bg-primary-400' : 'border border-solid border-gray-300 dark:border-gray-400 ');
return clsx(
`cursor-pointer flex items-center gap-1 md:gap-2 shadow rounded-lg p-1 md:py-2 md:px-3 text-xs text-gray-900 dark:text-gray-200`,
selected
? "text-white bg-primary-400"
: "border border-solid border-gray-300 dark:border-gray-400 "
);
};
export default function Filter({ filter, updateFilter }) {
const { t } = useTranslation("file");
@@ -48,7 +54,6 @@ export default function Filter({ filter, updateFilter }) {
from: fromVisible
} = filtersVisible;
return (
<div className="flex items-center gap-2">
<Tippy
@@ -58,10 +63,7 @@ export default function Filter({ filter, updateFilter }) {
placement="bottom-start"
content={<FilterFrom select={filter.from} updateFilter={handleUpdateFilter} />}
>
<div
className={getClass(from)}
onClick={toggleFilterVisible.bind(null, { from: true })}
>
<div className={getClass(from)} onClick={toggleFilterVisible.bind(null, { from: true })}>
{from && (
<Avatar
width={16}
@@ -99,10 +101,7 @@ export default function Filter({ filter, updateFilter }) {
placement="bottom-start"
content={<FilterType select={filter.type} updateFilter={handleUpdateFilter} />}
>
<div
className={getClass(type)}
onClick={toggleFilterVisible.bind(null, { type: true })}
>
<div className={getClass(type)} onClick={toggleFilterVisible.bind(null, { type: true })}>
<span className="txt">{type ? FileTypes[type].title : t("type")}</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
@@ -114,10 +113,7 @@ export default function Filter({ filter, updateFilter }) {
placement="bottom-start"
content={<FilterDate select={filter.date} updateFilter={handleUpdateFilter} />}
>
<div
className={getClass(date)}
onClick={toggleFilterVisible.bind(null, { date: true })}
>
<div className={getClass(date)} onClick={toggleFilterVisible.bind(null, { date: true })}>
<span className="txt">{date ? Dates[date].title : t("date")}</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
+15 -3
View File
@@ -1,7 +1,9 @@
import clsx from "clsx";
import { ChangeEvent, FC } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import IconSearch from "@/assets/icons/search.svg";
interface Props {
value?: string;
updateSearchValue?: (value: string) => void;
@@ -17,9 +19,19 @@ const Search: FC<Props> = ({ value = "", updateSearchValue = null, embed = false
};
return (
<div className={clsx(`hidden md:block relative w-full py-1.5 px-4 shadow`, embed && "py-2 shadow-none")}>
<div
className={clsx(
`hidden md:block relative w-full py-1.5 px-4 shadow`,
embed && "py-2 shadow-none"
)}
>
<IconSearch className="absolute left-6 top-1/2 -translate-y-1/2" />
<input value={value} onChange={handleChange} className="bg-black/5 dark:bg-black/20 rounded-full text-sm text-gray-500 py-2.5 pl-9" placeholder={`${t("action.search")}...`} />
<input
value={value}
onChange={handleChange}
className="bg-black/5 dark:bg-black/20 rounded-full text-sm text-gray-500 py-2.5 pl-9"
placeholder={`${t("action.search")}...`}
/>
</div>
);
};
+14 -7
View File
@@ -1,14 +1,19 @@
import { MouseEvent } from "react";
import { useDispatch } from "react-redux";
import { updateFileListView } from "@/app/slices/ui";
import IconList from "@/assets/icons/file.list.svg";
import IconGrid from "@/assets/icons/file.grid.svg";
import clsx from "clsx";
const getClass = (selected: boolean) => clsx(`cursor-pointer p-2 box-border flex-center`, selected && `border border-solid border-primary-400 shadow rounded-lg`);
import { updateFileListView } from "@/app/slices/ui";
import IconGrid from "@/assets/icons/file.grid.svg";
import IconList from "@/assets/icons/file.list.svg";
const getClass = (selected: boolean) =>
clsx(
`cursor-pointer p-2 box-border flex-center`,
selected && `border border-solid border-primary-400 shadow rounded-lg`
);
type Props = {
view?: "item" | "grid"
}
view?: "item" | "grid";
};
export default function View({ view = "item" }: Props) {
const dispatch = useDispatch();
const handleChangeView = (evt: MouseEvent<HTMLLIElement>) => {
@@ -18,7 +23,9 @@ export default function View({ view = "item" }: Props) {
};
const isGrid = view == "grid";
return (
<ul className={`hidden md:flex border border-solid dark:border-gray-400 shadow rounded-lg box-border`}>
<ul
className={`hidden md:flex border border-solid dark:border-gray-400 shadow rounded-lg box-border`}
>
<li className={getClass(!isGrid)} data-view={"item"} onClick={handleChangeView}>
<IconList className={`${!isGrid ? "fill-primary-400" : ""} dark:fill-gray-400`} />
</li>
+17 -11
View File
@@ -1,12 +1,14 @@
// @ts-nocheck
import { useState, useEffect, useRef, memo } from "react";
import Masonry from "masonry-layout";
import View from "./View";
import Search from "./Search";
import Filter from "./Filter";
import FileBox from "@/components/FileBox";
import { useAppSelector } from "@/app/store";
import { memo, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import Masonry from "masonry-layout";
import { useAppSelector } from "@/app/store";
import FileBox from "@/components/FileBox";
import Filter from "./Filter";
import Search from "./Search";
import View from "./View";
const checkFilter = (data, filter, channelMessage) => {
let selected = true;
const { mid, file_type, created_at, from_uid, properties } = data;
@@ -89,10 +91,14 @@ function ResourceManagement({ fileMessages }) {
<Filter filter={filter} updateFilter={updateFilter} />
<View view={view} />
</div>
<div className={clsx(`w-full h-full px-4 overflow-scroll flex`,
view == "item" && 'gap-2 flex-col',
view == "grid" && "flex-wrap"
)} ref={listContainerRef}>
<div
className={clsx(
`w-full h-full px-4 overflow-scroll flex`,
view == "item" && "gap-2 flex-col",
view == "grid" && "flex-wrap"
)}
ref={listContainerRef}
>
{fileMessages.map((id) => {
const data = message[id];
if (!data) return null;
+6 -5
View File
@@ -1,7 +1,8 @@
import Button from "@/components/styled/Button";
import { FC, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import Button from "@/components/styled/Button";
interface Props {
email: string;
reset?: (e: MouseEvent<HTMLButtonElement>) => void;
@@ -11,10 +12,10 @@ const SentTip: FC<Props> = ({ email, reset }) => {
const { t } = useTranslation("auth");
return (
<div className="flex flex-col items-center">
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">{t("check_email")}</h2>
<span className="text-center text-gray-500 mb-6">
{t("check_email_desc", { email })}
</span>
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">
{t("check_email")}
</h2>
<span className="text-center text-gray-500 mb-6">{t("check_email_desc", { email })}</span>
<Button onClick={reset} className="main flex">
{t("use_different")}
</Button>
+23 -14
View File
@@ -1,20 +1,21 @@
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import BASE_URL from "@/app/config";
import Input from "@/components/styled/Input";
import Button from "@/components/styled/Button";
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
import SentTip from "./SentTip";
import { useTranslation } from "react-i18next";
import SocialLoginButtons from "../login/SocialLoginButtons";
import Divider from "@/components/Divider";
import SignInLink from "../reg/SignInLink";
import { useNavigate } from "react-router-dom";
import BASE_URL from "@/app/config";
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
import { useAppSelector } from "@/app/store";
import Divider from "@/components/Divider";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import SocialLoginButtons from "../login/SocialLoginButtons";
import SignInLink from "../reg/SignInLink";
import SentTip from "./SentTip";
export default function SendMagicLinkPage() {
const { t } = useTranslation("auth");
const serverName = useAppSelector(store => store.server.name);
const serverName = useAppSelector((store) => store.server.name);
const [sent, setSent] = useState(false);
const [sendMagicLink, { isSuccess, isLoading, error }] = useSendLoginMagicLinkMutation();
const navigateTo = useNavigate();
@@ -73,8 +74,14 @@ export default function SendMagicLinkPage() {
) : (
<>
<div className="flex flex-col items-center">
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 mb-7 rounded-full" />
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">{t("enter")} {serverName} </h2>
<img
src={`${BASE_URL}/resource/organization/logo`}
alt="logo"
className="w-14 h-14 mb-7 rounded-full"
/>
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">
{t("enter")} {serverName}{" "}
</h2>
<span className="text-center text-gray-500 mb-6">{t("placeholder_email")}</span>
</div>
<form onSubmit={handleLogin} className="flex flex-col gap-5 w-[360px]">
@@ -94,7 +101,9 @@ export default function SendMagicLinkPage() {
</Button>
</form>
<Divider content="OR" />
<Button onClick={handlePwdPath} className="w-full" >{t("login.password")}</Button>
<Button onClick={handlePwdPath} className="w-full">
{t("login.password")}
</Button>
<div className="flex flex-col gap-3 py-3">
<SocialLoginButtons />
</div>
+19 -13
View File
@@ -1,18 +1,18 @@
import { useEffect } from "react";
import Tippy from "@tippyjs/react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
import Input from "@/components/styled/Input";
import Toggle from "@/components/styled/Toggle";
import Button from "@/components/styled/Button";
import {
useGetThirdPartySecretQuery,
useUpdateThirdPartySecretMutation
} from "@/app/services/server";
import useConfig from "@/hooks/useConfig";
import { LoginConfig } from "@/types/server";
import { useTranslation } from "react-i18next";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import Toggle from "@/components/styled/Toggle";
import useConfig from "@/hooks/useConfig";
export default function APIConfig() {
const { t } = useTranslation("setting");
@@ -40,7 +40,10 @@ export default function APIConfig() {
checked={thirdParty}
/>
<div className="w-full flex flex-col items-start gap-2">
<label htmlFor="secret" className="text-sm text-gray-500 dark:text-gray-100"> {t("third_app.key")}:</label>
<label htmlFor="secret" className="text-sm text-gray-500 dark:text-gray-100">
{" "}
{t("third_app.key")}:
</label>
<Input disabled={!thirdParty} type="password" id="secret" value={updatedSecret || data} />
</div>
<Tippy
@@ -49,9 +52,7 @@ export default function APIConfig() {
trigger="click"
content={
<div className="p-3 rounded-lg border border-orange-400 border-solid flex flex-col gap-3 w-[250px] bg-white">
<div className="text-orange-500 text-xs">
{t("third_app.update_tip")}
</div>
<div className="text-orange-500 text-xs">{t("third_app.update_tip")}</div>
<div className="flex justify-end gap-3 w-full">
<Button onClick={() => hideAll()} className="cancel mini">
{ct("action.cancel")}
@@ -67,8 +68,13 @@ export default function APIConfig() {
</Tippy>
<div className="text-xs text-orange-400">
{t("third_app.key_tip")}
<a className="text-primary-500 font-bold" href="https://doc.voce.chat/login-with-other-account" target="_blank" rel="noopener noreferrer">
🔗 {t("third_app.how_to")}
<a
className="text-primary-500 font-bold"
href="https://doc.voce.chat/login-with-other-account"
target="_blank"
rel="noopener noreferrer"
>
🔗 {t("third_app.how_to")}
</a>
</div>
</div>
+79 -62
View File
@@ -1,69 +1,86 @@
// import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../app/store';
import IconCopy from '@/assets/icons/copy.svg';
import useCopy from '../../hooks/useCopy';
import { useTranslation } from "react-i18next";
import IconCopy from "@/assets/icons/copy.svg";
import { useAppSelector } from "../../app/store";
import useCopy from "../../hooks/useCopy";
// type Props = {}
const APIUrl = `${location.origin}/api/swagger`;
const APIDocument = () => {
const token = useAppSelector(store => store.authData.token);
const { copy } = useCopy();
const { t } = useTranslation("setting");
const handleCopy = () => {
copy(token);
};
return (
<section className="flex flex-col justify-start items-start gap-4">
<div className="font-semibold dark:text-white">
{t("api_doc.desc")}
const token = useAppSelector((store) => store.authData.token);
const { copy } = useCopy();
const { t } = useTranslation("setting");
const handleCopy = () => {
copy(token);
};
return (
<section className="flex flex-col justify-start items-start gap-4">
<div className="font-semibold dark:text-white">{t("api_doc.desc")}</div>
<div className="flex flex-col gap-2 w-full">
<h2 className="text-gray-700 dark:text-white text-lg font-semibold flex gap-1">
{t("api_doc.access")}
<a
href={APIUrl}
target="_blank"
rel="noopener noreferrer"
className="underline text-primary-600"
>
{APIUrl}
</a>
</h2>
</div>
<div className="flex flex-col gap-2 w-full">
<h2 className="text-gray-700 dark:text-white text-lg font-semibold">
{t("api_doc.use_method")}
</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h3 className="text-gray-700 dark:text-white ">{t("api_doc.step_1")}</h3>
{/* <div className="flex flex-col gap-1"> */}
<img
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[50%]"
src="https://s.voce.chat/web_client/assets/img/api.doc.step1.png"
alt="step 1"
/>
{/* </div> */}
</div>
<div className="flex flex-col gap-2">
<h3 className="text-gray-700 dark:text-white flex flex-col items-start gap-2">
{t("api_doc.step_2")}{" "}
<span className="text-gray-400 dark:text-white text-xs">
({t("api_doc.step_2_desc")})
</span>
</h3>
<div className="flex flex-col border border-solid border-green-500 bg-green-100 rounded-md p-2 w-fit break-words text-sm relative">
<p className="break-all md:max-w-4xl font-bold">
{token}
<IconCopy
onClick={handleCopy}
className="absolute right-2 bottom-2 cursor-pointer"
/>
</p>
</div>
<div className='flex flex-col gap-2 w-full'>
<h2 className='text-gray-700 dark:text-white text-lg font-semibold flex gap-1'>
{t("api_doc.access")}
<a href={APIUrl} target="_blank" rel="noopener noreferrer" className='underline text-primary-600'>
{APIUrl}
</a>
</h2>
</div>
<div className='flex flex-col gap-2 w-full'>
<h2 className='text-gray-700 dark:text-white text-lg font-semibold'>
{t("api_doc.use_method")}
</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h3 className='text-gray-700 dark:text-white '>
{t("api_doc.step_1")}
</h3>
{/* <div className="flex flex-col gap-1"> */}
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[50%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step1.png" alt="step 1" />
{/* </div> */}
</div>
<div className="flex flex-col gap-2">
<h3 className='text-gray-700 dark:text-white flex flex-col items-start gap-2'>
{t("api_doc.step_2")} <span className='text-gray-400 dark:text-white text-xs'>
({t("api_doc.step_2_desc")})
</span>
</h3>
<div className='flex flex-col border border-solid border-green-500 bg-green-100 rounded-md p-2 w-fit break-words text-sm relative'>
<p className="break-all md:max-w-4xl font-bold">
{token}
<IconCopy onClick={handleCopy} className="absolute right-2 bottom-2 cursor-pointer" />
</p>
</div>
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step2.jpg" alt="step 2" />
</div>
<div className="flex flex-col gap-2">
<h3 className='text-gray-700 dark:text-white flex items-center gap-2'>
{t("api_doc.last")}
</h3>
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step3.png" alt="step 3" />
</div>
</div>
</div>
</section>
);
<img
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]"
src="https://s.voce.chat/web_client/assets/img/api.doc.step2.jpg"
alt="step 2"
/>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-gray-700 dark:text-white flex items-center gap-2">
{t("api_doc.last")}
</h3>
<img
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]"
src="https://s.voce.chat/web_client/assets/img/api.doc.step3.png"
alt="step 3"
/>
</div>
</div>
</div>
</section>
);
};
export default APIDocument;
export default APIDocument;
+104 -75
View File
@@ -1,82 +1,111 @@
// import Tippy from '@tippyjs/react';
import dayjs from 'dayjs';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetBotAPIKeysQuery } from '../../../app/services/user';
import IconDelete from '@/assets/icons/delete.svg';
import IconAdd from '@/assets/icons/add.svg';
import CreateAPIKeyModal from './CreateAPIKeyModal';
import DeleteAPIKeyModal from './DeleteAPIKeyModal';
import clsx from 'clsx';
import { useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import dayjs from "dayjs";
import IconAdd from "@/assets/icons/add.svg";
import IconDelete from "@/assets/icons/delete.svg";
import { useGetBotAPIKeysQuery } from "../../../app/services/user";
import CreateAPIKeyModal from "./CreateAPIKeyModal";
import DeleteAPIKeyModal from "./DeleteAPIKeyModal";
type Props = {
uid: number
}
type DeleteAPIKeyProps = { uid: number, kid: number }
uid: number;
};
type DeleteAPIKeyProps = { uid: number; kid: number };
const tdClass = "p-1 whitespace-nowrap text-xs text-gray-500 dark:text-gray-200 align-middle px-1";
const BotAPIKeys = ({ uid }: Props) => {
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const [currentUid, setCurrentUid] = useState<number | undefined>();
const [deleteApiKey, setDeleteApiKey] = useState<DeleteAPIKeyProps | undefined>();
const { data, refetch } = useGetBotAPIKeysQuery(uid);
const toggleCreateModal = (param?: number) => {
if (!param) refetch();
setCurrentUid(param);
};
const toggleDeleteModal = (param?: DeleteAPIKeyProps) => {
if (!param) refetch();
setDeleteApiKey(param);
};
if (!data) return null;
const colWidths = ["w-20", "w-[166px]", "w-36", "w-15", "w-10"];
return (
<div className='flex flex-col gap-2 items-start'>
<div className='border-t border-solid border-b border-gray-100 dark:border-gray-500 py-2 w-full'>
<table className="min-w-full table-fixed font-mono">
<thead >
<tr >
{[t("col_key_name"), t('col_key_value'), t('col_key_create_time'), t('col_key_last_used'), ""].map((title, idx) => <th key={title} scope="col" className={clsx(`text-xs text-gray-900 dark:text-gray-50 px-1 text-left pb-2`, colWidths[idx])}>
{title}
</th>)}
</tr>
</thead>
<tbody>
{data.length > 0 ? data.map(ak => {
const { id, name, key, created_at, last_used } = ak;
return <tr key={id} className="group" >
<td className={tdClass}>
{name}
</td>
<td className={`${tdClass} w-40`}>
{`${key.slice(0, 4)} ... ... ${key.slice(-6)}`}
</td>
<td className={tdClass}>
{dayjs(created_at).format("YYYY-MM-DD HH:mm:ss")}
</td>
<td className={tdClass}>
{last_used ? dayjs(last_used).format("YYYY-MM-DD HH:mm:ss") : "Unused"}
</td>
<td className={`${tdClass} invisible group-hover:visible`}>
<button onClick={toggleDeleteModal.bind(null, { kid: id, uid })}>
<IconDelete />
</button>
</td>
</tr>;
}) : <tr>
<td colSpan={4} className='text-center text-xs text-gray-400 py-2'>
{t("no_api_key")}
</td>
</tr>}
</tbody>
</table>
<button onClick={toggleCreateModal.bind(null, uid)} className="text-green-600 text-xs py-0.5 flex items-center gap-1 m-auto my-2 bg-green-50 rounded-full px-2 ">
<IconAdd className="!w-4 !h-4 fill-green-600" /> {t("add_api_key")}
</button>
</div>
{currentUid && <CreateAPIKeyModal uid={currentUid} closeModal={toggleCreateModal.bind(null, undefined)} />}
{deleteApiKey && <DeleteAPIKeyModal uid={deleteApiKey.uid} kid={deleteApiKey.kid} closeModal={toggleDeleteModal.bind(null, undefined)} />}
</div>
);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const [currentUid, setCurrentUid] = useState<number | undefined>();
const [deleteApiKey, setDeleteApiKey] = useState<DeleteAPIKeyProps | undefined>();
const { data, refetch } = useGetBotAPIKeysQuery(uid);
const toggleCreateModal = (param?: number) => {
if (!param) refetch();
setCurrentUid(param);
};
const toggleDeleteModal = (param?: DeleteAPIKeyProps) => {
if (!param) refetch();
setDeleteApiKey(param);
};
if (!data) return null;
const colWidths = ["w-20", "w-[166px]", "w-36", "w-15", "w-10"];
return (
<div className="flex flex-col gap-2 items-start">
<div className="border-t border-solid border-b border-gray-100 dark:border-gray-500 py-2 w-full">
<table className="min-w-full table-fixed font-mono">
<thead>
<tr>
{[
t("col_key_name"),
t("col_key_value"),
t("col_key_create_time"),
t("col_key_last_used"),
""
].map((title, idx) => (
<th
key={title}
scope="col"
className={clsx(
`text-xs text-gray-900 dark:text-gray-50 px-1 text-left pb-2`,
colWidths[idx]
)}
>
{title}
</th>
))}
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((ak) => {
const { id, name, key, created_at, last_used } = ak;
return (
<tr key={id} className="group">
<td className={tdClass}>{name}</td>
<td className={`${tdClass} w-40`}>
{`${key.slice(0, 4)} ... ... ${key.slice(-6)}`}
</td>
<td className={tdClass}>{dayjs(created_at).format("YYYY-MM-DD HH:mm:ss")}</td>
<td className={tdClass}>
{last_used ? dayjs(last_used).format("YYYY-MM-DD HH:mm:ss") : "Unused"}
</td>
<td className={`${tdClass} invisible group-hover:visible`}>
<button onClick={toggleDeleteModal.bind(null, { kid: id, uid })}>
<IconDelete />
</button>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="text-center text-xs text-gray-400 py-2">
{t("no_api_key")}
</td>
</tr>
)}
</tbody>
</table>
<button
onClick={toggleCreateModal.bind(null, uid)}
className="text-green-600 text-xs py-0.5 flex items-center gap-1 m-auto my-2 bg-green-50 rounded-full px-2 "
>
<IconAdd className="!w-4 !h-4 fill-green-600" /> {t("add_api_key")}
</button>
</div>
{currentUid && (
<CreateAPIKeyModal uid={currentUid} closeModal={toggleCreateModal.bind(null, undefined)} />
)}
{deleteApiKey && (
<DeleteAPIKeyModal
uid={deleteApiKey.uid}
kid={deleteApiKey.kid}
closeModal={toggleDeleteModal.bind(null, undefined)}
/>
)}
</div>
);
};
export default BotAPIKeys;
export default BotAPIKeys;
@@ -1,88 +1,96 @@
import { useRef, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useCreateBotAPIKeyMutation } from '../../../app/services/user';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import Input from '../../../components/styled/Input';
import StyledModal from '../../../components/styled/Modal';
import useCopy from '../../../hooks/useCopy';
import { useEffect, useRef } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useCreateBotAPIKeyMutation } from "../../../app/services/user";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import Input from "../../../components/styled/Input";
import StyledModal from "../../../components/styled/Modal";
import useCopy from "../../../hooks/useCopy";
type Props = {
uid: number,
closeModal: () => void
}
uid: number;
closeModal: () => void;
};
const CreateAPIKeyModal = ({ closeModal, uid }: Props) => {
const { copy } = useCopy();
const [createBotAPIKey, { error, isSuccess, isLoading, data = "" }] = useCreateBotAPIKeyMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleCreateBot = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
}
createBotAPIKey({
uid,
name: formEle.querySelector('input')?.value || ""
});
};
useEffect(() => {
if (error) {
switch (error.status) {
case 406:
toast.error("Invalid Webhook URL!");
break;
case 409:
toast.error("Name Already Exists!");
break;
default:
break;
}
}
}, [error]);
const handleCopy = () => {
copy(data);
toast.success("API Key Copied!");
closeModal();
};
const { copy } = useCopy();
const [createBotAPIKey, { error, isSuccess, isLoading, data = "" }] =
useCreateBotAPIKeyMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleCreateBot = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
}
createBotAPIKey({
uid,
name: formEle.querySelector("input")?.value || ""
});
};
useEffect(() => {
if (error) {
switch (error.status) {
case 406:
toast.error("Invalid Webhook URL!");
break;
case 409:
toast.error("Name Already Exists!");
break;
default:
break;
}
}
}, [error]);
const handleCopy = () => {
copy(data);
toast.success("API Key Copied!");
closeModal();
};
return (
<Modal id="modal-modal">
<StyledModal
title={t("create_key_title")}
description={t("create_key_desc")}
buttons={
isSuccess ? <Button onClick={handleCopy}>
{t("key_copy_and_close")}
</Button> : <>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleCreateBot}>{isLoading ? "..." : ct("action.done")}</Button>
</>
}
>
{isSuccess ? <div className="flex flex-col gap-2 text-sm">
<div className='border-green-600 bg-green-200/50 rounded border border-solid p-2 max-w-md w-full whitespace-pre-wrap break-all' >
{data}
</div> <div className='text-red-400'>
{t("create_key_warning")}
</div>
</div>
: <form ref={formRef} className="w-full flex flex-col gap-2 items-center" action="/">
<div className="flex flex-col gap-1 w-full">
<label htmlFor={"name"} className="text-sm text-gray-500">Name</label>
<Input name={"name"} required placeholder='Please input API Key name'></Input>
</div>
</form>}
</StyledModal>
</Modal>
);
return (
<Modal id="modal-modal">
<StyledModal
title={t("create_key_title")}
description={t("create_key_desc")}
buttons={
isSuccess ? (
<Button onClick={handleCopy}>{t("key_copy_and_close")}</Button>
) : (
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleCreateBot}>{isLoading ? "..." : ct("action.done")}</Button>
</>
)
}
>
{isSuccess ? (
<div className="flex flex-col gap-2 text-sm">
<div className="border-green-600 bg-green-200/50 rounded border border-solid p-2 max-w-md w-full whitespace-pre-wrap break-all">
{data}
</div>{" "}
<div className="text-red-400"> {t("create_key_warning")}</div>
</div>
) : (
<form ref={formRef} className="w-full flex flex-col gap-2 items-center" action="/">
<div className="flex flex-col gap-1 w-full">
<label htmlFor={"name"} className="text-sm text-gray-500">
Name
</label>
<Input name={"name"} required placeholder="Please input API Key name"></Input>
</div>
</form>
)}
</StyledModal>
</Modal>
);
};
export default CreateAPIKeyModal;
export default CreateAPIKeyModal;
+92 -88
View File
@@ -1,97 +1,101 @@
import { useRef, useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useCreateUserMutation } from '../../../app/services/user';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import Input from '../../../components/styled/Input';
import StyledModal from '../../../components/styled/Modal';
import { BASE_ORIGIN } from '../../../app/config';
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { BASE_ORIGIN } from "../../../app/config";
import { useCreateUserMutation } from "../../../app/services/user";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import Input from "../../../components/styled/Input";
import StyledModal from "../../../components/styled/Modal";
type Props = {
closeModal: () => void
}
closeModal: () => void;
};
type CreateDTO = {
name: string,
webhook_url?: string
}
name: string;
webhook_url?: string;
};
const CreateModal = ({ closeModal }: Props) => {
const [createUser, { isSuccess, isLoading, error }] = useCreateUserMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleCreateBot = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
const [createUser, { isSuccess, isLoading, error }] = useCreateUserMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleCreateBot = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
}
const myFormData = new FormData(formEle);
const formDataObj: CreateDTO = { name: "" };
myFormData.forEach((value, key) => {
if (value) {
formDataObj[key] = value;
}
});
const hostname = new URL(BASE_ORIGIN).hostname;
createUser({
is_bot: true,
is_admin: false,
gender: 1,
email: `bot_${new Date().getTime()}@${hostname}`,
password: "",
...formDataObj
});
};
useEffect(() => {
if (error) {
switch (error.status) {
case 406:
toast.error("Invalid Webhook URL!");
break;
}
const myFormData = new FormData(formEle);
const formDataObj: CreateDTO = { name: "" };
myFormData.forEach((value, key) => {
if (value) {
formDataObj[key] = value;
}
});
const hostname = new URL(BASE_ORIGIN).hostname;
createUser({
is_bot: true,
is_admin: false,
gender: 1,
email: `bot_${new Date().getTime()}@${hostname}`,
password: "",
...formDataObj
});
};
useEffect(() => {
if (error) {
switch (error.status) {
case 406:
toast.error("Invalid Webhook URL!");
break;
default:
break;
}
}
}, [error]);
useEffect(() => {
if (isSuccess) {
toast.success("Create Bot Successfully!");
closeModal();
}
}, [isSuccess]);
default:
break;
}
return (
<Modal id="modal-modal">
<StyledModal
title={t("create_title")}
description={t("create_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleCreateBot}>{isLoading ? "Creating" : ct("action.done")}</Button>
</>
}
}, [error]);
useEffect(() => {
if (isSuccess) {
toast.success("Create Bot Successfully!");
closeModal();
}
}, [isSuccess]);
return (
<Modal id="modal-modal">
<StyledModal
title={t("create_title")}
description={t("create_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleCreateBot}>{isLoading ? "Creating" : ct("action.done")}</Button>
</>
}
>
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
<div className="flex flex-col items-start gap-1 w-full">
<label htmlFor={"name"} className="text-sm text-gray-500">Name</label>
<Input name={"name"} required placeholder='Please input bot name'></Input>
</div>
<div className="flex flex-col items-start gap-1 w-full">
<label htmlFor={"webhook_url"} className="text-sm text-gray-500">Webhook URL (Optional)</label>
<Input name={"webhook_url"} type="url" placeholder='Please input webhook url'></Input>
</div>
</form>
</StyledModal>
</Modal>
);
>
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
<div className="flex flex-col items-start gap-1 w-full">
<label htmlFor={"name"} className="text-sm text-gray-500">
Name
</label>
<Input name={"name"} required placeholder="Please input bot name"></Input>
</div>
<div className="flex flex-col items-start gap-1 w-full">
<label htmlFor={"webhook_url"} className="text-sm text-gray-500">
Webhook URL (Optional)
</label>
<Input name={"webhook_url"} type="url" placeholder="Please input webhook url"></Input>
</div>
</form>
</StyledModal>
</Modal>
);
};
export default CreateModal;
export default CreateModal;
@@ -1,48 +1,50 @@
import { useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useLazyDeleteBotAPIKeyQuery } from '../../../app/services/user';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import StyledModal from '../../../components/styled/Modal';
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useLazyDeleteBotAPIKeyQuery } from "../../../app/services/user";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import StyledModal from "../../../components/styled/Modal";
type Props = {
uid: number,
kid: number,
closeModal: () => void
}
uid: number;
kid: number;
closeModal: () => void;
};
const DeleteAPIKeyModal = ({ closeModal, uid, kid }: Props) => {
const [deleteKey, { isSuccess, isLoading }] = useLazyDeleteBotAPIKeyQuery();
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleDeleteBot = () => {
deleteKey({ uid, kid });
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.delete"));
closeModal();
}
}, [isSuccess]);
const [deleteKey, { isSuccess, isLoading }] = useLazyDeleteBotAPIKeyQuery();
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleDeleteBot = () => {
deleteKey({ uid, kid });
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.delete"));
closeModal();
}
}, [isSuccess]);
return (
<Modal id="modal-modal">
<StyledModal
title={`${t("delete_key_title")} ${name}`}
description={t("delete_key_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button className='danger' onClick={handleDeleteBot}>{isLoading ? "Deleting" : ct("action.done")}</Button>
</>
}
>
</StyledModal>
</Modal>
);
return (
<Modal id="modal-modal">
<StyledModal
title={`${t("delete_key_title")} ${name}`}
description={t("delete_key_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button className="danger" onClick={handleDeleteBot}>
{isLoading ? "Deleting" : ct("action.done")}
</Button>
</>
}
></StyledModal>
</Modal>
);
};
export default DeleteAPIKeyModal;
export default DeleteAPIKeyModal;
+44 -42
View File
@@ -1,48 +1,50 @@
import { useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useLazyDeleteUserQuery } from '../../../app/services/user';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import StyledModal from '../../../components/styled/Modal';
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useLazyDeleteUserQuery } from "../../../app/services/user";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import StyledModal from "../../../components/styled/Modal";
type Props = {
uid: number,
name: string,
closeModal: () => void
}
uid: number;
name: string;
closeModal: () => void;
};
const DeleteModal = ({ closeModal, uid, name }: Props) => {
const [deleteUser, { isSuccess, isLoading }] = useLazyDeleteUserQuery();
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleDeleteBot = () => {
deleteUser(uid);
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.delete"));
closeModal();
}
}, [isSuccess]);
const [deleteUser, { isSuccess, isLoading }] = useLazyDeleteUserQuery();
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleDeleteBot = () => {
deleteUser(uid);
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.delete"));
closeModal();
}
}, [isSuccess]);
return (
<Modal id="modal-modal">
<StyledModal
title={`${t("delete_title")} ${name}`}
description={t("delete_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
{ct("action.cancel")}
</Button>
<Button className='danger' onClick={handleDeleteBot}>{isLoading ? "Deleting" : ct("action.done")}</Button>
</>
}
>
</StyledModal>
</Modal>
);
return (
<Modal id="modal-modal">
<StyledModal
title={`${t("delete_title")} ${name}`}
description={t("delete_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
{ct("action.cancel")}
</Button>
<Button className="danger" onClick={handleDeleteBot}>
{isLoading ? "Deleting" : ct("action.done")}
</Button>
</>
}
></StyledModal>
</Modal>
);
};
export default DeleteModal;
export default DeleteModal;
+110 -78
View File
@@ -1,86 +1,118 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from "react";
// import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import Textarea from '../../../components/styled/Textarea';
import StyledModal from '../../../components/styled/Modal';
import { useLazyGetBotRelatedChannelsQuery, useSendMessageByBotMutation } from '../../../app/services/server';
import clsx from 'clsx';
import { MessageTypes } from '../../../app/config';
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { MessageTypes } from "../../../app/config";
import {
useLazyGetBotRelatedChannelsQuery,
useSendMessageByBotMutation
} from "../../../app/services/server";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import StyledModal from "../../../components/styled/Modal";
import Textarea from "../../../components/styled/Textarea";
type Props = {
closeModal: () => void
}
closeModal: () => void;
};
const TestAPIKeyModal = ({ closeModal }: Props) => {
const [currCid, setCurrCid] = useState<number | null>(null);
const [msgType, setMsgType] = useState("text");
const [getChannels, { data }] = useLazyGetBotRelatedChannelsQuery();
const [sendMessage] = useSendMessageByBotMutation();
const inputRef = useRef<HTMLTextAreaElement | undefined>();
const msgInputRef = useRef<HTMLTextAreaElement | undefined>();
const [key, setKey] = useState("");
// const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
const handleSetKey = () => {
const input = inputRef?.current;
if (input && input.value) {
setKey(input.value);
}
};
const handleSetChannel = (cid: number) => {
setCurrCid(cid);
};
const handleSetMsgType = (type: string) => {
setMsgType(type);
};
const handleSend = () => {
const input = msgInputRef?.current;
if (input && input.value && currCid) {
sendMessage({ cid: currCid, api_key: key, type: msgType, content: input.value });
}
};
useEffect(() => {
if (key) {
getChannels({ api_key: key });
}
}, [key]);
const [currCid, setCurrCid] = useState<number | null>(null);
const [msgType, setMsgType] = useState("text");
const [getChannels, { data }] = useLazyGetBotRelatedChannelsQuery();
const [sendMessage] = useSendMessageByBotMutation();
const inputRef = useRef<HTMLTextAreaElement | undefined>();
const msgInputRef = useRef<HTMLTextAreaElement | undefined>();
const [key, setKey] = useState("");
// const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
const handleSetKey = () => {
const input = inputRef?.current;
if (input && input.value) {
setKey(input.value);
}
};
const handleSetChannel = (cid: number) => {
setCurrCid(cid);
};
const handleSetMsgType = (type: string) => {
setMsgType(type);
};
const handleSend = () => {
const input = msgInputRef?.current;
if (input && input.value && currCid) {
sendMessage({ cid: currCid, api_key: key, type: msgType, content: input.value });
}
};
useEffect(() => {
if (key) {
getChannels({ api_key: key });
}
}, [key]);
return (
<Modal id="modal-modal">
<StyledModal
title={key ? "" : `Input API Key`}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleSetKey}>{ct("action.done")}</Button>
</>
}
>
{key ? (
data ? (
<ul className="divide-y-2">
{data.map(({ gid, name, is_public }) => {
return (
<li
key={gid}
className={clsx(
"py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50",
gid == currCid ? "bg-slate-100" : ""
)}
onClick={handleSetChannel.bind(null, gid)}
>
# {name} {!is_public ? "🔒" : ""}
</li>
);
})}
</ul>
) : null
) : (
<Textarea rows={6} ref={inputRef} placeholder="Input API Key First" />
)}
return (
<Modal id="modal-modal">
<StyledModal
title={key ? "" : `Input API Key`}
buttons={
<>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleSetKey} >{ct("action.done")}</Button>
</>
}
>
{key ? (data ? <ul className="divide-y-2">
{data.map(({ gid, name, is_public }) => {
return <li key={gid} className={clsx("py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50", gid == currCid ? 'bg-slate-100' : "")} onClick={handleSetChannel.bind(null, gid)}>
# {name} {!is_public ? "🔒" : ""}
</li>;
})}
</ul> : null)
: <Textarea rows={6} ref={inputRef} placeholder='Input API Key First' />}
{currCid ? <div className='mt-4 flex flex-col items-start gap-2'>
<Textarea ref={msgInputRef} placeholder='Input Something...' />
<ul className='flex gap-1'>
{Object.entries(MessageTypes).map(([key, value]) => {
return <li onClick={handleSetMsgType.bind(null, key)} className={clsx("py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50", msgType == key ? 'bg-slate-100' : "")} key={key}>{value}</li>;
})}
</ul>
<Button className='mini' onClick={handleSend} >Send</Button>
</div> : null}
</StyledModal>
</Modal>
);
{currCid ? (
<div className="mt-4 flex flex-col items-start gap-2">
<Textarea ref={msgInputRef} placeholder="Input Something..." />
<ul className="flex gap-1">
{Object.entries(MessageTypes).map(([key, value]) => {
return (
<li
onClick={handleSetMsgType.bind(null, key)}
className={clsx(
"py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50",
msgType == key ? "bg-slate-100" : ""
)}
key={key}
>
{value}
</li>
);
})}
</ul>
<Button className="mini" onClick={handleSend}>
Send
</Button>
</div>
) : null}
</StyledModal>
</Modal>
);
};
export default TestAPIKeyModal;
export default TestAPIKeyModal;
+118 -91
View File
@@ -1,95 +1,122 @@
import clsx from 'clsx';
import { useState, useEffect, useRef } from 'react';
import { toast } from 'react-hot-toast';
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Orbit } from "@uiball/loaders";
import { useGetUserByAdminQuery, useUpdateUserMutation } from '../../../app/services/user';
import IconEdit from '@/assets/icons/edit.svg';
import IconSave from '@/assets/icons/save.svg';
import IconCancel from '@/assets/icons/close.circle.svg';
import clsx from "clsx";
import IconCancel from "@/assets/icons/close.circle.svg";
import IconEdit from "@/assets/icons/edit.svg";
import IconSave from "@/assets/icons/save.svg";
import { useGetUserByAdminQuery, useUpdateUserMutation } from "../../../app/services/user";
type Props = {
uid: number
}
const WebhookEdit = ({ uid }: Props) => {
const formRef = useRef<HTMLFormElement>(null);
const [editable, setEditable] = useState(false);
const [url, setUrl] = useState("");
const { data, isSuccess, refetch } = useGetUserByAdminQuery(uid);
const [updateUser, { isSuccess: updateSuccess, isLoading: isUpdating }] = useUpdateUserMutation();
useEffect(() => {
if (isSuccess && data) {
setUrl(data.webhook_url || "");
}
}, [data, isSuccess]);
useEffect(() => {
if (updateSuccess) {
refetch();
}
}, [updateSuccess]);
const handleEdit = async () => {
if (editable && formRef) {
const form = formRef.current;
// 检查格式
if (!form?.checkValidity()) {
form?.reportValidity();
return;
}
// 保存编辑
const webhook_url = new FormData(form).get("webhook") as string;
const resp = await updateUser({ id: uid, webhook_url });
// console.log("ressssss", resp);
if ("error" in resp) {
switch (resp.error.status) {
case 406:
toast.error("Not Valid URL!");
break;
default:
break;
}
return;
}
}
setEditable(prev => !prev);
};
const handleEditable = () => {
setEditable(true);
};
const handleCancelEdit = () => {
setEditable(false);
const form = formRef.current;
if (form) {
const input = form.querySelector("input");
input!.value = data?.webhook_url || "";
}
};
return (
<div>
{(url || editable || updateSuccess) ?
<div className="flex gap-2">
<form action="/" ref={formRef} onSubmit={(evt) => {
evt.preventDefault();
handleEdit();
}}>
<input readOnly={!editable} required autoFocus type="url" name='webhook' defaultValue={url} className={clsx("text-sm text-gray-400 dark:text-gray-100 dark:bg-slate-900 px-2 py-1", editable ? "ring-1 ring-gray-500 bg-gray-50" : "bg-transparent")} />
</form>
<button type='button' disabled={isUpdating} onClick={handleEdit}>
{isUpdating ? <Orbit size={16} /> : editable ?
<IconSave className="stroke-gray-500 !w-5 !h-5" />
: <IconEdit className="fill-gray-500 !w-5 !h-5" />}
</button>
{editable && !isUpdating && <button type='button' disabled={isUpdating} onClick={handleCancelEdit}>
<IconCancel className="!w-5 !h-5 fill-gray-500" />
</button>}
</div>
:
<button type='button' className="rounded-full bg-primary-50 text-green-600 text-xs py-0.5 px-2" onClick={handleEditable}>
Set Webhook
</button>}
</div>
);
uid: number;
};
export default WebhookEdit;
const WebhookEdit = ({ uid }: Props) => {
const formRef = useRef<HTMLFormElement>(null);
const [editable, setEditable] = useState(false);
const [url, setUrl] = useState("");
const { data, isSuccess, refetch } = useGetUserByAdminQuery(uid);
const [updateUser, { isSuccess: updateSuccess, isLoading: isUpdating }] = useUpdateUserMutation();
useEffect(() => {
if (isSuccess && data) {
setUrl(data.webhook_url || "");
}
}, [data, isSuccess]);
useEffect(() => {
if (updateSuccess) {
refetch();
}
}, [updateSuccess]);
const handleEdit = async () => {
if (editable && formRef) {
const form = formRef.current;
// 检查格式
if (!form?.checkValidity()) {
form?.reportValidity();
return;
}
// 保存编辑
const webhook_url = new FormData(form).get("webhook") as string;
const resp = await updateUser({ id: uid, webhook_url });
// console.log("ressssss", resp);
if ("error" in resp) {
switch (resp.error.status) {
case 406:
toast.error("Not Valid URL!");
break;
default:
break;
}
return;
}
}
setEditable((prev) => !prev);
};
const handleEditable = () => {
setEditable(true);
};
const handleCancelEdit = () => {
setEditable(false);
const form = formRef.current;
if (form) {
const input = form.querySelector("input");
input!.value = data?.webhook_url || "";
}
};
return (
<div>
{url || editable || updateSuccess ? (
<div className="flex gap-2">
<form
action="/"
ref={formRef}
onSubmit={(evt) => {
evt.preventDefault();
handleEdit();
}}
>
<input
readOnly={!editable}
required
autoFocus
type="url"
name="webhook"
defaultValue={url}
className={clsx(
"text-sm text-gray-400 dark:text-gray-100 dark:bg-slate-900 px-2 py-1",
editable ? "ring-1 ring-gray-500 bg-gray-50" : "bg-transparent"
)}
/>
</form>
<button type="button" disabled={isUpdating} onClick={handleEdit}>
{isUpdating ? (
<Orbit size={16} />
) : editable ? (
<IconSave className="stroke-gray-500 !w-5 !h-5" />
) : (
<IconEdit className="fill-gray-500 !w-5 !h-5" />
)}
</button>
{editable && !isUpdating && (
<button type="button" disabled={isUpdating} onClick={handleCancelEdit}>
<IconCancel className="!w-5 !h-5 fill-gray-500" />
</button>
)}
</div>
) : (
<button
type="button"
className="rounded-full bg-primary-50 text-green-600 text-xs py-0.5 px-2"
onClick={handleEditable}
>
Set Webhook
</button>
)}
</div>
);
};
export default WebhookEdit;
+68 -63
View File
@@ -1,69 +1,74 @@
import { useRef, useState, useEffect, ChangeEvent } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useUpdateUserMutation } from '../../../app/services/user';
import Modal from '../../../components/Modal';
import Button from '../../../components/styled/Button';
import Input from '../../../components/styled/Input';
import StyledModal from '../../../components/styled/Modal';
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateUserMutation } from "../../../app/services/user";
import Modal from "../../../components/Modal";
import Button from "../../../components/styled/Button";
import Input from "../../../components/styled/Input";
import StyledModal from "../../../components/styled/Modal";
type Props = {
uid: number,
webhook?: string,
closeModal: () => void
}
uid: number;
webhook?: string;
closeModal: () => void;
};
const WebhookModal = ({ uid, webhook, closeModal }: Props) => {
const [url, setUrl] = useState(webhook);
const [updateUser, { isSuccess, isLoading }] = useUpdateUserMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleUrlChange = (evt: ChangeEvent<HTMLInputElement>) => {
setUrl(evt.target.value);
};
const handleUpdateWebhook = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
}
const myFormData = new FormData(formEle);
const webhook_url = myFormData.get("webhook")?.toString() || "";
updateUser({
id: uid,
webhook_url
});
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
closeModal();
}
}, [isSuccess]);
const [url, setUrl] = useState(webhook);
const [updateUser, { isSuccess, isLoading }] = useUpdateUserMutation();
const formRef = useRef(null);
const { t } = useTranslation("setting", { keyPrefix: "bot" });
const { t: ct } = useTranslation();
// const [input, setInput] = useState("");
const handleUrlChange = (evt: ChangeEvent<HTMLInputElement>) => {
setUrl(evt.target.value);
};
const handleUpdateWebhook = () => {
if (!formRef || !formRef.current) return;
const formEle = formRef.current as HTMLFormElement;
if (!formEle.checkValidity()) {
formEle.reportValidity();
return;
}
const myFormData = new FormData(formEle);
const webhook_url = myFormData.get("webhook")?.toString() || "";
updateUser({
id: uid,
webhook_url
});
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
closeModal();
}
}, [isSuccess]);
return (
<Modal id="modal-modal">
<StyledModal
title={t("webhook_title")}
description={t("webhook_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
{ct("action.cancel")}
</Button>
<Button disabled={!url} onClick={handleUpdateWebhook}>{isLoading ? "Updating" : ct("action.done")}</Button>
</>
}
>
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
<label htmlFor={"webhook"} className="text-sm text-gray-500">Webhook URL</label>
<Input name={"webhook"} value={url} onChange={handleUrlChange} type="url"></Input>
</form>
</StyledModal>
</Modal>
);
return (
<Modal id="modal-modal">
<StyledModal
title={t("webhook_title")}
description={t("webhook_desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
{ct("action.cancel")}
</Button>
<Button disabled={!url} onClick={handleUpdateWebhook}>
{isLoading ? "Updating" : ct("action.done")}
</Button>
</>
}
>
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
<label htmlFor={"webhook"} className="text-sm text-gray-500">
Webhook URL
</label>
<Input name={"webhook"} value={url} onChange={handleUrlChange} type="url"></Input>
</form>
</StyledModal>
</Modal>
);
};
export default WebhookModal;
export default WebhookModal;
+4 -5
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateAvatarByAdminMutation } from "@/app/services/user";
@@ -7,12 +7,11 @@ import { useAppSelector } from "@/app/store";
import AvatarUploader from "@/components/AvatarUploader";
import Button from "@/components/styled/Button";
import IconDelete from "@/assets/icons/delete.svg";
import BotAPIKeys from "./BotAPIKeys";
import CreateModal from "./CreateModal";
import DeleteModal from "./DeleteModal";
import WebhookEdit from "./WebhookEdit";
import WebhookModal from "./WebhookModal";
import DeleteModal from "./DeleteModal";
import BotAPIKeys from "./BotAPIKeys";
import { toast } from "react-hot-toast";
type TipProps = { title: string; desc: string };
const Tip = ({ title, desc }: TipProps) => {
@@ -1,18 +1,19 @@
import { ChangeEvent, FC, useState } from "react";
import toast from "react-hot-toast";
import * as linkify from 'linkifyjs';
import Modal from "@/components/Modal";
import StyledModal from "@/components/styled/Modal";
import Button from "@/components/styled/Button";
import StyledRadio from "@/components/styled/Radio";
import Input from "@/components/styled/Input";
import { useGetLicensePaymentUrlMutation } from "@/app/services/server";
import { getLicensePriceList } from "@/app/config";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import { PriceSubscriptionDuration, PriceType } from "@/types/common";
import Tippy from "@tippyjs/react";
import dayjs from "dayjs";
import * as linkify from "linkifyjs";
import { getLicensePriceList } from "@/app/config";
import { useGetLicensePaymentUrlMutation } from "@/app/services/server";
import { PriceSubscriptionDuration, PriceType } from "@/types/common";
import Modal from "@/components/Modal";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import StyledModal from "@/components/styled/Modal";
import StyledRadio from "@/components/styled/Radio";
// import { LicenseMetadata, RenewLicense } from "@/types/server";
interface Props {
@@ -36,7 +37,7 @@ const getExpireDay = (sub_dur: PriceSubscriptionDuration) => {
break;
default:
break;
};
}
return res.format("YYYY-MM-DD");
};
const LicensePriceList = getLicensePriceList();
@@ -47,14 +48,21 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
const [host, setHost] = useState(location.hostname);
const [popUpVisible, setPopUpVisible] = useState(false);
const [selectPrice, setSelectPrice] = useState(
`${LicensePriceList[0].pid}|${LicensePriceList[0].limit}|${LicensePriceList[0].type}|${LicensePriceList[0].sub_dur || ""}`
`${LicensePriceList[0].pid}|${LicensePriceList[0].limit}|${LicensePriceList[0].type}|${
LicensePriceList[0].sub_dur || ""
}`
);
const handleRenew = async () => {
if (!linkify.test(host)) {
toast.error("Invalid Host");
return;
}
const [priceId, user_limit, type, sub_dur = "month"] = selectPrice.split("|") as [string, string, PriceType, PriceSubscriptionDuration];
const [priceId, user_limit, type, sub_dur = "month"] = selectPrice.split("|") as [
string,
string,
PriceType,
PriceSubscriptionDuration
];
const metadata = {
user_limit: Number(user_limit),
expire: type == "subscription" ? getExpireDay(sub_dur) : getExpireDay("year"),
@@ -87,7 +95,7 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
setHost(evt.target.value);
};
const togglePopUpVisible = () => {
setPopUpVisible(prev => !prev);
setPopUpVisible((prev) => !prev);
};
const handleTalk = () => {
window.open("https://calendly.com/hansu", "_blank");
@@ -105,46 +113,65 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
<Button onClick={closeModal} className="ghost">
{ct("action.cancel")}
</Button>
{isBooking ? <Button onClick={handleTalk}>
Booking a meeting!
</Button> : <Tippy
visible={popUpVisible}
interactive
placement="top-end"
offset={[0, -40]}
trigger="click"
content={
<div className="p-3 rounded-lg border border-solid border-gray-200 dark:border-gray-900 flex flex-col items-start gap-3 w-[380px] bg-white dark:bg-gray-800 shadow shadow-gray-200 dark:shadow-gray-900 drop-shadow-xl">
<div className="text-gray-500 text-sm">
{t("license.tip_domain")}
</div>
<Input value={host} onChange={handleUpdateHost} />
<div className="flex justify-between items-center w-full mt-4">
<span className="text-xs text-orange-500"> {t("license.tip_port")}</span>
<div className="flex gap-3">
<Button className="mini cancel" onClick={togglePopUpVisible}>
{ct("action.cancel")}
</Button>
<Button className="mini" disabled={isLoading || isSuccess} onClick={handleRenew}>
{isLoading ? "Initialize Payment URL" : isSuccess ? "Redirecting" : t("license.tip_confirm")}
</Button>
{isBooking ? (
<Button onClick={handleTalk}>Booking a meeting!</Button>
) : (
<Tippy
visible={popUpVisible}
interactive
placement="top-end"
offset={[0, -40]}
trigger="click"
content={
<div className="p-3 rounded-lg border border-solid border-gray-200 dark:border-gray-900 flex flex-col items-start gap-3 w-[380px] bg-white dark:bg-gray-800 shadow shadow-gray-200 dark:shadow-gray-900 drop-shadow-xl">
<div className="text-gray-500 text-sm">{t("license.tip_domain")}</div>
<Input value={host} onChange={handleUpdateHost} />
<div className="flex justify-between items-center w-full mt-4">
<span className="text-xs text-orange-500"> {t("license.tip_port")}</span>
<div className="flex gap-3">
<Button className="mini cancel" onClick={togglePopUpVisible}>
{ct("action.cancel")}
</Button>
<Button
className="mini"
disabled={isLoading || isSuccess}
onClick={handleRenew}
>
{isLoading
? "Initialize Payment URL"
: isSuccess
? "Redirecting"
: t("license.tip_confirm")}
</Button>
</div>
</div>
</div>
</div>
}
>
<button onClick={togglePopUpVisible} className="text-sm text-white bg-primary-400 break-keep shadow rounded-lg px-3.5 py-2.5 md:hover:bg-primary-500 active:bg-primary-500 disabled:bg-gray-300"> {t("license.renew")}</button>
{/* <Button >
}
>
<button
onClick={togglePopUpVisible}
className="text-sm text-white bg-primary-400 break-keep shadow rounded-lg px-3.5 py-2.5 md:hover:bg-primary-500 active:bg-primary-500 disabled:bg-gray-300"
>
{" "}
{t("license.renew")}
</button>
{/* <Button >
{t("license.renew")}
</Button> */}
</Tippy>}
</Tippy>
)}
</>
}
>
<StyledRadio
options={LicensePriceList.map(({ title, desc, price }) => `${title} ${desc ? `[${desc}]` : ""}${price ? `[${price}]` : ""}`)}
values={LicensePriceList.map(({ pid, limit, type = "payment", sub_dur = "month" }) => `${pid}|${limit}|${type}|${sub_dur}`)}
options={LicensePriceList.map(
({ title, desc, price }) =>
`${title} ${desc ? `[${desc}]` : ""}${price ? `[${price}]` : ""}`
)}
values={LicensePriceList.map(
({ pid, limit, type = "payment", sub_dur = "month" }) =>
`${pid}|${limit}|${type}|${sub_dur}`
)}
value={selectPrice}
onChange={(v) => {
handlePriceSelect(v);
@@ -1,16 +1,17 @@
import { ChangeEvent, FC, useState, useEffect } from "react";
import { ChangeEvent, FC, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import Modal from "@/components/Modal";
import StyledModal from "@/components/styled/Modal";
import Button from "@/components/styled/Button";
import StyledModal from "@/components/styled/Modal";
import Textarea from "@/components/styled/Textarea";
import { useTranslation } from "react-i18next";
interface Props {
closeModal: () => void;
updateLicense: (param: string) => Promise<any>;
updated: boolean;
updating: boolean
updating: boolean;
// domain: string;
}
@@ -43,13 +44,18 @@ const UpdateLicenseModal: FC<Props> = ({ closeModal, updateLicense, updating, up
<Button onClick={closeModal} className="ghost">
{ct("action.cancel")}
</Button>
<Button disabled={updating || updated || !value} onClick={handleRenew} >
<Button disabled={updating || updated || !value} onClick={handleRenew}>
{updating ? "Updating" : updated ? "Update Successfully" : t("license.update")}
</Button>
</>
}
>
<Textarea rows={18} placeholder={t("license.update_placeholder")} value={value} onChange={handleLicenseUpdate} />
<Textarea
rows={18}
placeholder={t("license.update_placeholder")}
value={value}
onChange={handleLicenseUpdate}
/>
</StyledModal>
</Modal>
);
+74 -34
View File
@@ -1,28 +1,40 @@
import { useState, HTMLAttributes } from "react";
import { HTMLAttributes, useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import dayjs from "dayjs";
import Button from "@/components/styled/Button";
import useLicense from "@/hooks/useLicense";
import LicensePriceListModal from "./LicensePriceListModal";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import UpdateLicenseModal from "./UpdateLicenseModal";
// import Tippy from "@tippyjs/react";
interface ItemProps extends HTMLAttributes<HTMLSpanElement> { label: string, data?: string | number | string[], foldable?: boolean }
interface ItemProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
data?: string | number | string[];
foldable?: boolean;
}
const Item = ({ label, data, foldable = false, ...rest }: ItemProps) => {
const infoClass = clsx("font-bold w-full cursor-pointer dark:text-green-500", foldable ? "truncate" : "whitespace-pre-wrap break-all");
const infoClass = clsx(
"font-bold w-full cursor-pointer dark:text-green-500",
foldable ? "truncate" : "whitespace-pre-wrap break-all"
);
if (!data) return null;
return <div className="whitespace-nowrap flex flex-col items-start justify-start text-lg">
<span className="text-sm text-green-500">{label}</span>
{Array.isArray(data) ? <ul className={infoClass}>
{data.map((d) => {
return <li key={d}>{d}</li>;
})}
</ul> : <span className={infoClass} {...rest}>
{data}
</span>}
</div>;
return (
<div className="whitespace-nowrap flex flex-col items-start justify-start text-lg">
<span className="text-sm text-green-500">{label}</span>
{Array.isArray(data) ? (
<ul className={infoClass}>
{data.map((d) => {
return <li key={d}>{d}</li>;
})}
</ul>
) : (
<span className={infoClass} {...rest}>
{data}
</span>
)}
</div>
);
};
export default function License() {
const { t, i18n } = useTranslation("setting");
@@ -38,43 +50,71 @@ export default function License() {
setUpdateVisible((prev) => !prev);
};
const handleLicenseValueToggle = () => {
setBase58Fold(pre => !pre);
setBase58Fold((pre) => !pre);
};
return (
<>
<div className="max-w-3xl flex flex-col justify-start items-start gap-4">
<div className={clsx('relative w-full p-3 rounded border-solid border flex flex-col gap-4 shadow', reachLimit ? "border-red-600 bg-red-200/50" : "border-green-600 bg-green-100 dark:bg-green-900")}>
<div
className={clsx(
"relative w-full p-3 rounded border-solid border flex flex-col gap-4 shadow",
reachLimit
? "border-red-600 bg-red-200/50"
: "border-green-600 bg-green-100 dark:bg-green-900"
)}
>
<Item label={t("license.signed")} data={licenseInfo?.sign ? "Yes" : "Not Yet"} />
<Item label={t("license.domain")} data={licenseInfo?.domains} />
<Item label={t("license.user_limit")} data={(licenseInfo?.user_limit ?? 0) >= 999999 ? "No Limit" : licenseInfo?.user_limit} />
<Item label={t("license.expire")} data={dayjs(licenseInfo?.expired_at).format("YYYY-MM-DD h:mm:ss A")} />
<Item label={t("license.create")} data={dayjs(licenseInfo?.created_at).format("YYYY-MM-DD h:mm:ss A")} />
<Item label={t("license.value")} data={licenseInfo?.base58} foldable={base58Fold} title={base58Fold ? "Click to see full text" : "Click to fold text"}
onClick={handleLicenseValueToggle} />
<Item
label={t("license.user_limit")}
data={(licenseInfo?.user_limit ?? 0) >= 999999 ? "No Limit" : licenseInfo?.user_limit}
/>
<Item
label={t("license.expire")}
data={dayjs(licenseInfo?.expired_at).format("YYYY-MM-DD h:mm:ss A")}
/>
<Item
label={t("license.create")}
data={dayjs(licenseInfo?.created_at).format("YYYY-MM-DD h:mm:ss A")}
/>
<Item
label={t("license.value")}
data={licenseInfo?.base58}
foldable={base58Fold}
title={base58Fold ? "Click to see full text" : "Click to fold text"}
onClick={handleLicenseValueToggle}
/>
</div>
<div className="flex gap-2">
<Button onClick={toggleModalVisible}>{t("license.renew")}</Button>
<Button onClick={toggleUpdateModalVisible} className="ghost">{t("license.update")}</Button>
<Button onClick={toggleUpdateModalVisible} className="ghost">
{t("license.update")}
</Button>
</div>
<div className="flex flex-col gap-4 bg-primary-500 text-white rounded drop-shadow-xl p-5">
<h2 className="text-2xl font-bold">{t("license.tip.title")} 🎁</h2>
<p className="flex flex-col">
<span>
{t("license.tip.user_test")}
</span>
<span>{t("license.tip.user_test")}</span>
<span>
{t("license.tip.contact")}
{i18n.language.startsWith("zh") ? "Privoce" : <a className="underline text-lg text-green-200" href="https://calendly.com/hansu/han-meeting" target="_blank" rel="noopener noreferrer">https://calendly.com/hansu/han-meeting</a>}
{i18n.language.startsWith("zh") ? (
"Privoce"
) : (
<a
className="underline text-lg text-green-200"
href="https://calendly.com/hansu/han-meeting"
target="_blank"
rel="noopener noreferrer"
>
https://calendly.com/hansu/han-meeting
</a>
)}
</span>
</p>
</div>
</div>
{modalVisible && (
<LicensePriceListModal
closeModal={toggleModalVisible}
/>
)}
{modalVisible && <LicensePriceListModal closeModal={toggleModalVisible} />}
{updateVisible && (
<UpdateLicenseModal
updated={upserted}
+14 -7
View File
@@ -1,12 +1,12 @@
import { ChangeEvent, FC, useEffect, useState } from "react";
import toast from "react-hot-toast";
import Modal from "@/components/Modal";
import StyledModal from "@/components/styled/Modal";
import Button from "@/components/styled/Button";
import Checkbox from "@/components/styled/Checkbox";
import useLogout from "@/hooks/useLogout";
import { useTranslation } from "react-i18next";
import Modal from "@/components/Modal";
import Button from "@/components/styled/Button";
import Checkbox from "@/components/styled/Checkbox";
import StyledModal from "@/components/styled/Modal";
import useLogout from "@/hooks/useLogout";
interface Props {
closeModal: () => void;
@@ -41,7 +41,9 @@ const LogoutConfirmModal: FC<Props> = ({ closeModal }) => {
description={t("logout.desc")}
buttons={
<>
<Button className="cancel" onClick={closeModal}>{ct("action.cancel")}</Button>
<Button className="cancel" onClick={closeModal}>
{ct("action.cancel")}
</Button>
<Button onClick={handleLogout} className="danger">
{exiting ? "Logging out" : ct("action.logout")}
</Button>
@@ -52,7 +54,12 @@ const LogoutConfirmModal: FC<Props> = ({ closeModal }) => {
<label htmlFor="clear_cb" className="cursor-pointer text-orange-500 mr-3">
{t("logout.clear_local")}
</label>
<Checkbox className=" cursor-pointer" name="clear_cb" checked={clearLocal} onChange={handleCheck} />
<Checkbox
className=" cursor-pointer"
name="clear_cb"
checked={clearLocal}
onChange={handleCheck}
/>
</div>
</StyledModal>
</Modal>
+5 -6
View File
@@ -1,13 +1,14 @@
import { useEffect, useState, MouseEvent } from "react";
import { MouseEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateAvatarMutation } from "@/app/services/user";
import { useAppSelector } from "@/app/store";
import AvatarUploader from "@/components/AvatarUploader";
import Button from "@/components/styled/Button";
import ProfileBasicEditModal from "./ProfileBasicEditModal";
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
import UpdatePasswordModal from "./UpdatePasswordModal";
import { useAppSelector } from "@/app/store";
import { useTranslation } from "react-i18next";
type EditField = "name" | "email" | "";
export default function MyAccount() {
@@ -91,9 +92,7 @@ export default function MyAccount() {
<span className="text-xs uppercase font-semibold">{t("password")}</span>
<span className="text-sm">*********</span>
</div>
<Button onClick={togglePasswordModal}>
{ct("action.edit")}
</Button>
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
</div>
</div>
{/* uid 1 是初始账户,不能删 */}
+38 -33
View File
@@ -1,40 +1,45 @@
// import React from 'react'
import { useTranslation } from 'react-i18next';
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
import { useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import StyledRadio from "@/components/styled/Radio";
import { useAppSelector } from '../../../app/store';
import SettingBlock from './SettingBlock';
import { ChatLayout } from '../../../types/server';
import {
useGetSystemCommonQuery,
useUpdateSystemCommonMutation
} from "../../../app/services/server";
import { useAppSelector } from "../../../app/store";
import { ChatLayout } from "../../../types/server";
import SettingBlock from "./SettingBlock";
// type Props = {}
const Index = () => {
const currStatus = useAppSelector(store => store.server.chat_layout_mode ?? "Left");
const { t } = useTranslation("setting", { keyPrefix: "overview.chat_layout" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleChange = (newVal: ChatLayout) => {
updateSetting({ chat_layout_mode: newVal });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t('desc')} >
<StyledRadio
options={[t("left"), t("self_right")]}
values={["Left", "SelfRight"]}
value={currStatus}
onChange={handleChange}
/>
</SettingBlock>
);
const currStatus = useAppSelector((store) => store.server.chat_layout_mode ?? "Left");
const { t } = useTranslation("setting", { keyPrefix: "overview.chat_layout" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleChange = (newVal: ChatLayout) => {
updateSetting({ chat_layout_mode: newVal });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t("desc")}>
<StyledRadio
options={[t("left"), t("self_right")]}
values={["Left", "SelfRight"]}
value={currStatus}
onChange={handleChange}
/>
</SettingBlock>
);
};
export default Index;
export default Index;
@@ -1,39 +1,44 @@
// import React from 'react'
import { useTranslation } from 'react-i18next';
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
import { useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import StyledRadio from "@/components/styled/Radio";
import { useAppSelector } from '../../../app/store';
import SettingBlock from './SettingBlock';
import {
useGetSystemCommonQuery,
useUpdateSystemCommonMutation
} from "../../../app/services/server";
import { useAppSelector } from "../../../app/store";
import SettingBlock from "./SettingBlock";
// type Props = {}
const Index = () => {
const currStatus = useAppSelector(store => !!store.server.contact_verification_enable);
const { t } = useTranslation("setting", { keyPrefix: "overview.contact_verify" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleToggle = () => {
updateSetting({ contact_verification_enable: !currStatus });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t('desc')} >
<StyledRadio
options={[t("enable"), t("disable")]}
values={["true", "false"]}
value={`${currStatus}`}
onChange={handleToggle}
/>
</SettingBlock>
);
const currStatus = useAppSelector((store) => !!store.server.contact_verification_enable);
const { t } = useTranslation("setting", { keyPrefix: "overview.contact_verify" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleToggle = () => {
updateSetting({ contact_verification_enable: !currStatus });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t("desc")}>
<StyledRadio
options={[t("enable"), t("disable")]}
values={["true", "false"]}
value={`${currStatus}`}
onChange={handleToggle}
/>
</SettingBlock>
);
};
export default Index;
export default Index;
+34 -33
View File
@@ -1,39 +1,40 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Radio from '../../../components/styled/Radio';
import { Theme } from '../../../types/common';
import SettingBlock from './SettingBlock';
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Radio from "../../../components/styled/Radio";
import { Theme } from "../../../types/common";
import SettingBlock from "./SettingBlock";
// type Props = {}
const DarkMode = () => {
const [theme, setTheme] = useState<Theme>(localStorage.theme || "auto");
const { t } = useTranslation("setting");
const handleThemeToggle = (v: Theme) => {
setTheme(v);
localStorage.theme = v;
// reset
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove("light");
if (v !== "auto") {
document.documentElement.classList.add(v);
} else {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(isDark ? "dark" : 'light');
}
};
return (
<SettingBlock title={t("overview.theme.title")} desc={t("overview.theme.desc")}>
<Radio
options={[t("overview.theme.auto"), t("overview.theme.dark"), t("overview.theme.light")]}
values={['auto', 'dark', 'light']}
value={theme}
onChange={(v) => {
handleThemeToggle(v);
}}
/>
</SettingBlock>
);
const [theme, setTheme] = useState<Theme>(localStorage.theme || "auto");
const { t } = useTranslation("setting");
const handleThemeToggle = (v: Theme) => {
setTheme(v);
localStorage.theme = v;
// reset
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove("light");
if (v !== "auto") {
document.documentElement.classList.add(v);
} else {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.add(isDark ? "dark" : "light");
}
};
return (
<SettingBlock title={t("overview.theme.title")} desc={t("overview.theme.desc")}>
<Radio
options={[t("overview.theme.auto"), t("overview.theme.dark"), t("overview.theme.light")]}
values={["auto", "dark", "light"]}
value={theme}
onChange={(v) => {
handleThemeToggle(v);
}}
/>
</SettingBlock>
);
};
export default DarkMode;
export default DarkMode;
+43 -38
View File
@@ -1,45 +1,50 @@
// import React from 'react'
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from '../../../app/services/server';
import StyledInput from "@/components/styled/Input";
import { ChangeEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import StyledButton from "@/components/styled/Button";
import { ChangeEvent, useState, useEffect } from 'react';
import SettingBlock from './SettingBlock';
import StyledInput from "@/components/styled/Input";
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "../../../app/services/server";
import SettingBlock from "./SettingBlock";
// type Props = {}
const Index = () => {
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
const [url, setUrl] = useState(location.origin);
const { t } = useTranslation("setting");
const { t: ct } = useTranslation();
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
// update
setUrl(evt.target.value);
};
const handleUpdate = () => {
updateUrl(url);
};
const [updateUrl, { isLoading, isSuccess }] = useUpdateFrontendUrlMutation();
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
}
}, [isSuccess]);
useEffect(() => {
if (getUrlSuccess && data) {
setUrl(data);
}
}, [getUrlSuccess, data]);
// if(!fetch)
return (
<SettingBlock title={t("overview.url.title")} desc={t("overview.url.desc")}>
<div className="flex items-center gap-4 mt-2">
<StyledInput placeholder='frontend url' value={url} onChange={handleChange} />
<StyledButton disabled={!url || isLoading} className='small' onClick={handleUpdate}> {ct("action.update")}</StyledButton>
</div>
</SettingBlock>
);
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
const [url, setUrl] = useState(location.origin);
const { t } = useTranslation("setting");
const { t: ct } = useTranslation();
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
// update
setUrl(evt.target.value);
};
const handleUpdate = () => {
updateUrl(url);
};
const [updateUrl, { isLoading, isSuccess }] = useUpdateFrontendUrlMutation();
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
}
}, [isSuccess]);
useEffect(() => {
if (getUrlSuccess && data) {
setUrl(data);
}
}, [getUrlSuccess, data]);
// if(!fetch)
return (
<SettingBlock title={t("overview.url.title")} desc={t("overview.url.desc")}>
<div className="flex items-center gap-4 mt-2">
<StyledInput placeholder="frontend url" value={url} onChange={handleChange} />
<StyledButton disabled={!url || isLoading} className="small" onClick={handleUpdate}>
{" "}
{ct("action.update")}
</StyledButton>
</div>
</SettingBlock>
);
};
export default Index;
export default Index;
+26 -20
View File
@@ -1,29 +1,35 @@
// import React from 'react'
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import StyledRadio from "@/components/styled/Radio";
import SettingBlock from './SettingBlock';
import SettingBlock from "./SettingBlock";
// type Props = {}
const Index = () => {
const { t, i18n } = useTranslation("setting");
const handleGuestToggle = (v: "zh" | "en") => {
i18n.changeLanguage(v);
};
return (
<SettingBlock title={t("overview.lang.title")} desc={t("overview.lang.desc")}>
<StyledRadio
options={[t("overview.lang.en"), t("overview.lang.zh"), t("overview.lang.jp"), t("overview.lang.tr")]}
values={["en", "zh", "jp", "tr"]}
value={i18n.language.split("-")[0]}
onChange={(v) => {
console.log("wtff", v);
const { t, i18n } = useTranslation("setting");
const handleGuestToggle = (v: "zh" | "en") => {
i18n.changeLanguage(v);
};
return (
<SettingBlock title={t("overview.lang.title")} desc={t("overview.lang.desc")}>
<StyledRadio
options={[
t("overview.lang.en"),
t("overview.lang.zh"),
t("overview.lang.jp"),
t("overview.lang.tr")
]}
values={["en", "zh", "jp", "tr"]}
value={i18n.language.split("-")[0]}
onChange={(v) => {
console.log("wtff", v);
handleGuestToggle(v);
}}
/>
</SettingBlock>
);
handleGuestToggle(v);
}}
/>
</SettingBlock>
);
};
export default Index;
export default Index;
+37 -32
View File
@@ -1,39 +1,44 @@
// import React from 'react'
import { useTranslation } from 'react-i18next';
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
import { useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import StyledRadio from "@/components/styled/Radio";
import { useAppSelector } from '../../../app/store';
import SettingBlock from './SettingBlock';
import {
useGetSystemCommonQuery,
useUpdateSystemCommonMutation
} from "../../../app/services/server";
import { useAppSelector } from "../../../app/store";
import SettingBlock from "./SettingBlock";
// type Props = {}
const Index = () => {
const currStatus = useAppSelector(store => !!store.server.show_user_online_status);
const { t } = useTranslation("setting", { keyPrefix: "overview.online_status" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleToggle = () => {
updateSetting({ show_user_online_status: !currStatus });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t('desc')} >
<StyledRadio
options={[t("enable"), t("disable")]}
values={["true", "false"]}
value={`${currStatus}`}
onChange={handleToggle}
/>
</SettingBlock>
);
const currStatus = useAppSelector((store) => !!store.server.show_user_online_status);
const { t } = useTranslation("setting", { keyPrefix: "overview.online_status" });
const { t: ct } = useTranslation();
const { refetch } = useGetSystemCommonQuery();
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
useEffect(() => {
if (isSuccess) {
refetch();
toast.success(ct("tip.update"));
}
}, [isSuccess]);
const handleToggle = () => {
updateSetting({ show_user_online_status: !currStatus });
};
// if (!loadSuccess) return null;
return (
<SettingBlock title={t("title")} desc={t("desc")}>
<StyledRadio
options={[t("enable"), t("disable")]}
values={["true", "false"]}
value={`${currStatus}`}
onChange={handleToggle}
/>
</SettingBlock>
);
};
export default Index;
export default Index;
+4 -3
View File
@@ -1,13 +1,14 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateLogoMutation, useUpdateServerMutation } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
import LogoUploader from "@/components/AvatarUploader";
import SaveTip from "@/components/SaveTip";
import Input from "@/components/styled/Input";
import Label from "@/components/styled/Label";
import Textarea from "@/components/styled/Textarea";
import SaveTip from "@/components/SaveTip";
import { useAppSelector } from "@/app/store";
const Index = () => {
const { t } = useTranslation("setting");
+15 -17
View File
@@ -1,21 +1,19 @@
import React from 'react';
import React from "react";
type Props = {
title: string,
desc: string,
children: React.ReactNode
}
const SettingBlock = ({ title, desc, children }: Props) => {
return (
<div className="text-sm">
<p className="text-gray-600 dark:text-gray-100 font-semibold">{title}</p>
<p className="flex justify-between w-full text-gray-400 mb-2 text-xs">
{desc}
</p>
{children}
</div>
);
title: string;
desc: string;
children: React.ReactNode;
};
export default SettingBlock;
const SettingBlock = ({ title, desc, children }: Props) => {
return (
<div className="text-sm">
<p className="text-gray-600 dark:text-gray-100 font-semibold">{title}</p>
<p className="flex justify-between w-full text-gray-400 mb-2 text-xs">{desc}</p>
{children}
</div>
);
};
export default SettingBlock;

Some files were not shown because too many files have changed in this diff Show More