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
+2 -2
View File
@@ -19,8 +19,8 @@
"^@/assets/(.*)$",
"^[./]"
],
"importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
"importOrderCaseInsensitive": true,
"importOrderMergeDuplicateImports": true,
"importOrderSeparation": true,
"importOrderGroupNamespaceSpecifiers": true
"importOrderSeparation": true
}
+3
View File
@@ -62,5 +62,8 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+5 -3
View File
@@ -1,16 +1,17 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { hideAll } from "tippy.js";
import { useAppSelector } from "@/app/store";
import IconInvite from "@/assets/icons/add.person.svg";
import IconMention from "@/assets/icons/mention.svg";
import IconSearch from "@/assets/icons/search.svg";
import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal";
import UsersModal from "./UsersModal";
import InviteModal from "./InviteModal";
import { useTranslation } from "react-i18next";
import SearchUser from "./SearchUser";
import ServerVersionChecker from "./ServerVersionChecker";
import UsersModal from "./UsersModal";
export default function AddEntriesMenu() {
const { t } = useTranslation();
@@ -53,7 +54,8 @@ export default function AddEntriesMenu() {
setChannelModalVisible(false);
};
const itemClass = "rounded flex items-center gap-2 text-sm font-semibold cursor-pointer px-2 py-2.5 md:hover:bg-gray-800/20 md:dark:hover:bg-gray-200/20";
const itemClass =
"rounded flex items-center gap-2 text-sm font-semibold cursor-pointer px-2 py-2.5 md:hover:bg-gray-800/20 md:dark:hover:bg-gray-200/20";
const iconClass = "w-5 h-5 dark:fill-gray-300";
return (
<>
+94 -86
View File
@@ -1,97 +1,105 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateAutoDeleteMsgMutation } from "@/app/services/user";
import { useAppSelector } from '../app/store';
import { useLazyClearChannelMessageQuery } from "../app/services/channel";
import { useAppSelector } from "../app/store";
import { ChatContext } from "../types/common";
import SaveTip from "./SaveTip";
import StyledRadio from "./styled/Radio";
import { ChatContext } from '../types/common';
import StyledButton from "./styled/Button";
import { useLazyClearChannelMessageQuery } from '../app/services/channel';
import StyledRadio from "./styled/Radio";
type Props = {
id: number,
type?: ChatContext,
// expires_in?: number
}
id: number;
type?: ChatContext;
// expires_in?: number
};
const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
const { setting, channel, loginUser } = useAppSelector(store => {
return {
setting: type == "channel" ? store.footprint.autoDeleteMsgChannels.find(item => item.gid == id) : store.footprint.autoDeleteMsgUsers.find(item => item.uid == id),
loginUser: store.authData.user,
channel: type == "channel" ? store.channels.byId[id] : null
};
});
const [updateSetting, { isSuccess }] = useUpdateAutoDeleteMsgMutation();
const [clearMessage, { isSuccess: clearSuccess }] = useLazyClearChannelMessageQuery();
const { setting, channel, loginUser } = useAppSelector((store) => {
return {
setting:
type == "channel"
? store.footprint.autoDeleteMsgChannels.find((item) => item.gid == id)
: store.footprint.autoDeleteMsgUsers.find((item) => item.uid == id),
loginUser: store.authData.user,
channel: type == "channel" ? store.channels.byId[id] : null
};
});
const [updateSetting, { isSuccess }] = useUpdateAutoDeleteMsgMutation();
const [clearMessage, { isSuccess: clearSuccess }] = useLazyClearChannelMessageQuery();
const [value, setValue] = useState<number>(setting?.expires_in ?? 0);
const { t } = useTranslation("setting", { keyPrefix: "auto_delete_msg" });
const { t: ct } = useTranslation();
const options = [
{ title: t("off"), value: 0 },
{ title: t("5_min"), value: 5 * 60 },
{ title: t("10_min"), value: 10 * 60 },
{ title: t("1_hour"), value: 60 * 60 },
{ title: t("1_day"), value: 24 * 60 * 60 },
{ title: t("1_week"), value: 7 * 24 * 60 * 60 },
];
const handleSave = () => {
const dto = type == "dm" ? { users: [{ uid: id, expires_in: value }] } : { groups: [{ gid: id, expires_in: value }] };
updateSetting(dto);
};
const handleReset = () => {
setValue(setting?.expires_in ?? 0);
};
const handleChange = (val: number) => {
setValue(val);
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
}
}, [isSuccess]);
useEffect(() => {
if (clearSuccess) {
toast.success("Cleared!");
}
}, [clearSuccess]);
const handleClear = () => {
if (confirm("are you sure?")) {
clearMessage(id);
}
};
const originalVal = setting?.expires_in ?? 0;
const showClear = type == "channel" && (channel?.owner == loginUser?.uid || loginUser?.is_admin);
return (
<section className="max-w-[512px] h-full relative">
<div className="text-sm">
<h2 className='dark:text-white' >{t("title")}</h2>
<p className="text-gray-400">{t("desc")}</p>
</div>
<div className="mt-4">
<StyledRadio
options={options.map(({ title }) => title)}
values={options.map(({ value }) => value)}
value={value || 0}
onChange={handleChange}
/>
</div>
{originalVal !== value && <SaveTip saveHandler={handleSave} resetHandler={handleReset} />}
{showClear && <>
<div className="text-sm mt-8">
<h2 className='dark:text-white' >{t("clear_title")}</h2>
<p className="text-gray-400">{t("clear_desc")}</p>
</div>
<div className="mt-4">
<StyledButton className="danger" onClick={handleClear}>{t("clear")}</StyledButton>
</div>
</>
}
</section>
);
const [value, setValue] = useState<number>(setting?.expires_in ?? 0);
const { t } = useTranslation("setting", { keyPrefix: "auto_delete_msg" });
const { t: ct } = useTranslation();
const options = [
{ title: t("off"), value: 0 },
{ title: t("5_min"), value: 5 * 60 },
{ title: t("10_min"), value: 10 * 60 },
{ title: t("1_hour"), value: 60 * 60 },
{ title: t("1_day"), value: 24 * 60 * 60 },
{ title: t("1_week"), value: 7 * 24 * 60 * 60 }
];
const handleSave = () => {
const dto =
type == "dm"
? { users: [{ uid: id, expires_in: value }] }
: { groups: [{ gid: id, expires_in: value }] };
updateSetting(dto);
};
const handleReset = () => {
setValue(setting?.expires_in ?? 0);
};
const handleChange = (val: number) => {
setValue(val);
};
useEffect(() => {
if (isSuccess) {
toast.success(ct("tip.update"));
}
}, [isSuccess]);
useEffect(() => {
if (clearSuccess) {
toast.success("Cleared!");
}
}, [clearSuccess]);
const handleClear = () => {
if (confirm("are you sure?")) {
clearMessage(id);
}
};
const originalVal = setting?.expires_in ?? 0;
const showClear = type == "channel" && (channel?.owner == loginUser?.uid || loginUser?.is_admin);
return (
<section className="max-w-[512px] h-full relative">
<div className="text-sm">
<h2 className="dark:text-white">{t("title")}</h2>
<p className="text-gray-400">{t("desc")}</p>
</div>
<div className="mt-4">
<StyledRadio
options={options.map(({ title }) => title)}
values={options.map(({ value }) => value)}
value={value || 0}
onChange={handleChange}
/>
</div>
{originalVal !== value && <SaveTip saveHandler={handleSave} resetHandler={handleReset} />}
{showClear && (
<>
<div className="text-sm mt-8">
<h2 className="dark:text-white">{t("clear_title")}</h2>
<p className="text-gray-400">{t("clear_desc")}</p>
</div>
<div className="mt-4">
<StyledButton className="danger" onClick={handleClear}>
{t("clear")}
</StyledButton>
</div>
</>
)}
</section>
);
};
export default AutoDeleteMessages;
export default AutoDeleteMessages;
+4 -1
View File
@@ -1,4 +1,5 @@
import { FC, ImgHTMLAttributes, useState } from "react";
import { getInitials } from "../utils";
interface Props extends ImgHTMLAttributes<HTMLImageElement> {
@@ -53,7 +54,9 @@ const Avatar: FC<Props> = ({
color: type === "channel" ? "#475467" : "#FFFFFF"
}}
>
<span className="whitespace-nowrap" style={{ transform: `scale(${scaleVal})` }}>{initials}</span>
<span className="whitespace-nowrap" style={{ transform: `scale(${scaleVal})` }}>
{initials}
</span>
</div>
);
};
+22 -6
View File
@@ -1,9 +1,10 @@
import { ChangeEvent, FC, useState } from "react";
import Avatar from "./Avatar";
import uploadIcon from "@/assets/icons/upload.image.svg?url";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import uploadIcon from "@/assets/icons/upload.image.svg?url";
import Avatar from "./Avatar";
type UID = number;
interface Props {
size?: number;
@@ -41,11 +42,20 @@ const AvatarUploader: FC<Props> = ({
setUploading(false);
};
return (
<div style={{ width: `${size}px`, height: `${size}px` }} className={clsx(className, "relative group")}>
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={clsx(className, "relative group")}
>
<div className="group overflow-hidden relative w-full h-full rounded-full bg-gray-50">
<Avatar width={size} height={size} type={type} src={url} name={name} className={`${className} object-cover w-full h-full`} />
<Avatar
width={size}
height={size}
type={type}
src={url}
name={name}
className={`${className} object-cover w-full h-full`}
/>
{!disabled && (
<>
<div className="flex-center flex-col whitespace-nowrap hidden group-hover:flex p-1 absolute inset-0 bg-black/50 text-white font-bold text-xs">
@@ -63,7 +73,13 @@ const AvatarUploader: FC<Props> = ({
</>
)}
</div>
{!disabled && <img src={uploadIcon} alt="icon" className="hidden w-7 h-7 absolute top-0 right-0 group-hover:block" />}
{!disabled && (
<img
src={uploadIcon}
alt="icon"
className="hidden w-7 h-7 absolute top-0 right-0 group-hover:block"
/>
)}
</div>
);
};
+64 -35
View File
@@ -3,16 +3,15 @@ import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import Linkify from "linkify-react";
import ChannelModal from "./ChannelModal";
import InviteModal from "./InviteModal";
import { useAppSelector } from "@/app/store";
import IconEdit from "@/assets/icons/edit.svg";
import IconChat from "@/assets/icons/placeholder.chat.svg";
import IconAsk from "@/assets/icons/placeholder.question.svg";
import IconInvite from "@/assets/icons/placeholder.invite.svg";
import IconDownload from "@/assets/icons/placeholder.download.svg";
import IconInvite from "@/assets/icons/placeholder.invite.svg";
import IconAsk from "@/assets/icons/placeholder.question.svg";
import ChannelModal from "./ChannelModal";
import InviteModal from "./InviteModal";
import UsersModal from "./UsersModal";
import { useAppSelector } from "@/app/store";
interface Props {
type?: "chat" | "user";
@@ -24,7 +23,13 @@ const classes = {
};
const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
const { t } = useTranslation("welcome");
const { server, isAdmin, upgraded } = useAppSelector((store) => { return { server: store.server, isAdmin: store.authData.user?.is_admin, upgraded: store.server.upgraded }; });
const { server, isAdmin, upgraded } = useAppSelector((store) => {
return {
server: store.server,
isAdmin: store.authData.user?.is_admin,
upgraded: store.server.upgraded
};
});
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [createChannelVisible, setCreateChannelVisible] = useState(false);
const [userListVisible, setUserListVisible] = useState(false);
@@ -37,33 +42,46 @@ const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
const toggleInviteModalVisible = () => {
setInviteModalVisible((prev) => !prev);
};
const chatTip =
type == "chat" ? t("start_by_channel") : t("start_by_dm");
const chatTip = type == "chat" ? t("start_by_channel") : t("start_by_dm");
const chatHandler = type == "chat" ? toggleChannelModalVisible : toggleUserListVisible;
return (
<>
<div className="flex flex-col gap-8 -mt-[50px] dark:bg-gray-700">
<div className="flex flex-col gap-2 items-center group px-4">
<h2 className="text-center text-3xl text-slate-700 dark:text-white font-bold">{t("title", { name: server.name })}</h2>
<h2 className="text-center text-3xl text-slate-700 dark:text-white font-bold">
{t("title", { name: server.name })}
</h2>
<p className="text-sm text-gray-400 max-w-md text-center relative whitespace-normal">
<Linkify options={
{
<Linkify
options={{
render: {
url: ({ content, attributes: { href: link } }) => {
return <>
<a className="text-primary-400" target="_blank" href={link} rel="noreferrer">
{content}
</a>
</>;
},
return (
<>
<a
className="text-primary-400"
target="_blank"
href={link}
rel="noreferrer"
>
{content}
</a>
</>
);
}
}
}
}>
}}
>
{server.description ? server.description : t("desc")}
</Linkify>
{isAdmin && <NavLink to={"/setting/overview"} className="absolute top-0 -right-7 invisible group-hover:visible">
<IconEdit />
</NavLink>}
{isAdmin && (
<NavLink
to={"/setting/overview"}
className="absolute top-0 -right-7 invisible group-hover:visible"
>
<IconEdit />
</NavLink>
)}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 grid-rows-2 gap-2 md:gap-6 m-auto">
@@ -71,21 +89,32 @@ const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
<IconInvite className={classes.boxIcon} />
<div className={classes.boxTip}>{t("invite")}</div>
</div>
<button onClick={chatHandler} className={classes.box} >
<button onClick={chatHandler} className={classes.box}>
<IconChat className={classes.boxIcon} />
<div className={classes.boxTip}>{chatTip}</div>
</button>
{!upgraded && <>
<a href={"https://voce.chat#download"} target={"_blank"} rel="noreferrer" className={classes.box} >
<IconDownload className={classes.boxIcon} />
<div className={classes.boxTip}>{t("download")}</div>
</a>
<a href={"https://doc.voce.chat"} target={"_blank"} rel="noreferrer" className={classes.box} >
<IconAsk className={classes.boxIcon} />
<div className={classes.boxTip}>{t("help")}</div>
</a>
</>
}
{!upgraded && (
<>
<a
href={"https://voce.chat#download"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconDownload className={classes.boxIcon} />
<div className={classes.boxTip}>{t("download")}</div>
</a>
<a
href={"https://doc.voce.chat"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconAsk className={classes.boxIcon} />
<div className={classes.boxTip}>{t("help")}</div>
</a>
</>
)}
</div>
</div>
{createChannelVisible && (
+13 -5
View File
@@ -1,8 +1,8 @@
import { FC } from "react";
import Avatar from "./Avatar";
import { useAppSelector } from "@/app/store";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import Avatar from "./Avatar";
interface Props {
interactive?: boolean;
@@ -24,9 +24,16 @@ const Channel: FC<Props> = ({ interactive = true, id, compact = false, avatarSiz
return (
<div
className={clsx(`flex items-center justify-start gap-2 p-2 rounded-lg select-none`, compact && "p-0", interactive && "md:hover:bg-gray-500/10")}
className={clsx(
`flex items-center justify-start gap-2 p-2 rounded-lg select-none`,
compact && "p-0",
interactive && "md:hover:bg-gray-500/10"
)}
>
<div className={`cursor-pointer relative`} style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}>
<div
className={`cursor-pointer relative`}
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
>
<Avatar
width={avatarSize}
height={avatarSize}
@@ -39,7 +46,8 @@ const Channel: FC<Props> = ({ interactive = true, id, compact = false, avatarSiz
</div>
{!compact && (
<div className="flex text-sm text-gray-500 font-semibold">
<span className="max-w-[140px] truncate">{name}</span> ({is_public ? totalMemberCount : members.length})
<span className="max-w-[140px] truncate">{name}</span> (
{is_public ? totalMemberCount : members.length})
</div>
)}
</div>
+7 -2
View File
@@ -1,6 +1,7 @@
import { FC } from "react";
import HashIcon from "@/assets/icons/channel.svg";
import LockHashIcon from "@/assets/icons/channel.private.svg";
import HashIcon from "@/assets/icons/channel.svg";
interface Props {
personal?: boolean;
@@ -11,7 +12,11 @@ interface Props {
const ChannelIcon: FC<Props> = ({ personal = false, muted = false, className = "" }) => {
return (
<div className={`flex ${muted ? "!text-gray-400" : ""} ${className}`}>
{personal ? <LockHashIcon className="dark:fill-gray-300" /> : <HashIcon className="dark:fill-gray-300" />}
{personal ? (
<LockHashIcon className="dark:fill-gray-300" />
) : (
<HashIcon className="dark:fill-gray-300" />
)}
</div>
);
};
+31 -19
View File
@@ -1,20 +1,20 @@
import { useState, useEffect, FC, MouseEvent, ChangeEvent } from "react";
import { ChangeEvent, FC, MouseEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import i18n from "@/i18n";
import clsx from "clsx";
import Modal from "../Modal";
import Button from "../styled/Button";
import ChannelIcon from "../ChannelIcon";
import User from "../User";
import StyledCheckbox from "../styled/Checkbox";
import StyledToggle from "../styled/Toggle";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import { useCreateChannelMutation, useSendChannelMsgMutation } from "@/app/services/channel";
import { useAppSelector } from "@/app/store";
import { CreateChannelDTO } from "@/types/channel";
import i18n from "@/i18n";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import ChannelIcon from "../ChannelIcon";
import Modal from "../Modal";
import Button from "../styled/Button";
import StyledCheckbox from "../styled/Checkbox";
import StyledToggle from "../styled/Toggle";
import User from "../User";
interface Props {
personal?: boolean;
@@ -66,7 +66,7 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
}, [isError]);
useEffect(() => {
const id = typeof newChannel == 'object' ? newChannel.gid : newChannel;
const id = typeof newChannel == "object" ? newChannel.gid : newChannel;
if (isSuccess && id && channelData[id]) {
const name = channelData[id].name;
// 发个欢迎消息
@@ -78,7 +78,6 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
}
}, [isSuccess, newChannel, channelData]);
const handleNameInput = (evt: ChangeEvent<HTMLInputElement>) => {
setData((prev) => ({ ...prev, name: evt.target.value }));
};
@@ -138,18 +137,31 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
)}
</div>
)}
<div className={clsx(`w-80 md:min-w-[400px] flex flex-col items-center p-8 box-border`, !is_public && "w-[344px] justify-evenly")}>
<h3 className="font-semibold text-xl text-gray-600 mb-4 dark:text-white">{t("create_channel")}</h3>
<div
className={clsx(
`w-80 md:min-w-[400px] flex flex-col items-center p-8 box-border`,
!is_public && "w-[344px] justify-evenly"
)}
>
<h3 className="font-semibold text-xl text-gray-600 mb-4 dark:text-white">
{t("create_channel")}
</h3>
<p className="text-gray-400 mb-2 md:mb-12 text-sm font-normal">
{!is_public
? t("create_private_channel_desc")
: t("create_channel_desc")}
{!is_public ? t("create_private_channel_desc") : t("create_channel_desc")}
</p>
<div className="w-full flex flex-col justify-start gap-2 mb-2 md:mb-8">
<span className="text-gray-400 text-sm font-normal">{t("channel_name")}</span>
<div className="relative">
<input className="text-gray-600 dark:text-gray-300 rounded p-2 pl-9 border border-solid border-gray-300 w-full bg-transparent" onChange={handleNameInput} value={name} placeholder="new channel" />
<ChannelIcon personal={!is_public} className="absolute left-2 top-1/2 -translate-y-1/2" />
<input
className="text-gray-600 dark:text-gray-300 rounded p-2 pl-9 border border-solid border-gray-300 w-full bg-transparent"
onChange={handleNameInput}
value={name}
placeholder="new channel"
/>
<ChannelIcon
personal={!is_public}
className="absolute left-2 top-1/2 -translate-y-1/2"
/>
</div>
</div>
<div className="w-full flex items-center justify-between mb-8 md:mb-12">
+4 -3
View File
@@ -1,10 +1,11 @@
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import useDeleteMessage from "@/hooks/useDeleteMessage";
import StyledModal from "./styled/Modal";
import Button from "./styled/Button";
import Modal from "./Modal";
import PreviewMessage from "./Message/PreviewMessage";
import Modal from "./Modal";
import Button from "./styled/Button";
import StyledModal from "./styled/Modal";
interface Props {
closeModal: (b: boolean) => void;
+14 -4
View File
@@ -1,14 +1,24 @@
import { FC } from "react";
import clsx from "clsx";
interface Props {
content: string;
className?: string
className?: string;
}
const Divider: FC<Props> = ({ content, className = "" }) => {
return <div className={clsx("relative border-none h-[1px] bg-slate-200 dark:bg-gray-500 my-6 overflow-visible", className)}>
<span className="p-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-gray-500 dark:text-gray-300 font-semibold bg-white dark:bg-gray-700">{content}</span>
</div>;
return (
<div
className={clsx(
"relative border-none h-[1px] bg-slate-200 dark:bg-gray-500 my-6 overflow-visible",
className
)}
>
<span className="p-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-gray-500 dark:text-gray-300 font-semibold bg-white dark:bg-gray-700">
{content}
</span>
</div>
);
};
export default Divider;
+17 -19
View File
@@ -1,27 +1,25 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
type FallbackProps = {
error: Error,
resetErrorBoundary: (...args: Array<unknown>) => void
}
error: Error;
resetErrorBoundary: (...args: Array<unknown>) => void;
};
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className='text-lg text-red-600'>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
return (
<div role="alert" className="text-lg text-red-600">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
type Props = {
children: ReactNode
}
const ErrorCatcher = ({ children }: Props) => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>
);
children: ReactNode;
};
export default ErrorCatcher;
const ErrorCatcher = ({ children }: Props) => {
return <ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>;
};
export default ErrorCatcher;
+21 -9
View File
@@ -1,16 +1,17 @@
import { FC, ReactElement } from "react";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import { formatBytes, fromNowTime, getFileIcon } from "@/utils";
import IconDownload from "@/assets/icons/download.svg";
import {
VideoPreview,
AudioPreview,
CodePreview,
DocPreview,
ImagePreview,
PdfPreview,
CodePreview,
DocPreview
VideoPreview
} from "./preview";
import { getFileIcon, formatBytes, fromNowTime } from "@/utils";
import IconDownload from "@/assets/icons/download.svg";
import { useAppSelector } from "@/app/store";
interface Data {
file_type: string;
@@ -86,12 +87,19 @@ const FileBox: FC<Props> = ({
return (
<div
className={clsx(`rounded-md border border-solid border-gray-300 dark:border-gray-500 bg-gray-100 dark:bg-gray-900`, flex ? "w-full" : "w-72 md:w-[370px]", withPreview ? "relative overflow-hidden h-[281px]" : "h-[66px] ", file_type.startsWith("audio") && "h-[125px]")}
className={clsx(
`rounded-md border border-solid border-gray-300 dark:border-gray-500 bg-gray-100 dark:bg-gray-900`,
flex ? "w-full" : "w-72 md:w-[370px]",
withPreview ? "relative overflow-hidden h-[281px]" : "h-[66px] ",
file_type.startsWith("audio") && "h-[125px]"
)}
>
<div className="w-full p-2 flex items-center justify-between gap-2">
{icon}
<div className="flex flex-col gap-1 w-full overflow-hidden">
<span className="font-semibold text-sm text-gray-800 dark:text-gray-200 truncate">{name}</span>
<span className="font-semibold text-sm text-gray-800 dark:text-gray-200 truncate">
{name}
</span>
<em className="text-xs text-gray-500 flex gap-4 not-italic">
<span className="size">{formatBytes(size)}</span>
<span className="hidden md:block time">{fromNowTime(created_at)}</span>
@@ -100,7 +108,11 @@ const FileBox: FC<Props> = ({
</span>
</em>
</div>
<a className="hidden md:block whitespace-nowrap" download={name} href={`${content}&download=true`}>
<a
className="hidden md:block whitespace-nowrap"
download={name}
href={`${content}&download=true`}
>
<IconDownload className="fill-gray-500 dark:fill-gray-300" />
</a>
</div>
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, FC } from "react";
import { FC, useEffect, useState } from "react";
interface Props {
url: string;
@@ -17,7 +17,11 @@ const Code: FC<Props> = ({ url }) => {
}, [url]);
if (!content) return null;
return <div className="h-[218px] p-[15px] pb-0 bg-black text-white overflow-scroll whitespace-pre-wrap break-all leading-snug">{content}</div>;
return (
<div className="h-[218px] p-[15px] pb-0 bg-black text-white overflow-scroll whitespace-pre-wrap break-all leading-snug">
{content}
</div>
);
};
export default Code;
+4 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, FC } from "react";
import { FC, useEffect, useState } from "react";
interface Props {
url: string;
@@ -17,7 +17,9 @@ const Doc: FC<Props> = ({ url }) => {
}, [url]);
if (!content) return null;
return <div className="bg-white h-[218px] p-[15px] pb-0 whitespace-pre-wrap break-all">{content}</div>;
return (
<div className="bg-white h-[218px] p-[15px] pb-0 whitespace-pre-wrap break-all">{content}</div>
);
};
export default Doc;
+18 -7
View File
@@ -1,9 +1,10 @@
import { FC, useEffect, useState } from "react";
import clsx from "clsx";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import { LineWobble } from "@uiball/loaders";
import clsx from "clsx";
import { useLazyPreCheckFileFromUrlQuery } from "@/app/services/message";
interface Props {
url: string;
alt?: string;
@@ -46,13 +47,23 @@ const ImageBox: FC<Props> = ({ url, alt }) => {
}
}, [isSuccess, error, url]);
return (
<div className={clsx("h-[218px] overflow-hidden flex-center", status == "error" && "bg-red-100 dark:bg-red-200/60")}>
{status == "loaded" ? <img className="w-full h-full object-cover" src={url} alt={alt} /> : (
status == "loading" ? <span><LineWobble color="rgb(21,91,117)" /></span> :
status == 404 ? <span className="text-lg text-orange-500">File not found, removed maybe</span> : <span className="text-lg text-red-800">Load image error</span>
<div
className={clsx(
"h-[218px] overflow-hidden flex-center",
status == "error" && "bg-red-100 dark:bg-red-200/60"
)}
>
{status == "loaded" ? (
<img className="w-full h-full object-cover" src={url} alt={alt} />
) : status == "loading" ? (
<span>
<LineWobble color="rgb(21,91,117)" />
</span>
) : status == 404 ? (
<span className="text-lg text-orange-500">File not found, removed maybe</span>
) : (
<span className="text-lg text-red-800">Load image error</span>
)}
</div>
);
+1
View File
@@ -1,4 +1,5 @@
import React, { FC } from "react";
interface Props {
url: string;
}
+3 -3
View File
@@ -1,8 +1,8 @@
import VideoPreview from "./Video";
import AudioPreview from "./Audio";
import ImagePreview from "./Image";
import PdfPreview from "./Pdf";
import CodePreview from "./Code";
import DocPreview from "./Doc";
import ImagePreview from "./Image";
import PdfPreview from "./Pdf";
import VideoPreview from "./Video";
export { VideoPreview, AudioPreview, ImagePreview, PdfPreview, CodePreview, DocPreview };
+41 -31
View File
@@ -1,37 +1,47 @@
import { useState } from 'react';
import { formatBytes } from '../../utils';
import { useState } from "react";
import clsx from "clsx";
import IconDownload from "@/assets/icons/download.svg";
import IconAudio from "@/assets/icons/file.audio.svg";
import clsx from 'clsx';
import { formatBytes } from "../../utils";
type Props = {
url: string,
name: string,
size: number,
download: string
}
const AudioMessage = ({ url, name, size, download }: Props) => {
const [canPlay, setCanPlay] = useState(false);
const handleCanPlay = () => {
setCanPlay(true);
};
const _size = formatBytes(size);
return (
<div className='md:w-96 flex flex-col gap-2 px-3 py-2 rounded-md border border-solid border-gray-300 dark:border-gray-500 overflow-hidden bg-stone-100 dark:bg-stone-900'>
<div className="flex justify-between z-30 overflow-hidden">
<div className="flex gap-2 ">
<IconAudio className="w-9 h-auto" />
<div className="flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-300">
<span title={name} className='font-bold w-[240px] truncate'>{name}</span>
<span>{_size}</span>
</div>
</div>
<a href={download} className="mt-2 hidden md:block"><IconDownload className="fill-gray-500 dark:fill-gray-400" /></a>
</div>
<audio src={url} onCanPlay={handleCanPlay} controls className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")} />
</div>
);
url: string;
name: string;
size: number;
download: string;
};
export default AudioMessage;
const AudioMessage = ({ url, name, size, download }: Props) => {
const [canPlay, setCanPlay] = useState(false);
const handleCanPlay = () => {
setCanPlay(true);
};
const _size = formatBytes(size);
return (
<div className="md:w-96 flex flex-col gap-2 px-3 py-2 rounded-md border border-solid border-gray-300 dark:border-gray-500 overflow-hidden bg-stone-100 dark:bg-stone-900">
<div className="flex justify-between z-30 overflow-hidden">
<div className="flex gap-2 ">
<IconAudio className="w-9 h-auto" />
<div className="flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-300">
<span title={name} className="font-bold w-[240px] truncate">
{name}
</span>
<span>{_size}</span>
</div>
</div>
<a href={download} className="mt-2 hidden md:block">
<IconDownload className="fill-gray-500 dark:fill-gray-400" />
</a>
</div>
<audio
src={url}
onCanPlay={handleCanPlay}
controls
className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")}
/>
</div>
);
};
export default AudioMessage;
+25 -16
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, FC } from "react";
import { Ping, LineWobble } from '@uiball/loaders';
import { FC, useEffect, useState } from "react";
import { LineWobble, Ping } from "@uiball/loaders";
import { getDefaultSize, isMobile } from "@/utils";
type Props = {
@@ -21,7 +22,10 @@ const ImageMessage: FC<Props> = ({
}) => {
const url = thumbnail || content;
const [status, setStatus] = useState<"loading" | "error" | "loaded">("loading");
const { width = 0, height = 0 } = getDefaultSize(properties, { min: 200, max: isMobile() ? 300 : 480 });
const { width = 0, height = 0 } = getDefaultSize(properties, {
min: 200,
max: isMobile() ? 300 : 480
});
useEffect(() => {
const img = new Image();
img.onload = () => {
@@ -34,25 +38,29 @@ const ImageMessage: FC<Props> = ({
}, [url]);
return (
<div className={`relative`} style={{
width: width ? `${width}px` : "",
height: height ? `${height}px` : ""
}}
<div
className={`relative`}
style={{
width: width ? `${width}px` : "",
height: height ? `${height}px` : ""
}}
>
{uploading && (
<div className="absolute left-0 top-0 w-full h-full bg-white/50 flex flex-col justify-center items-center gap-1">
<Ping
size={45}
speed={2}
color="#555"
/>
<Ping size={45} speed={2} color="#555" />
<span className="text-xs text-gray-500">{progress}%</span>
</div>
)}
{status == "error" ? <p className="w-full h-full flex-center text-lg text-red-800 bg-red-100">load image error</p> :
status == "loading" ? <p className="w-full h-full flex-center bg-primary-50/80 dark:bg-primary-900/70">
{status == "error" ? (
<p className="w-full h-full flex-center text-lg text-red-800 bg-red-100">
load image error
</p>
) : status == "loading" ? (
<p className="w-full h-full flex-center bg-primary-50/80 dark:bg-primary-900/70">
<LineWobble />
</p> : <img
</p>
) : (
<img
className="h-auto w-full cursor-zoom-in object-cover preview"
// style={{
// width: width ? `${width}px` : "",
@@ -62,7 +70,8 @@ const ImageMessage: FC<Props> = ({
data-origin={content}
data-download={download}
src={url}
/>}
/>
)}
</div>
);
};
+5 -1
View File
@@ -1,4 +1,5 @@
import { FC } from "react";
interface Props {
value: number;
width?: string;
@@ -7,7 +8,10 @@ interface Props {
const Progress: FC<Props> = ({ value, width = "100%" }) => {
return (
<div className="bg-gray-50 rounded h-2 overflow-hidden" style={{ width }}>
<div className="h-2 bg-primary-700 rounded transition-all" style={{ width: `${value}%` }}></div>
<div
className="h-2 bg-primary-700 rounded transition-all"
style={{ width: `${value}%` }}
></div>
</div>
);
};
+71 -59
View File
@@ -1,67 +1,79 @@
import { SyntheticEvent, useState } from 'react';
import { SyntheticEvent, useState } from "react";
import { Orbit } from "@uiball/loaders";
import { formatBytes } from '../../utils';
import IconDownload from "@/assets/icons/download.svg";
import IconVideo from "@/assets/icons/file.video.svg";
import { formatBytes } from "../../utils";
// import clsx from 'clsx';
type Props = {
url: string,
name: string,
size: number,
download: string
}
const VideoMessage = ({ url, name, size, download }: Props) => {
const [canPlay, setCanPlay] = useState(false);
const [error, setError] = useState(false);
const _size = formatBytes(size);
const handlePlay = (evt: SyntheticEvent<HTMLVideoElement>) => {
const infoEle = evt.currentTarget.previousSibling;
if (infoEle) {
(infoEle as HTMLDivElement).classList.add("hidden");
}
};
const handlePause = (evt: SyntheticEvent<HTMLVideoElement>) => {
const infoEle = evt.currentTarget.previousSibling;
if (infoEle) {
(infoEle as HTMLDivElement).classList.remove("hidden");
}
};
const handleCanPlay = () => {
setCanPlay(true);
};
const handleError = () => {
setCanPlay(false);
setError(true);
};
const tipClass = 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2';
return (
<div className='w-60 h-32 md:w-96 md:h-52 relative rounded-md border border-solid border-gray-300 dark:border-gray-700 overflow-hidden group'>
<div className="absolute top-0 left-0 w-full h-full bg-black/40 z-20 group-hover:hidden"></div>
<div className="absolute top-0 left-0 w-full flex justify-between z-30 px-3 py-2 overflow-hidden group-hover:bg-black/20">
<div className="flex gap-2 ">
<IconVideo className="hidden md:block w-9 h-auto" />
<div className="flex flex-col gap-1 text-sm text-white">
<span title={name} className='font-bold w-56 md:w-[240px] truncate'>{name}</span>
<span>{_size}</span>
</div>
</div>
<a href={download} className="hidden md:block mt-2"><IconDownload className="fill-white" /></a>
</div>
{!canPlay ?
<div className={tipClass}>
<Orbit color='#fff' />
</div> :
(error ?
<span className={`${tipClass} text-red-500`}>Error</span> :
null)
}
<video onPlay={handlePlay} onError={handleError} onCanPlay={handleCanPlay} onPause={handlePause} controls={canPlay} className="absolute left-0 top-0 w-full h-full object-cover z-10">
<source src={url} type="video/mp4"></source>
</video>
</div>
);
url: string;
name: string;
size: number;
download: string;
};
export default VideoMessage;
const VideoMessage = ({ url, name, size, download }: Props) => {
const [canPlay, setCanPlay] = useState(false);
const [error, setError] = useState(false);
const _size = formatBytes(size);
const handlePlay = (evt: SyntheticEvent<HTMLVideoElement>) => {
const infoEle = evt.currentTarget.previousSibling;
if (infoEle) {
(infoEle as HTMLDivElement).classList.add("hidden");
}
};
const handlePause = (evt: SyntheticEvent<HTMLVideoElement>) => {
const infoEle = evt.currentTarget.previousSibling;
if (infoEle) {
(infoEle as HTMLDivElement).classList.remove("hidden");
}
};
const handleCanPlay = () => {
setCanPlay(true);
};
const handleError = () => {
setCanPlay(false);
setError(true);
};
const tipClass = "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2";
return (
<div className="w-60 h-32 md:w-96 md:h-52 relative rounded-md border border-solid border-gray-300 dark:border-gray-700 overflow-hidden group">
<div className="absolute top-0 left-0 w-full h-full bg-black/40 z-20 group-hover:hidden"></div>
<div className="absolute top-0 left-0 w-full flex justify-between z-30 px-3 py-2 overflow-hidden group-hover:bg-black/20">
<div className="flex gap-2 ">
<IconVideo className="hidden md:block w-9 h-auto" />
<div className="flex flex-col gap-1 text-sm text-white">
<span title={name} className="font-bold w-56 md:w-[240px] truncate">
{name}
</span>
<span>{_size}</span>
</div>
</div>
<a href={download} className="hidden md:block mt-2">
<IconDownload className="fill-white" />
</a>
</div>
{!canPlay ? (
<div className={tipClass}>
<Orbit color="#fff" />
</div>
) : error ? (
<span className={`${tipClass} text-red-500`}>Error</span>
) : null}
<video
onPlay={handlePlay}
onError={handleError}
onCanPlay={handleCanPlay}
onPause={handlePause}
controls={canPlay}
className="absolute left-0 top-0 w-full h-full object-cover z-10"
>
<source src={url} type="video/mp4"></source>
</video>
</div>
);
};
export default VideoMessage;
+36 -31
View File
@@ -1,17 +1,18 @@
import { FC, useEffect, useState } from "react";
import ImageMessage from "./ImageMessage";
import useRemoveLocalMessage from "@/hooks/useRemoveLocalMessage";
import useUploadFile from "@/hooks/useUploadFile";
import useSendMessage from "@/hooks/useSendMessage";
import Progress from "./Progress";
import { getFileIcon, formatBytes, isImage, getImageSize, fromNowTime } from "@/utils";
import { useAppSelector } from "@/app/store";
import IconDownload from "@/assets/icons/download.svg";
import IconClose from "@/assets/icons/close.circle.svg";
import VideoMessage from "./VideoMessage";
import AudioMessage from "./AudioMessage";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import useRemoveLocalMessage from "@/hooks/useRemoveLocalMessage";
import useSendMessage from "@/hooks/useSendMessage";
import useUploadFile from "@/hooks/useUploadFile";
import { formatBytes, fromNowTime, getFileIcon, getImageSize, isImage } from "@/utils";
import IconClose from "@/assets/icons/close.circle.svg";
import IconDownload from "@/assets/icons/download.svg";
import AudioMessage from "./AudioMessage";
import ImageMessage from "./ImageMessage";
import Progress from "./Progress";
import VideoMessage from "./VideoMessage";
const isLocalFile = (content: string) => {
return content.startsWith("blob:");
@@ -55,7 +56,14 @@ const FileMessage: FC<Props> = ({
from: from_uid,
to
});
const { stopUploading, data, uploadFile, progress, isSuccess: uploadSuccess, isError } = useUploadFile();
const {
stopUploading,
data,
uploadFile,
progress,
isSuccess: uploadSuccess,
isError
} = useUploadFile();
const fromUser = useAppSelector((store) => store.users.byId[from_uid]);
const { size = 0, name, content_type } = properties ?? {};
useEffect(() => {
@@ -145,30 +153,23 @@ const FileMessage: FC<Props> = ({
);
// video
if (content_type.startsWith("video") && !sending)
return (
<VideoMessage
size={size}
url={content}
name={name}
download={download}
/>
);
return <VideoMessage size={size} url={content} name={name} download={download} />;
// audio
if (content_type.startsWith("audio") && !sending)
return (
<AudioMessage
size={size}
url={content}
name={name}
download={download}
/>
);
return <AudioMessage size={size} url={content} name={name} download={download} />;
return (
<div className={clsx(`bg-slate-50 dark:bg-slate-900 border border-solid border-gray-300 dark:border-gray-500 box-border md:w-[370px] rounded-md`, sending && "opacity-90")}>
<div
className={clsx(
`bg-slate-50 dark:bg-slate-900 border border-solid border-gray-300 dark:border-gray-500 box-border md:w-[370px] rounded-md`,
sending && "opacity-90"
)}
>
<div className="px-2 py-3 flex items-center justify-between gap-2">
{icon}
<div className="flex flex-col gap-1 w-full overflow-hidden">
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100 truncate">{name}</span>
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100 truncate">
{name}
</span>
<span className="hidden md:flex whitespace-nowrap text-xs text-gray-500 dark:text-gray-300 gap-4">
{sending ? (
<Progress value={progress} width={"80%"} />
@@ -188,7 +189,11 @@ const FileMessage: FC<Props> = ({
{sending ? (
<IconClose className="cursor-pointer" onClick={handleCancel} />
) : (
<a className="hidden md:block whitespace-nowrap" download={name} href={`${content}&download=true`}>
<a
className="hidden md:block whitespace-nowrap"
download={name}
href={`${content}&download=true`}
>
<IconDownload className="fill-gray-500 dark:fill-gray-400" />
</a>
)}
+16 -12
View File
@@ -1,19 +1,20 @@
import { useState, MouseEvent, ChangeEvent, FC } from "react";
import { ChangeEvent, FC, MouseEvent, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import Modal from "../Modal";
import Button from "../styled/Button";
import Input from "../styled/Input";
import Channel from "../Channel";
import User from "../User";
import Reply from "../Message/Reply";
import useForwardMessage from "@/hooks/useForwardMessage";
import useSendMessage from "@/hooks/useSendMessage";
import useFilteredChannels from "@/hooks/useFilteredChannels";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import useForwardMessage from "@/hooks/useForwardMessage";
import useSendMessage from "@/hooks/useSendMessage";
import CloseIcon from "@/assets/icons/close.circle.svg";
import Channel from "../Channel";
import Reply from "../Message/Reply";
import Modal from "../Modal";
import Button from "../styled/Button";
import StyledCheckbox from "../styled/Checkbox";
import { useTranslation } from "react-i18next";
import Input from "../styled/Input";
import User from "../User";
interface IProps {
mids: number[];
closeModal: () => void;
@@ -140,7 +141,10 @@ const ForwardModal: FC<IProps> = ({ mids, closeModal }) => {
return (
<li key={uid} className="relative">
<User key={uid} uid={uid} interactive={false} />
<CloseIcon className="cursor-pointer absolute right-1 top-1/2 -translate-y-1/2" onClick={removeSelected.bind(null, uid, "user")} />
<CloseIcon
className="cursor-pointer absolute right-1 top-1/2 -translate-y-1/2"
onClick={removeSelected.bind(null, uid, "user")}
/>
</li>
);
})}
@@ -158,7 +162,7 @@ const ForwardModal: FC<IProps> = ({ mids, closeModal }) => {
></Input>
<div className="w-full flex items-center justify-end gap-4">
<Button onClick={closeModal} className="normal cancel">
{t('action.cancel')}
{t("action.cancel")}
</Button>
<Button className="normal" disabled={sendButtonDisabled} onClick={handleForward}>
Send To {selectedCount == 0 ? null : `(${selectedCount})`}
+5 -5
View File
@@ -1,11 +1,11 @@
import { FC, useEffect } from "react";
import IconGithub from "@/assets/icons/github.svg";
import Button from "./styled/Button";
import { useTranslation } from "react-i18next";
import IconGithub from "@/assets/icons/github.svg";
import Button from "./styled/Button";
type Props = {
source?: "widget" | "webapp",
source?: "widget" | "webapp";
client_id?: string;
type?: "login" | "register";
};
@@ -15,14 +15,14 @@ const GithubLoginButton: FC<Props> = ({ type = "login", source = "webapp", clien
useEffect(() => {
const handleGithubLoginStatusChange = (evt: StorageEvent) => {
const { key, newValue } = evt;
if (key == 'widget' && !!newValue) {
if (key == "widget" && !!newValue) {
console.log("github logged in");
localStorage.removeItem("widget");
if (window.location !== window.parent.location) {
// in iframe
const parentWindow = window.parent;
if (parentWindow) {
parentWindow.postMessage("RELOAD_WITH_OPEN", '*');
parentWindow.postMessage("RELOAD_WITH_OPEN", "*");
}
} else {
// 刷新页面
+33 -31
View File
@@ -1,34 +1,36 @@
import React from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
import IconArrow from '@/assets/icons/arrow.left.svg';
type Props = {
path?: string,
className?: string
}
import React from "react";
import { useMatch, useNavigate } from "react-router-dom";
const GoBackNav = ({ path, className = "" }: Props) => {
const navigate = useNavigate();
const isChannelChatPage = useMatch("/chat/channel/:channel_id");
const isDMChatPage = useMatch("/chat/dm/:user_id");
const isProfilePage = useMatch("/users/:user_id");
// console.log("routt", isChannelChatPage, isDMChatPage);
const isChatPage = !!isChannelChatPage || !!isDMChatPage;
const handleBack = () => {
if (path) {
navigate(path);
} else if (isChatPage) {
navigate("/chat");
} else if (isProfilePage) {
navigate("/users");
} else {
navigate(-1);
}
};
return (
<button className={`p-1 absolute left-0 md:hidden ${className}`} onClick={handleBack}>
<IconArrow className="dark:stroke-white w-6 h-6" />
</button>
);
import IconArrow from "@/assets/icons/arrow.left.svg";
type Props = {
path?: string;
className?: string;
};
export default GoBackNav;
const GoBackNav = ({ path, className = "" }: Props) => {
const navigate = useNavigate();
const isChannelChatPage = useMatch("/chat/channel/:channel_id");
const isDMChatPage = useMatch("/chat/dm/:user_id");
const isProfilePage = useMatch("/users/:user_id");
// console.log("routt", isChannelChatPage, isDMChatPage);
const isChatPage = !!isChannelChatPage || !!isDMChatPage;
const handleBack = () => {
if (path) {
navigate(path);
} else if (isChatPage) {
navigate("/chat");
} else if (isProfilePage) {
navigate("/users");
} else {
navigate(-1);
}
};
return (
<button className={`p-1 absolute left-0 md:hidden ${className}`} onClick={handleBack}>
<IconArrow className="dark:stroke-white w-6 h-6" />
</button>
);
};
export default GoBackNav;
+10 -7
View File
@@ -1,12 +1,12 @@
import { FC, Suspense, useEffect, useState } from "react";
import { GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
import { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
import { useLoginMutation } from "@/app/services/auth";
import IconGoogle from "@/assets/icons/google.svg";
import Button from "./styled/Button";
import { useLoginMutation } from "@/app/services/auth";
import { useTranslation } from "react-i18next";
interface Props {
loadError?: boolean;
@@ -43,14 +43,17 @@ const GoogleLoginInner: FC<Props> = ({ type = "login", loaded, loadError }) => {
}, [error]);
return (
<Button className=" group relative w-full !bg-white dark:!bg-gray-700 !text-gray-600 dark:!text-gray-200 overflow-hidden border border-solid border-gray-300 dark:border-gray-500" disabled={!loaded || isLoading}>
<Button
className=" group relative w-full !bg-white dark:!bg-gray-700 !text-gray-600 dark:!text-gray-200 overflow-hidden border border-solid border-gray-300 dark:border-gray-500"
disabled={!loaded || isLoading}
>
<div className="absolute left-0 top-0 w-full h-full flex-center gap-3 z-[998] bg-inherit">
<IconGoogle className="w-6 h-6" />
{loadError
? "Script Load Error!"
: loaded
? `${type === "login" ? t("login.google") : t("reg.google")}`
: `Initializing`}
? `${type === "login" ? t("login.google") : t("reg.google")}`
: `Initializing`}
</div>
<div className="absolute left-0 top-0 w-full h-full group-hover:opacity-0 group-hover:z-[999]">
<GoogleLogin
+1
View File
@@ -1,5 +1,6 @@
import { FC, ReactElement, useEffect } from "react";
import { Navigate } from "react-router-dom";
import { useGetInitializedQuery, useLazyGuestLoginQuery } from "@/app/services/auth";
import { useGetLoginConfigQuery } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
+55 -48
View File
@@ -1,52 +1,59 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
import ImagePreviewModal, { PreviewImageData } from "./ImagePreviewModal";
type Props = {
context?: "chat" | "markdown",
container: HTMLElement | null
}
const ImagePreview = ({ container, context = "chat" }: Props) => {
const [previewImage, setPreviewImage] = useState<PreviewImageData | null>(null);
const closePreviewModal = () => {
setPreviewImage(null);
};
useEffect(() => {
if (!container) return;
const chatHandler = (evt: MouseEvent) => {
const target = evt.target as HTMLImageElement;
if (!target) return;
if (target.nodeName == "IMG" && target.classList.contains("preview")) {
const thumbnail = target.src;
const originUrl = target.dataset.origin || target.src;
const downloadLink = target.dataset.download || target.src;
const meta = JSON.parse(target.dataset.meta || "{}");
setPreviewImage({ thumbnail, originUrl, downloadLink, ...meta });
}
};
const markdownHandler = (evt: MouseEvent) => {
evt.stopPropagation();
const target = evt.target as HTMLImageElement;
if (!target) return;
// 图片
if (target.nodeName == "IMG") {
const urlObj = new URL(target.src);
const originUrl = `${urlObj.origin}${urlObj.pathname}?file_path=${urlObj.searchParams.get(
"file_path"
)}`;
const data = { originUrl };
setPreviewImage(data);
}
};
const handler = context == 'chat' ? chatHandler : markdownHandler;
// 点击查看大图
container.addEventListener("click", handler, true);
return () => {
container.removeEventListener("click", handler, true);
};
}, [container, context]);
return previewImage ? <ImagePreviewModal download={context == "chat"} data={previewImage} closeModal={closePreviewModal} /> : null;
context?: "chat" | "markdown";
container: HTMLElement | null;
};
export default ImagePreview;
const ImagePreview = ({ container, context = "chat" }: Props) => {
const [previewImage, setPreviewImage] = useState<PreviewImageData | null>(null);
const closePreviewModal = () => {
setPreviewImage(null);
};
useEffect(() => {
if (!container) return;
const chatHandler = (evt: MouseEvent) => {
const target = evt.target as HTMLImageElement;
if (!target) return;
if (target.nodeName == "IMG" && target.classList.contains("preview")) {
const thumbnail = target.src;
const originUrl = target.dataset.origin || target.src;
const downloadLink = target.dataset.download || target.src;
const meta = JSON.parse(target.dataset.meta || "{}");
setPreviewImage({ thumbnail, originUrl, downloadLink, ...meta });
}
};
const markdownHandler = (evt: MouseEvent) => {
evt.stopPropagation();
const target = evt.target as HTMLImageElement;
if (!target) return;
// 图片
if (target.nodeName == "IMG") {
const urlObj = new URL(target.src);
const originUrl = `${urlObj.origin}${urlObj.pathname}?file_path=${urlObj.searchParams.get(
"file_path"
)}`;
const data = { originUrl };
setPreviewImage(data);
}
};
const handler = context == "chat" ? chatHandler : markdownHandler;
// 点击查看大图
container.addEventListener("click", handler, true);
return () => {
container.removeEventListener("click", handler, true);
};
}, [container, context]);
return previewImage ? (
<ImagePreviewModal
download={context == "chat"}
data={previewImage}
closeModal={closePreviewModal}
/>
) : null;
};
export default ImagePreview;
+4 -4
View File
@@ -1,9 +1,9 @@
import { useRef, useState, useEffect, FC } from "react";
import { useOutsideClick, useKey } from "rooks";
import { Ring } from "@uiball/loaders";
import Modal from "./Modal";
import { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Ring } from "@uiball/loaders";
import { useKey, useOutsideClick } from "rooks";
import Modal from "./Modal";
export interface PreviewImageData {
originUrl: string;
+6 -5
View File
@@ -1,11 +1,11 @@
import { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import useStreaming from "@/hooks/useStreaming";
// import clsx from "clsx";
import Button from "./styled/Button";
interface Props {
}
interface Props {}
const InactiveScreen: FC<Props> = () => {
const { t } = useTranslation();
@@ -27,10 +27,11 @@ const InactiveScreen: FC<Props> = () => {
<div className="w-screen h-screen dark:bg-gray-700 flex-center text-4xl font-bold">
<div className="flex flex-col items-center text-center gap-2">
<h1 className="text-lg md:text-4xl dark:text-white font-bold">{t("inactive.title")}</h1>
<p className="text-gray-400 text-xs md:text-sm font-semibold" >{t("inactive.desc")}</p>
<Button className="mt-4 uppercase" onClick={handleReload}>{t("action.reload")}</Button>
<p className="text-gray-400 text-xs md:text-sm font-semibold">{t("inactive.desc")}</p>
<Button className="mt-4 uppercase" onClick={handleReload}>
{t("action.reload")}
</Button>
</div>
</div>
);
};
+14 -5
View File
@@ -1,10 +1,11 @@
import { FC } from "react";
import { useTranslation } from "react-i18next";
import useInviteLink from "@/hooks/useInviteLink";
import Input from "./styled/Input";
import Button from "./styled/Button";
import IconQuestion from "@/assets/icons/question.svg";
import QRCode from "./QRCode";
import IconQuestion from '@/assets/icons/question.svg';
import Button from "./styled/Button";
import Input from "./styled/Input";
type Props = {};
const InviteLink: FC<Props> = () => {
@@ -18,13 +19,21 @@ const InviteLink: FC<Props> = () => {
<div className="flex flex-col items-start pb-8">
<p className="font-semibold text-sm mb-2 text-gray-500 dark:text-gray-50 flex flex-col md:flex-row gap-4">
{t("share_invite_link")}
<a className="text-primary-500 flex gap-1 items-center" href="http://doc.voce.chat/faq#fe_url" target="_blank" rel="noopener noreferrer">
<a
className="text-primary-500 flex gap-1 items-center"
href="http://doc.voce.chat/faq#fe_url"
target="_blank"
rel="noopener noreferrer"
>
<IconQuestion /> {t("invite_link_faq")}
</a>
</p>
<div className="w-full md:w-[512px] mb-3 relative">
<Input readOnly className={"large !pr-16"} placeholder="Generating" value={link} />
<Button onClick={copyLink} className="ghost small border_less absolute right-1 top-1/2 -translate-y-1/2">
<Button
onClick={copyLink}
className="ghost small border_less absolute right-1 top-1/2 -translate-y-1/2"
>
{linkCopied ? "Copied" : t("action.copy", { ns: "common" })}
</Button>
</div>
+21 -9
View File
@@ -1,13 +1,14 @@
import { useState, useEffect, FC, MouseEvent, ChangeEvent } from "react";
import { ChangeEvent, FC, MouseEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useAddMembersMutation } from "@/app/services/channel";
import { useAppSelector } from "@/app/store";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import CloseIcon from "@/assets/icons/close.svg";
import Button from "../styled/Button";
import StyledCheckbox from "../styled/Checkbox";
import Input from "../styled/Input";
import User from "../User";
import StyledCheckbox from "../styled/Checkbox";
import { useAddMembersMutation } from "@/app/services/channel";
import CloseIcon from "@/assets/icons/close.svg";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import { useAppSelector } from "@/app/store";
interface Props {
cid?: number;
@@ -60,9 +61,16 @@ const AddMembers: FC<Props> = ({ cid = 0, closeModal }) => {
<ul className="flex items-center flex-wrap gap-1 w-full overflow-scroll">
{selects.map((uid) => {
return (
<li className="px-1.5 py-1 rounded text-sm bg-primary-300 text-white flex items-center justify-between gap-1" key={uid}>
<li
className="px-1.5 py-1 rounded text-sm bg-primary-300 text-white flex items-center justify-between gap-1"
key={uid}
>
{userData[uid]?.name}
<CloseIcon data-uid={uid} onClick={toggleCheckMember} className="cursor-pointer w-3 h-3 fill-white" />
<CloseIcon
data-uid={uid}
onClick={toggleCheckMember}
className="cursor-pointer w-3 h-3 fill-white"
/>
</li>
);
})}
@@ -97,7 +105,11 @@ const AddMembers: FC<Props> = ({ cid = 0, closeModal }) => {
);
})}
</ul>
<Button disabled={selects.length == 0 || isAdding} className="flex mt-4 justify-center items-center" onClick={handleAddMembers}>
<Button
disabled={selects.length == 0 || isAdding}
className="flex mt-4 justify-center items-center"
onClick={handleAddMembers}
>
{isAdding ? `Adding` : "Add"} to #{channel.name}
</Button>
</div>
+23 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, ChangeEvent, FC, useRef } from "react";
import { ChangeEvent, FC, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
import useInviteLink from "@/hooks/useInviteLink";
import QRCode from "../QRCode";
@@ -12,7 +13,6 @@ interface Props {
}
const InviteByEmail: FC<Props> = ({ cid }) => {
const { t } = useTranslation("chat");
const { t: ct } = useTranslation();
const [email, setEmail] = useState("");
@@ -27,7 +27,6 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
}, [linkCopied]);
useEffect(() => {
if (isSuccess) {
toast.success("Email Sent!");
}
}, [isSuccess]);
@@ -49,7 +48,9 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
return (
<div className="pt-4">
<div className="flex flex-col gap-4 mb-6">
<label className="text-sm text-gray-400 dark:text-gray-100" htmlFor="">{t("invite_by_email")}</label>
<label className="text-sm text-gray-400 dark:text-gray-100" htmlFor="">
{t("invite_by_email")}
</label>
<div className="relative flex items-center gap-2">
<form ref={formRef} action="/" className="w-full">
<Input
@@ -63,26 +64,37 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
placeholder={enableSMTP ? "Enter Email" : t("enable_smtp")}
/>
</form>
<Button disabled={!enableSMTP || !email || isLoading} className="send" onClick={handleSendEmail}>
<Button
disabled={!enableSMTP || !email || isLoading}
className="send"
onClick={handleSendEmail}
>
{ct("action.send")}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 mb-3">
<label className="text-sm text-gray-400 dark:text-gray-100" htmlFor="">{t("send_invite_link")}</label>
<label className="text-sm text-gray-400 dark:text-gray-100" htmlFor="">
{t("send_invite_link")}
</label>
<div className="relative flex items-center gap-2">
<Input readOnly className="!pr-[50px]" placeholder="Generating" value={link} />
<button className="absolute right-1 top-1/2 -translate-y-1/2 pr-2 text-sm text-primary-400 hover:text-primary-600" onClick={copyLink}>
<button
className="absolute right-1 top-1/2 -translate-y-1/2 pr-2 text-sm text-primary-400 hover:text-primary-600"
onClick={copyLink}
>
{ct("action.copy")}
</button>
</div>
</div>
<div className="w-44 h-44 my-2">
{!generating && <QRCode size={1200} link={link} />}
</div>
<div className="w-44 h-44 my-2">{!generating && <QRCode size={1200} link={link} />}</div>
<div className="text-xs text-gray-600 dark:text-gray-200">
{t("invite_link_expire")}
<button disabled={generating} className="text-primary-400 ml-1" onClick={() => generateNewLink()}>
<button
disabled={generating}
className="text-primary-400 ml-1"
onClick={() => generateNewLink()}
>
{t("generate_new_link")}
</button>
</div>
+5 -5
View File
@@ -1,11 +1,11 @@
import InviteByEmail from "./InviteByEmail";
import AddMembers from "./AddMembers";
import CloseIcon from "@/assets/icons/close.svg";
import Modal from "../Modal";
import { useAppSelector } from "@/app/store";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "@/app/store";
import CloseIcon from "@/assets/icons/close.svg";
import Modal from "../Modal";
import AddMembers from "./AddMembers";
import InviteByEmail from "./InviteByEmail";
interface Props {
type?: "server" | "channel";
@@ -1,11 +1,12 @@
import { useEffect, FC } from "react";
import { FC, useEffect } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import useLeaveChannel from "@/hooks/useLeaveChannel";
import Modal from "../Modal";
import StyledModal from "../styled/Modal";
import Button from "../styled/Button";
import { useTranslation } from "react-i18next";
import StyledModal from "../styled/Modal";
interface Props {
id: number;
@@ -31,11 +32,7 @@ const LeaveConfirmModal: FC<Props> = ({ id, closeModal, handleNextStep }) => {
<StyledModal
className="compact"
title={t("channel.leave")}
description={
isOwner
? t("channel.transfer_desc")
: t("channel.leave_desc")
}
description={isOwner ? t("channel.transfer_desc") : t("channel.leave_desc")}
buttons={
<>
<Button onClick={closeModal.bind(null, undefined)} className="cancel">
@@ -1,13 +1,15 @@
import { useEffect, useState, FC } from "react";
import { FC, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import Modal from "../Modal";
import useLeaveChannel from "@/hooks/useLeaveChannel";
import StyledModal from "../styled/Modal";
import Button from "../styled/Button";
import User from "../User";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import clsx from "clsx";
import useLeaveChannel from "@/hooks/useLeaveChannel";
import Modal from "../Modal";
import Button from "../styled/Button";
import StyledModal from "../styled/Modal";
import User from "../User";
interface Props {
id: number;
closeModal: () => void;
@@ -72,7 +74,10 @@ const TransferOwnerModal: FC<Props> = ({ id, closeModal, withLeave = true }) =>
return (
<li
key={id}
className={clsx(`cursor-pointer flex items-center px-2 md:hover:bg-gray-500/10`, uid == id ? "bg-gray-500/10" : "")}
className={clsx(
`cursor-pointer flex items-center px-2 md:hover:bg-gray-500/10`,
uid == id ? "bg-gray-500/10" : ""
)}
onClick={handleSelectUser.bind(null, id)}
>
<User uid={id} interactive={false} />
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState, FC } from "react";
import TransferOwnerModal from "./TransferOwnerModal";
import { FC, useState } from "react";
import LeaveConfirmModal from "./LeaveConfirmModal";
import TransferOwnerModal from "./TransferOwnerModal";
interface Props {
id: number;
+64 -52
View File
@@ -1,59 +1,71 @@
// import { MouseEvent, useState } from 'react';
// import { useTranslation } from 'react-i18next';
import Linkify from "linkify-react";
import 'linkify-plugin-mention';
import URLPreview from './Message/URLPreview';
import Mention from './Message/Mention';
import "linkify-plugin-mention";
import Mention from "./Message/Mention";
import URLPreview from "./Message/URLPreview";
type Props = {
url?: boolean,
mention?: boolean,
mentionTextOnly?: boolean,
mentionPopOver?: boolean,
text: string,
cid?: number
}
const LinkifyText = ({ url = true, mention = true, mentionTextOnly = false, mentionPopOver = true, text, cid }: Props) => {
// const { t } = useTranslation();
// const
return (
<Linkify options={
{
render: {
email: ({ content, attributes: { href: link } }) => {
if (mentionTextOnly) return <>
{content}
</>;
return <a className="link" href={link} rel="noreferrer">
{content}
</a>;
},
url: ({ content, attributes: { href: link } }) => {
// console.log("attr", link);
if (!url) return <>{content}</>;
return <>
<a className="link" target="_blank" href={link} rel="noreferrer">
{content}
</a>
<URLPreview url={link} />
</>;
},
mention: ({ content }) => {
if (!mention) return <>{content}</>;
// console.log();
if (/@[0-9]+/.test(content)) {
const uid = content.trim().slice(1);
return <Mention uid={+uid} cid={cid} popover={mentionPopOver} textOnly={mentionTextOnly} />;
}
return <>{content}</>;
}
}
}
}>
{text}
</Linkify>
);
url?: boolean;
mention?: boolean;
mentionTextOnly?: boolean;
mentionPopOver?: boolean;
text: string;
cid?: number;
};
export default LinkifyText;
const LinkifyText = ({
url = true,
mention = true,
mentionTextOnly = false,
mentionPopOver = true,
text,
cid
}: Props) => {
// const { t } = useTranslation();
// const
return (
<Linkify
options={{
render: {
email: ({ content, attributes: { href: link } }) => {
if (mentionTextOnly) return <>{content}</>;
return (
<a className="link" href={link} rel="noreferrer">
{content}
</a>
);
},
url: ({ content, attributes: { href: link } }) => {
// console.log("attr", link);
if (!url) return <>{content}</>;
return (
<>
<a className="link" target="_blank" href={link} rel="noreferrer">
{content}
</a>
<URLPreview url={link} />
</>
);
},
mention: ({ content }) => {
if (!mention) return <>{content}</>;
// console.log();
if (/@[0-9]+/.test(content)) {
const uid = content.trim().slice(1);
return (
<Mention uid={+uid} cid={cid} popover={mentionPopOver} textOnly={mentionTextOnly} />
);
}
return <>{content}</>;
}
}
}}
>
{text}
</Linkify>
);
};
export default LinkifyText;
+14 -6
View File
@@ -1,8 +1,9 @@
import { FC, useState, useEffect, memo } from "react";
import clsx from "clsx";
import { FC, memo, useEffect, useState } from "react";
import { Ring } from "@uiball/loaders";
import Button from "./styled/Button";
import clsx from "clsx";
import useLogout from "@/hooks/useLogout";
import Button from "./styled/Button";
interface Props {
reload?: boolean;
@@ -22,7 +23,6 @@ const Loading: FC<Props> = ({ reload = false, fullscreen = false }) => {
inter = window.setTimeout(() => {
setReloadVisible(true);
}, 30 * 1000);
}
return () => {
clearTimeout(inter);
@@ -30,9 +30,17 @@ const Loading: FC<Props> = ({ reload = false, fullscreen = false }) => {
}, [reload]);
return (
<div className={clsx("w-full h-full flex-center flex-col gap-4 dark:bg-gray-800/80", fullscreen ? "w-screen h-screen" : "")}>
<div
className={clsx(
"w-full h-full flex-center flex-col gap-4 dark:bg-gray-800/80",
fullscreen ? "w-screen h-screen" : ""
)}
>
<Ring size={40} lineWeight={5} speed={2} color="black" />
<Button className={clsx(`danger`, reloadVisible ? "visible" : "invisible")} onClick={handleReload}>
<Button
className={clsx(`danger`, reloadVisible ? "visible" : "invisible")}
onClick={handleReload}
>
Reload
</Button>
</div>
+156 -147
View File
@@ -1,161 +1,170 @@
import { useEffect, FC, useRef } from "react";
import { FC, useEffect, useRef } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { ViewportList } from "react-viewport-list";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
import toast from "react-hot-toast";
import { ViewportList } from 'react-viewport-list';
import { useTranslation } from "react-i18next";
import { useUpdateUserMutation } from "@/app/services/user";
import User from "../User";
import IconMore from "@/assets/icons/more.svg";
import IconOwner from "@/assets/icons/owner.svg";
import { useAppSelector } from "@/app/store";
import useUserOperation from "@/hooks/useUserOperation";
import IconArrowDown from "@/assets/icons/arrow.down.mini.svg";
import IconCheck from "@/assets/icons/check.sign.svg";
import useUserOperation from "@/hooks/useUserOperation";
import { useAppSelector } from "@/app/store";
import IconMore from "@/assets/icons/more.svg";
import IconOwner from "@/assets/icons/owner.svg";
import User from "../User";
interface Props {
cid?: number;
cid?: number;
}
const MemberList: FC<Props> = ({ cid }) => {
const ref = useRef<HTMLUListElement | null>(
null,
);
const { t } = useTranslation("member");
const { t: ct } = useTranslation();
const { users, channels, loginUser } = useAppSelector((store) => {
return {
users: store.users,
channels: store.channels,
loginUser: store.authData.user
};
});
const { copyEmail, removeFromChannel, removeUser } = useUserOperation({ cid });
const [updateUser, { isSuccess: updateSuccess }] = useUpdateUserMutation();
useEffect(() => {
if (updateSuccess) {
toast.success(ct("tip.update"));
}
}, [updateSuccess]);
const handleToggleRole = ({
ignore = false,
uid,
isAdmin = true
}: {
ignore: boolean;
uid: number;
isAdmin: boolean;
}) => {
hideAll();
if (ignore) return;
updateUser({ id: uid, is_admin: isAdmin });
const ref = useRef<HTMLUListElement | null>(null);
const { t } = useTranslation("member");
const { t: ct } = useTranslation();
const { users, channels, loginUser } = useAppSelector((store) => {
return {
users: store.users,
channels: store.channels,
loginUser: store.authData.user
};
});
const { copyEmail, removeFromChannel, removeUser } = useUserOperation({ cid });
const [updateUser, { isSuccess: updateSuccess }] = useUpdateUserMutation();
const channel = cid ? channels.byId[cid] : null;
const uids = channel ? (channel.is_public ? users.ids : channel.members) : users.ids;
return <ul className="flex flex-col gap-1 w-full md:w-[512px] mb-44 max-h-[800px] overflow-y-scroll" ref={ref}>
<ViewportList
initialPrerender={15}
viewportRef={ref}
items={uids}
>
{(uid) => {
const currUser = users.byId[uid];
if (!currUser) return null;
const { name, email, is_admin } = currUser;
const owner = channel && channel.owner == uid;
const switchRoleVisible = loginUser?.is_admin && loginUser.uid !== uid && uid !== 1;
const dotsVisible = email || loginUser?.is_admin;
const canRemove = loginUser?.is_admin && loginUser?.uid != uid && uid !== 1;
const canRemoveFromChannel =
channel && channel.owner == loginUser?.uid && loginUser?.uid != uid;
return (
<li key={uid} className="w-full flex items-center justify-between px-3 py-2 rounded-md md:hover:bg-slate-50 md:dark:hover:bg-gray-800">
<div className="flex gap-4">
<User compact uid={uid} interactive={false} />
<div className="flex flex-col">
<span className="font-bold text-sm text-gray-600 dark:text-white flex items-center gap-1">
{name} {owner && <IconOwner />}
</span>
<span className="hidden md:block text-xs text-gray-500 dark:text-slate-50">{email}</span>
</div>
</div>
<div className="flex items-center gap-7">
{switchRoleVisible ? <Tippy
interactive
placement="bottom-end"
trigger="click"
content={
<ul className="context-menu">
<li
className="item sb"
onClick={handleToggleRole.bind(null, {
ignore: is_admin,
uid,
isAdmin: true
})}
>
{t("admin")}
{is_admin && <IconCheck className="icon dark:fill-white" />}
</li>
<li
className="item sb"
onClick={handleToggleRole.bind(null, {
ignore: !is_admin,
uid,
isAdmin: false
})}
>
{t("user")}
{!is_admin && <IconCheck className="icon dark:fill-white" />}
</li>
</ul>
}
>
<span className="text-xs text-right text-gray-500 dark:text-slate-100 flex items-center gap-1 cursor-pointer">
{is_admin ? t("admin") : t("user")}
<IconArrowDown className="dark:fill-slate-50" />
</span>
</Tippy> :
<span className="text-xs text-right text-gray-500 dark:text-slate-100 flex items-center gap-1">
{is_admin ? t("admin") : t("user")}</span>}
useEffect(() => {
if (updateSuccess) {
toast.success(ct("tip.update"));
}
}, [updateSuccess]);
{dotsVisible && (
<Tippy
interactive
placement="right-start"
trigger="click"
content={
<ul className="min-w-30 context-menu">
{email && (
<li className="item" onClick={copyEmail.bind(null, email)}>
{ct("action.copy_email")}
</li>
)}
{canRemoveFromChannel && (
<li className="item danger" onClick={removeFromChannel.bind(null, uid)}>
{t("remove_from_channel")}
</li>
)}
{canRemove && (
<li className="item danger" onClick={removeUser.bind(null, uid)}>
{ct("action.remove")}
</li>
)}
</ul>
}
>
<div className="relative w-6 h-6">
<IconMore role="button" className="dark:fill-gray-400" />
</div>
</Tippy>
)}
</div>
</li>
);
}}
</ViewportList>
</ul>;
const handleToggleRole = ({
ignore = false,
uid,
isAdmin = true
}: {
ignore: boolean;
uid: number;
isAdmin: boolean;
}) => {
hideAll();
if (ignore) return;
updateUser({ id: uid, is_admin: isAdmin });
};
const channel = cid ? channels.byId[cid] : null;
const uids = channel ? (channel.is_public ? users.ids : channel.members) : users.ids;
return (
<ul
className="flex flex-col gap-1 w-full md:w-[512px] mb-44 max-h-[800px] overflow-y-scroll"
ref={ref}
>
<ViewportList initialPrerender={15} viewportRef={ref} items={uids}>
{(uid) => {
const currUser = users.byId[uid];
if (!currUser) return null;
const { name, email, is_admin } = currUser;
const owner = channel && channel.owner == uid;
const switchRoleVisible = loginUser?.is_admin && loginUser.uid !== uid && uid !== 1;
const dotsVisible = email || loginUser?.is_admin;
const canRemove = loginUser?.is_admin && loginUser?.uid != uid && uid !== 1;
const canRemoveFromChannel =
channel && channel.owner == loginUser?.uid && loginUser?.uid != uid;
return (
<li
key={uid}
className="w-full flex items-center justify-between px-3 py-2 rounded-md md:hover:bg-slate-50 md:dark:hover:bg-gray-800"
>
<div className="flex gap-4">
<User compact uid={uid} interactive={false} />
<div className="flex flex-col">
<span className="font-bold text-sm text-gray-600 dark:text-white flex items-center gap-1">
{name} {owner && <IconOwner />}
</span>
<span className="hidden md:block text-xs text-gray-500 dark:text-slate-50">
{email}
</span>
</div>
</div>
<div className="flex items-center gap-7">
{switchRoleVisible ? (
<Tippy
interactive
placement="bottom-end"
trigger="click"
content={
<ul className="context-menu">
<li
className="item sb"
onClick={handleToggleRole.bind(null, {
ignore: is_admin,
uid,
isAdmin: true
})}
>
{t("admin")}
{is_admin && <IconCheck className="icon dark:fill-white" />}
</li>
<li
className="item sb"
onClick={handleToggleRole.bind(null, {
ignore: !is_admin,
uid,
isAdmin: false
})}
>
{t("user")}
{!is_admin && <IconCheck className="icon dark:fill-white" />}
</li>
</ul>
}
>
<span className="text-xs text-right text-gray-500 dark:text-slate-100 flex items-center gap-1 cursor-pointer">
{is_admin ? t("admin") : t("user")}
<IconArrowDown className="dark:fill-slate-50" />
</span>
</Tippy>
) : (
<span className="text-xs text-right text-gray-500 dark:text-slate-100 flex items-center gap-1">
{is_admin ? t("admin") : t("user")}
</span>
)}
{dotsVisible && (
<Tippy
interactive
placement="right-start"
trigger="click"
content={
<ul className="min-w-30 context-menu">
{email && (
<li className="item" onClick={copyEmail.bind(null, email)}>
{ct("action.copy_email")}
</li>
)}
{canRemoveFromChannel && (
<li className="item danger" onClick={removeFromChannel.bind(null, uid)}>
{t("remove_from_channel")}
</li>
)}
{canRemove && (
<li className="item danger" onClick={removeUser.bind(null, uid)}>
{ct("action.remove")}
</li>
)}
</ul>
}
>
<div className="relative w-6 h-6">
<IconMore role="button" className="dark:fill-gray-400" />
</div>
</Tippy>
)}
</div>
</li>
);
}}
</ViewportList>
</ul>
);
};
export default MemberList;
+4 -5
View File
@@ -1,7 +1,8 @@
import { FC } from "react";
import InviteLink from "../InviteLink";
import { useAppSelector } from "@/app/store";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "@/app/store";
import InviteLink from "../InviteLink";
import MemberList from "./MemberList";
interface Props {
@@ -20,9 +21,7 @@ const ManageMembers: FC<Props> = ({ cid }) => {
{loginUser?.is_admin && <InviteLink />}
<div className="flex flex-col mb-10">
<h4 className="font-bold text-gray-700 dark:text-white">{t("manage_members")}</h4>
<p className="text-gray-400 dark:text-gray-100 text-xs">
{t("manage_tip")}
</p>
<p className="text-gray-400 dark:text-gray-100 text-xs">{t("manage_tip")}</p>
</div>
<MemberList cid={cid} />
</section>
+4 -3
View File
@@ -1,9 +1,10 @@
import { FC } from "react";
import Modal from "../Modal";
import IconClose from "@/assets/icons/close.svg";
import Button from "../styled/Button";
import { useTranslation } from "react-i18next";
import IconClose from "@/assets/icons/close.svg";
import Modal from "../Modal";
import Button from "../styled/Button";
interface Props {
handleInstall?: () => void;
closePrompt?: () => void;
+6 -4
View File
@@ -1,8 +1,10 @@
import { useEffect, useState, FC } from "react";
import Prompt from "./Prompt";
import usePWAInstallPrompt from "@/hooks/usePWAInstallPrompt";
import { FC, useEffect, useState } from "react";
import { BeforeInstallPromptEvent } from "@/types/global";
interface IProps { }
import usePWAInstallPrompt from "@/hooks/usePWAInstallPrompt";
import Prompt from "./Prompt";
interface IProps {}
const Manifest: FC<IProps> = () => {
const { setCanceled, prompted, setDeferredPrompt, showPrompt } = usePWAInstallPrompt();
const [popup, setPopup] = useState(false);
+4 -4
View File
@@ -1,14 +1,15 @@
import { useRef, useEffect, FC } from "react";
import { FC, useEffect, useRef } from "react";
import { Editor } from "@toast-ui/react-editor";
import "prismjs/themes/prism.css";
import "@toast-ui/editor/dist/toastui-editor.css";
import '@toast-ui/editor/dist/theme/toastui-editor-dark.css';
import "@toast-ui/editor/dist/theme/toastui-editor-dark.css";
import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css";
import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js";
import useUploadFile from "@/hooks/useUploadFile";
import Button from "../styled/Button";
import { isDarkMode } from "@/utils";
import Button from "../styled/Button";
type Props = {
updateDraft?: (draft: string) => void;
@@ -32,7 +33,6 @@ const MarkdownEditor: FC<Props> = ({
useEffect(() => {
const editor = editorRef?.current;
if (editor) {
const editorInstance = editor.getInstance();
editorInstance.removeHook("addImageBlobHook");
editorInstance.addHook("addImageBlobHook", async (blob, callback) => {
+10 -5
View File
@@ -1,8 +1,10 @@
import { useRef, FC } from "react";
import { FC, useRef } from "react";
import "prismjs/themes/prism.css";
import { Viewer } from "@toast-ui/react-editor";
import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js";
import '@toast-ui/editor/dist/toastui-editor-viewer.css';
import { Viewer } from "@toast-ui/react-editor";
import "@toast-ui/editor/dist/toastui-editor-viewer.css";
// import '@toast-ui/editor/dist/theme/toastui-editor-dark.css';
// import '@toast-ui/editor/dist/toastui-editor.css';
// import '@toast-ui/editor/dist/theme/toastui-editor-dark.css';
@@ -12,7 +14,6 @@ import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin
import { isDarkMode } from "../utils";
import ImagePreview from "./ImagePreview";
interface IProps {
content: string;
}
@@ -22,7 +23,11 @@ const MarkdownRender: FC<IProps> = ({ content }) => {
<>
<ImagePreview container={mdContainer.current} context="markdown" />
<div ref={mdContainer} id="MARKDOWN_CONTAINER">
<Viewer initialValue={content} plugins={[codeSyntaxHighlight]} theme={isDarkMode() ? "dark" : "light"}></Viewer>
<Viewer
initialValue={content}
plugins={[codeSyntaxHighlight]}
theme={isDarkMode() ? "dark" : "light"}
></Viewer>
</div>
</>
);
+28 -18
View File
@@ -1,27 +1,29 @@
import { useState, useRef, FC } from "react";
import { FC, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { hideAll } from "tippy.js";
import toast from "react-hot-toast";
import { updateSelectMessages } from "@/app/slices/ui";
import ContextMenu, { Item } from "../ContextMenu";
import Tooltip from "../Tooltip";
import { ChatContext } from "@/types/common";
import useFavMessage from "@/hooks/useFavMessage";
import useSendMessage from "@/hooks/useSendMessage";
import ReactionPicker from "./ReactionPicker";
import replyIcon from "@/assets/icons/reply.svg?url";
import reactIcon from "@/assets/icons/reaction.svg?url";
import editIcon from "@/assets/icons/edit.svg?url";
import IconBookmark from "@/assets/icons/bookmark.add.svg";
import IconPin from "@/assets/icons/pin.svg";
import IconForward from "@/assets/icons/forward.svg";
import IconSelect from "@/assets/icons/select.svg";
import IconDelete from "@/assets/icons/delete.svg";
import editIcon from "@/assets/icons/edit.svg?url";
import IconForward from "@/assets/icons/forward.svg";
import moreIcon from "@/assets/icons/more.svg?url";
import IconPin from "@/assets/icons/pin.svg";
import reactIcon from "@/assets/icons/reaction.svg?url";
import replyIcon from "@/assets/icons/reply.svg?url";
import IconSelect from "@/assets/icons/select.svg";
import ContextMenu, { Item } from "../ContextMenu";
import Tooltip from "../Tooltip";
import ReactionPicker from "./ReactionPicker";
import useMessageOperation from "./useMessageOperation";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ChatContext } from "@/types/common";
type Props = {
isSelf: boolean;
context: ChatContext;
@@ -29,7 +31,13 @@ type Props = {
mid: number;
toggleEditMessage: () => void;
};
const Commands: FC<Props> = ({ isSelf, context = "dm", contextId = 0, mid = 0, toggleEditMessage }) => {
const Commands: FC<Props> = ({
isSelf,
context = "dm",
contextId = 0,
mid = 0,
toggleEditMessage
}) => {
const { t } = useTranslation();
const {
canDelete,
@@ -89,10 +97,12 @@ const Commands: FC<Props> = ({ isSelf, context = "dm", contextId = 0, mid = 0, t
<>
<ul
ref={cmdsRef}
className={clsx(`bg-white dark:bg-gray-900 rounded-md z-[999] absolute top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`,
tippyVisible && '!visible',
className={clsx(
`bg-white dark:bg-gray-900 rounded-md z-[999] absolute top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`,
tippyVisible && "!visible",
isSelf ? "left-2.5" : "right-2.5"
)}>
)}
>
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
+10 -8
View File
@@ -1,19 +1,21 @@
import { FC, ReactElement } from "react";
import Tippy from "@tippyjs/react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import ContextMenu, { Item } from "../ContextMenu";
import Tippy from "@tippyjs/react";
import { updateSelectMessages } from "@/app/slices/ui";
import { ChatContext } from "@/types/common";
import useSendMessage from "@/hooks/useSendMessage";
import IconCopy from "@/assets/icons/copy.svg";
import IconDelete from "@/assets/icons/delete.svg";
import IconEdit from "@/assets/icons/edit.svg";
import IconReply from "@/assets/icons/reply.svg";
import IconForward from "@/assets/icons/forward.svg";
import IconPin from "@/assets/icons/pin.svg";
import IconCopy from "@/assets/icons/copy.svg";
import IconReply from "@/assets/icons/reply.svg";
import IconSelect from "@/assets/icons/select.svg";
import { updateSelectMessages } from "@/app/slices/ui";
import useSendMessage from "@/hooks/useSendMessage";
import ContextMenu, { Item } from "../ContextMenu";
import useMessageOperation from "./useMessageOperation";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
type Props = {
context: ChatContext;
contextId: number;
+11 -5
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect, ChangeEvent, KeyboardEvent, FC } from "react";
import { ChangeEvent, FC, KeyboardEvent, useEffect, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useKey } from "rooks";
import { useEditMessageMutation } from "@/app/services/message";
import { ContentTypes } from "@/app/config";
import { useEditMessageMutation } from "@/app/services/message";
import { useAppSelector } from "@/app/store";
type Props = {
@@ -56,7 +57,6 @@ const EditMessage: FC<Props> = ({ mid, cancelEdit }) => {
};
if (!msg) return null;
return (
<div className="w-full">
<div className="bg-gray-200 rounded-lg p-4">
@@ -80,10 +80,16 @@ const EditMessage: FC<Props> = ({ mid, cancelEdit }) => {
</div>
<div className="flex items-center p-1 gap-4 text-xs">
<span>
esc to <button className="text-primary-500 cursor-pointer px-1" onClick={cancelEdit}>cancel</button>
esc to{" "}
<button className="text-primary-500 cursor-pointer px-1" onClick={cancelEdit}>
cancel
</button>
</span>
<span>
enter to <button className="text-primary-500 cursor-pointer px-1" onClick={handleSave}>{isEditing ? "saving" : `save`}</button>
enter to{" "}
<button className="text-primary-500 cursor-pointer px-1" onClick={handleSave}>
{isEditing ? "saving" : `save`}
</button>
</span>
</div>
</div>
+75 -75
View File
@@ -1,83 +1,83 @@
import dayjs from 'dayjs';
import { FC, useEffect, useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { removeMessage } from '../../app/slices/message';
import { removeChannelMsg } from '../../app/slices/message.channel';
import { removeUserMsg } from '../../app/slices/message.user';
import IconTimer from '@/assets/icons/timer.svg';
import { ChatContext } from '../../types/common';
import { FC, useCallback, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import dayjs from "dayjs";
import IconTimer from "@/assets/icons/timer.svg";
import { removeMessage } from "../../app/slices/message";
import { removeChannelMsg } from "../../app/slices/message.channel";
import { removeUserMsg } from "../../app/slices/message.user";
import { ChatContext } from "../../types/common";
type Props = {
context: ChatContext,
contextId: number,
mid: number;
expiresIn: number;
createAt: number;
context: ChatContext;
contextId: number;
mid: number;
expiresIn: number;
createAt: number;
};
const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }) => {
const [countdown, setCountdown] = useState<number | undefined>();
const dispatch = useDispatch();
const clearMsgFromClient = useCallback(
() => {
if (context == "dm") {
dispatch(removeUserMsg({ mid, id: contextId }));
} else {
dispatch(removeChannelMsg({ mid, id: contextId }));
const [countdown, setCountdown] = useState<number | undefined>();
const dispatch = useDispatch();
const clearMsgFromClient = useCallback(() => {
if (context == "dm") {
dispatch(removeUserMsg({ mid, id: contextId }));
} else {
dispatch(removeChannelMsg({ mid, id: contextId }));
}
dispatch(removeMessage(mid));
}, [context, contextId, mid]);
}
dispatch(removeMessage(mid));
},
[context, contextId, mid],
);
useEffect(() => {
if (expiresIn > 0 && createAt > 0) {
const expire_time = createAt + expiresIn * 1000;
if (dayjs().isAfter(new Date(expire_time))) {
// 已过期,立即删除
clearMsgFromClient();
} else {
// 倒计时
setCountdown(Math.floor((expire_time - new Date().getTime()) / 1000));
}
}
}, [expiresIn, createAt]);
useEffect(() => {
let timer = 0;
if (typeof countdown !== "undefined") {
if (countdown > 0) {
timer = window.setTimeout(() => {
setCountdown(prev => {
const _prev = prev ?? 0;
return _prev - 1;
});
}, 1000);
} else {
// 倒计时结束
console.log("countdown over", mid, countdown);
clearMsgFromClient();
}
}
return () => {
if (typeof countdown !== "undefined") {
clearTimeout(timer);
}
};
}, [countdown, mid]);
if (!countdown) return null;
const duration = dayjs.duration(countdown * 1000);
const day = duration.days() !== 0 ? `${duration.days()} day` : "";
const hours = duration.hours() !== 0 ? `${duration.hours().toString().padStart(2, '0')}:` : "";
const minutes = duration.minutes() !== 0 ? `${duration.minutes().toString().padStart(2, '0')}:` : "";
const formatted_countdown = `${day} ${hours}${minutes}${duration.seconds().toString().padStart(2, '0')}`;
return (
<div className='absolute bottom-1 right-2 text-xs text-gray-400 flex items-center gap-1 font-mono'>
<IconTimer className="w-4 h-4 stroke-slate-400" />
{formatted_countdown}
</div>
);
useEffect(() => {
if (expiresIn > 0 && createAt > 0) {
const expire_time = createAt + expiresIn * 1000;
if (dayjs().isAfter(new Date(expire_time))) {
// 已过期,立即删除
clearMsgFromClient();
} else {
// 倒计时
setCountdown(Math.floor((expire_time - new Date().getTime()) / 1000));
}
}
}, [expiresIn, createAt]);
useEffect(() => {
let timer = 0;
if (typeof countdown !== "undefined") {
if (countdown > 0) {
timer = window.setTimeout(() => {
setCountdown((prev) => {
const _prev = prev ?? 0;
return _prev - 1;
});
}, 1000);
} else {
// 倒计时结束
console.log("countdown over", mid, countdown);
clearMsgFromClient();
}
}
return () => {
if (typeof countdown !== "undefined") {
clearTimeout(timer);
}
};
}, [countdown, mid]);
if (!countdown) return null;
const duration = dayjs.duration(countdown * 1000);
const day = duration.days() !== 0 ? `${duration.days()} day` : "";
const hours = duration.hours() !== 0 ? `${duration.hours().toString().padStart(2, "0")}:` : "";
const minutes =
duration.minutes() !== 0 ? `${duration.minutes().toString().padStart(2, "0")}:` : "";
const formatted_countdown = `${day} ${hours}${minutes}${duration
.seconds()
.toString()
.padStart(2, "0")}`;
return (
<div className="absolute bottom-1 right-2 text-xs text-gray-400 flex items-center gap-1 font-mono">
<IconTimer className="w-4 h-4 stroke-slate-400" />
{formatted_countdown}
</div>
);
};
export default ExpireTimer;
+21 -6
View File
@@ -1,7 +1,8 @@
import { FC, ReactElement, useEffect, useState } from "react";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import useFavMessage from "@/hooks/useFavMessage";
import Avatar from "../Avatar";
import renderContent from "./renderContent";
type Props = {
id?: string;
@@ -17,20 +18,34 @@ const FavoredMessage: FC<Props> = ({ id = "" }) => {
const favorite_mids = messages.map(({ from_mid }) => +from_mid) || [];
setMsgs(
<div data-favorite-mids={favorite_mids.join(",")} className="favorite flex flex-col rounded-md bg-slate-50 dark:bg-slate-800">
<div
data-favorite-mids={favorite_mids.join(",")}
className="favorite flex flex-col rounded-md bg-slate-50 dark:bg-slate-800"
>
<div className="list">
{messages.map((msg, idx) => {
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
return (
<div className="w-full relative flex items-start gap-3 px-2 py-1 my-2 rounded-lg md:dark:hover:bg-gray-800" key={idx}>
<div
className="w-full relative flex items-start gap-3 px-2 py-1 my-2 rounded-lg md:dark:hover:bg-gray-800"
key={idx}
>
{user && (
<div className="shrink-0 w-10 h-10 flex">
<Avatar width={40} height={40} className="rounded-full object-cover" src={user.avatar} name={user.name} />
<Avatar
width={40}
height={40}
className="rounded-full object-cover"
src={user.avatar}
name={user.name}
/>
</div>
)}
<div className="w-full flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-600 dark:text-gray-400">{user?.name || "Deleted User"}</span>
<span className="text-gray-600 dark:text-gray-400">
{user?.name || "Deleted User"}
</span>
</div>
<div className="select-text text-gray-800 break-all whitespace-pre-wrap dark:text-white">
{renderContent({
+14 -7
View File
@@ -1,10 +1,11 @@
import { useEffect, useState, FC, ReactElement } from "react";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import IconForward from "@/assets/icons/forward.svg";
import useNormalizeMessage from "@/hooks/useNormalizeMessage";
import { FC, ReactElement, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
import useNormalizeMessage from "@/hooks/useNormalizeMessage";
import IconForward from "@/assets/icons/forward.svg";
import Avatar from "../Avatar";
import renderContent from "./renderContent";
type Props = {
context: ChatContext;
@@ -22,7 +23,10 @@ const ForwardedMessage: FC<Props> = ({ context, to, from_uid, id }) => {
const forward_mids = messages.map(({ from_mid }) => from_mid) || [];
// console.log("fff", messages);
setForwards(
<div data-forwarded-mids={forward_mids.join(",")} className="flex flex-col text-left rounded-lg bg-gray-200 dark:bg-gray-900">
<div
data-forwarded-mids={forward_mids.join(",")}
className="flex flex-col text-left rounded-lg bg-gray-200 dark:bg-gray-900"
>
<h4 className="p-2 pb-0 flex items-center gap-1 text-gray-500 text-xs">
<IconForward className="w-4 h-4 fill-gray-500" />
{t("action.forward")}
@@ -31,7 +35,10 @@ const ForwardedMessage: FC<Props> = ({ context, to, from_uid, id }) => {
{messages.map((msg, idx) => {
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
return (
<div className="w-full relative flex items-start gap-4 px-2 py-1 my-2 rounded-lg" key={idx}>
<div
className="w-full relative flex items-start gap-4 px-2 py-1 my-2 rounded-lg"
key={idx}
>
{user && (
<div className="w-6 h-6 rounded-full flex shrink-0 overflow-hidden">
<Avatar width={24} height={24} src={user.avatar} name={user.name} />
+2 -1
View File
@@ -1,7 +1,8 @@
// import { FC, ReactNode } from "react";
import Tippy from "@tippyjs/react";
import Profile from "../Profile";
import { useAppSelector } from "@/app/store";
import Profile from "../Profile";
interface Props {
uid: number;
+6 -7
View File
@@ -1,13 +1,12 @@
import { FC } from "react";
import { useEffect } from "react";
import { FC, useEffect } from "react";
import toast from "react-hot-toast";
import usePinMessage from "@/hooks/usePinMessage";
import StyledModal from "../styled/Modal";
import Button from "../styled/Button";
import Modal from "../Modal";
import PreviewMessage from "./PreviewMessage";
import { useTranslation } from "react-i18next";
import usePinMessage from "@/hooks/usePinMessage";
import Modal from "../Modal";
import Button from "../styled/Button";
import StyledModal from "../styled/Modal";
import PreviewMessage from "./PreviewMessage";
interface Props {
closeModal: () => void;
+24 -6
View File
@@ -1,12 +1,13 @@
import { FC } from "react";
import clsx from "clsx";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import { useAppSelector } from "@/app/store";
import Avatar from "../Avatar";
import renderContent from "./renderContent";
interface Props {
mid?: number;
context?: "forward" | "pin"
context?: "forward" | "pin";
}
const PreviewMessage: FC<Props> = ({ mid = 0, context = "forward" }) => {
@@ -19,15 +20,32 @@ const PreviewMessage: FC<Props> = ({ mid = 0, context = "forward" }) => {
const pinMsg = context == "pin";
const forwardMsg = context == "forward";
return (
<div className={clsx(`w-full relative flex items-start gap-3 p-2 my-2 rounded-lg`, pinMsg && "max-h-64 overflow-auto overflow-x-hidden border border-solid border-gray-200 dark:border-gray-400")}>
<div
className={clsx(
`w-full relative flex items-start gap-3 p-2 my-2 rounded-lg`,
pinMsg &&
"max-h-64 overflow-auto overflow-x-hidden border border-solid border-gray-200 dark:border-gray-400"
)}
>
<div className="w-10 h-10 flex shrink-0">
<Avatar width={40} height={40} className="rounded-full object-cover" src={avatar} name={name} />
<Avatar
width={40}
height={40}
className="rounded-full object-cover"
src={avatar}
name={name}
/>
</div>
<div className="w-full flex flex-col items-start">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-500 text-sm">{name}</span>
</div>
<div className={clsx(`select-text text-gray-600 text-sm break-all whitespace-pre-wrap dark:text-white`, forwardMsg && "max-h-72 overflow-y-scroll")}>
<div
className={clsx(
`select-text text-gray-600 text-sm break-all whitespace-pre-wrap dark:text-white`,
forwardMsg && "max-h-72 overflow-y-scroll"
)}
>
{renderContent({
content_type,
content,
+18 -10
View File
@@ -1,13 +1,13 @@
import { FC } from "react";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
import ReactionItem, { Emojis, ReactionMap } from "../ReactionItem";
import ReactionPicker from "./ReactionPicker";
import Tooltip from "../Tooltip";
import { useReactMessageMutation } from "@/app/services/message";
import IconAddEmoji from "@/assets/icons/add.emoji.svg";
import { useAppSelector } from "@/app/store";
import { useReactMessageMutation } from "@/app/services/message";
import { useAppSelector } from "@/app/store";
import IconAddEmoji from "@/assets/icons/add.emoji.svg";
import ReactionItem, { Emojis, ReactionMap } from "../ReactionItem";
import Tooltip from "../Tooltip";
import ReactionPicker from "./ReactionPicker";
const ReactionDetails = ({
uids = [],
@@ -28,7 +28,11 @@ const ReactionDetails = ({
? `${names.join(", ")} and ${names.length - 3} others reacted with`
: `${names.join(", ")} reacted with`;
return (
<div className={`relative bg-white rounded-lg shadow flex items-start gap-2 p-2 ${index == 0 ? "first" : ""}`}>
<div
className={`relative bg-white rounded-lg shadow flex items-start gap-2 p-2 ${
index == 0 ? "first" : ""
}`}
>
<div className="w-8 h-8">
<ReactionItem native={emoji} />
</div>
@@ -65,7 +69,9 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
return uids.length > 0 ? (
<span
onClick={readOnly ? undefined : handleReact.bind(null, reaction)}
className={`cursor-pointer rounded-md relative flex items-center gap-1 p-1 md:hover:bg-cyan-100 ${reacted ? "shadow-[inset_0_0_0_1px_#06aed4] bg-cyan-200" : ""}`}
className={`cursor-pointer rounded-md relative flex items-center gap-1 p-1 md:hover:bg-cyan-100 ${
reacted ? "shadow-[inset_0_0_0_1px_#06aed4] bg-cyan-200" : ""
}`}
key={reaction}
>
<Tippy
@@ -81,7 +87,9 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
</i>
</Tippy>
{uids.length > 1 ? <i className="text-primary-600 text-xs not-italic">{`${uids.length}`} </i> : null}
{uids.length > 1 ? (
<i className="text-primary-600 text-xs not-italic">{`${uids.length}`} </i>
) : null}
</span>
) : null;
})}
@@ -94,7 +102,7 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
>
<button className="invisible group-hover:visible w-6 h-6 bg-cyan-100 md:hover:bg-cyan-200 rounded-md flex-center">
<IconAddEmoji className={'w-4 h-4'} />
<IconAddEmoji className={"w-4 h-4"} />
</button>
</Tippy>
</Tooltip>
+11 -5
View File
@@ -1,8 +1,9 @@
import { FC } from "react";
import { useReactMessageMutation } from "@/app/services/message";
import { Emojis } from "@/app/config";
import Emoji from "../ReactionItem";
import { useReactMessageMutation } from "@/app/services/message";
import { useAppSelector } from "@/app/store";
import Emoji from "../ReactionItem";
type Props = {
mid: number;
@@ -25,17 +26,22 @@ const ReactionPicker: FC<Props> = ({ mid, hidePicker }) => {
if (el) {
el.scrollIntoViewIfNeeded(false);
}
};
return (
<div className="z-[999]">
<ul className={`p-1 grid grid-cols-[repeat(4,_1fr)] gap-2 bg-white dark:bg-gray-900 drop-shadow-md rounded-xl ${isLoading ? "opacity-60" : ""}`}>
<ul
className={`p-1 grid grid-cols-[repeat(4,_1fr)] gap-2 bg-white dark:bg-gray-900 drop-shadow-md rounded-xl ${
isLoading ? "opacity-60" : ""
}`}
>
{Emojis.map((emoji) => {
let reacted =
reactionData[emoji] && reactionData[emoji].findIndex((id) => id == currUid) > -1;
return (
<li
className={`flex-center cursor-pointer rounded-lg p-4 md:hover:bg-gray-50 w-4 h-4 ${reacted ? "bg-gray-50" : ""}`}
className={`flex-center cursor-pointer rounded-lg p-4 md:hover:bg-gray-50 w-4 h-4 ${
reacted ? "bg-gray-50" : ""
}`}
key={emoji}
onClick={handleReact.bind(null, emoji)}
>
+24 -23
View File
@@ -1,31 +1,33 @@
import React, { MouseEvent, FC } from "react";
import clsx from "clsx";
import MarkdownRender from "../MarkdownRender";
import { ContentTypes } from "@/app/config";
import { getFileIcon, isImage } from "@/utils";
import LinkifyText from '../LinkifyText';
import Avatar from "../Avatar";
import { useAppSelector } from "@/app/store";
import { MessagePayload } from "@/app/slices/message";
import React, { FC, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ContentTypes } from "@/app/config";
import { MessagePayload } from "@/app/slices/message";
import { useAppSelector } from "@/app/store";
import { getFileIcon, isImage } from "@/utils";
import Avatar from "../Avatar";
import LinkifyText from "../LinkifyText";
import MarkdownRender from "../MarkdownRender";
const renderContent = (data: MessagePayload) => {
const { content_type, content, thumbnail, properties } = data;
let res = null;
switch (content_type) {
case ContentTypes.text:
res = (
<span className="max-w-lg md:truncate md:break-words md:break-all text-gray-800 dark:text-gray-100">
<LinkifyText text={content as string} url={false} mentionTextOnly={true} mentionPopOver={false} />
<LinkifyText
text={content as string}
url={false}
mentionTextOnly={true}
mentionPopOver={false}
/>
</span>
);
break;
case ContentTypes.audio:
res = (
<span className=" text-primary-400 text-sm">[Voice Message]</span>
);
res = <span className=" text-primary-400 text-sm">[Voice Message]</span>;
break;
case ContentTypes.markdown:
res = (
@@ -83,11 +85,12 @@ const Reply: FC<ReplyProps> = ({ mid, interactive = true }) => {
}
};
const defaultClass = `w-fit flex items-start flex-col md:flex-row p-2 bg-gray-200 dark:bg-gray-900 rounded-lg gap-2 mb-1`;
if (!data) return <div
key={mid}
data-mid={mid}
className={clsx(defaultClass, 'italic')}
>{t("reply_msg_del")}</div>;
if (!data)
return (
<div key={mid} data-mid={mid} className={clsx(defaultClass, "italic")}>
{t("reply_msg_del")}
</div>
);
const currUser = users[data.from_uid || 0];
if (!currUser) return null;
@@ -95,9 +98,7 @@ const Reply: FC<ReplyProps> = ({ mid, interactive = true }) => {
<div
key={mid}
data-mid={mid}
className={clsx(defaultClass,
interactive ? "cursor-pointer" : "!bg-transparent"
)}
className={clsx(defaultClass, interactive ? "cursor-pointer" : "!bg-transparent")}
onClick={interactive ? handleClick : undefined}
>
<div className="md:flex shrink-0 w-6 h-6 hidden ">
+16 -8
View File
@@ -1,15 +1,15 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { useLazyGetOGInfoQuery } from "@/app/services/message";
import { upsertOG } from "@/app/slices/footprint";
import { useAppSelector } from "@/app/store";
export default function URLPreview({ url = "" }) {
const dispatch = useDispatch();
const [favicon, setFavicon] = useState("");
const [getInfo, { isLoading }] = useLazyGetOGInfoQuery();
const ogData = useAppSelector(store => store.footprint.og[url]);
const ogData = useAppSelector((store) => store.footprint.og[url]);
const [data, setData] = useState<{ title: string; description: string; ogImage: string } | null>(
null
);
@@ -46,18 +46,26 @@ export default function URLPreview({ url = "" }) {
return ogImage ? (
// 简版
<a className={`${containerClass} flex-col !items-start p-3`} href={url} target="_blank" rel="noreferrer">
<a
className={`${containerClass} flex-col !items-start p-3`}
href={url}
target="_blank"
rel="noreferrer"
>
<h3 className={`text-primary-500 w-full truncate`}>{title}</h3>
<p className={`text-xs text-gray-400 mb-2 w-full truncate`}>{description}</p>
<div className="w-full h-[180px]">
<img className="w-full h-full object-cover" onError={handleOGImageError} src={ogImage} alt="og image" />
<img
className="w-full h-full object-cover"
onError={handleOGImageError}
src={ogImage}
alt="og image"
/>
</div>
</a>
) : (
// 带图详情
<a
className={`${containerClass} gap-2 px-2 py-3`}
href={url} target="_blank" rel="noreferrer">
<a className={`${containerClass} gap-2 px-2 py-3`} href={url} target="_blank" rel="noreferrer">
{favicon && (
<div className="flex rounded">
<img className="object-contain w-12 h-12" src={favicon} alt="favicon" />
+63 -43
View File
@@ -1,24 +1,24 @@
import React, { useRef, useState, useEffect, FC } from "react";
import dayjs from "dayjs";
import React, { FC, useEffect, useRef, useState } from "react";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import dayjs from "dayjs";
import useInView from "./useInView";
import Reaction from "./Reaction";
import Reply from "./Reply";
import Profile from "../Profile";
import Avatar from "../Avatar";
import Commands from "./Commands";
import EditMessage from "./EditMessage";
import renderContent from "./renderContent";
import Tooltip from "../Tooltip";
import ContextMenu from "./ContextMenu";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import useContextMenu from "@/hooks/useContextMenu";
import usePinMessage from "@/hooks/usePinMessage";
import { useAppSelector } from "@/app/store";
import IconInfo from "@/assets/icons/info.svg";
import Avatar from "../Avatar";
import Profile from "../Profile";
import Tooltip from "../Tooltip";
import Commands from "./Commands";
import ContextMenu from "./ContextMenu";
import EditMessage from "./EditMessage";
import ExpireTimer from "./ExpireTimer";
import IconInfo from '@/assets/icons/info.svg';
import { ChatContext } from "@/types/common";
import Reaction from "./Reaction";
import renderContent from "./renderContent";
import Reply from "./Reply";
import useInView from "./useInView";
interface IProps {
readOnly?: boolean;
@@ -41,15 +41,17 @@ const Message: FC<IProps> = ({
const [edit, setEdit] = useState(false);
const avatarRef = useRef(null);
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
const { message, reactionMessageData, usersData, loginUid, enableRightLayout } = useAppSelector((store) => {
return {
enableRightLayout: store.server.chat_layout_mode == "SelfRight",
reactionMessageData: store.reactionMessage,
message: store.message[mid],
usersData: store.users.byId,
loginUid: store.authData.user?.uid
};
});
const { message, reactionMessageData, usersData, loginUid, enableRightLayout } = useAppSelector(
(store) => {
return {
enableRightLayout: store.server.chat_layout_mode == "SelfRight",
reactionMessageData: store.reactionMessage,
message: store.message[mid],
usersData: store.users.byId,
loginUid: store.authData.user?.uid
};
}
);
const toggleEditMessage = () => {
setEdit((prev) => !prev);
@@ -81,10 +83,8 @@ const Message: FC<IProps> = ({
properties,
expires_in = 0,
failed = false
} = message;
const reactions = reactionMessageData[mid];
const currUser = usersData[fromUid || 0];
// if (!message) return null;
@@ -98,17 +98,17 @@ const Message: FC<IProps> = ({
const showExpire = (expires_in ?? 0) > 0;
const isSelf = fromUid == loginUid && enableRightLayout;
return (
<div
key={_key}
onContextMenu={readOnly ? undefined : handleContextMenuEvent}
data-msg-mid={mid}
ref={inViewRef}
className={clsx(`group w-full relative flex items-start gap-2 md:gap-4 p-1 md:p-2 my-2 rounded-lg md:dark:hover:bg-gray-800 md:hover:bg-gray-100`,
className={clsx(
`group w-full relative flex items-start gap-2 md:gap-4 p-1 md:p-2 my-2 rounded-lg md:dark:hover:bg-gray-800 md:hover:bg-gray-100`,
readOnly && "hover:bg-transparent",
showExpire && "bg-red-200 dark:bg-red-200/40",
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7",
isSelf && "flex-row-reverse",
isSelf && "flex-row-reverse"
)}
>
<Tippy
@@ -121,7 +121,13 @@ const Message: FC<IProps> = ({
content={<Profile uid={fromUid || 0} type="card" cid={context == "dm" ? 0 : contextId} />}
>
<div className="cursor-pointer w-10 h-10 shrink-0" data-uid={fromUid} ref={avatarRef}>
<Avatar className="w-10 h-10 rounded-full object-cover" width={40} height={40} src={currUser?.avatar} name={currUser?.name} />
<Avatar
className="w-10 h-10 rounded-full object-cover"
width={40}
height={40}
src={currUser?.avatar}
name={currUser?.name}
/>
</div>
</Tippy>
<ContextMenu
@@ -133,14 +139,23 @@ const Message: FC<IProps> = ({
hide={hideContextMenu}
>
<div
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative", isSelf && "items-end")}
data-pin-tip={`pinned by ${pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
}`}
className={clsx(
"w-full flex flex-col gap-2",
pinInfo && "relative",
isSelf && "items-end"
)}
data-pin-tip={`pinned by ${
pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
}`}
>
{pinInfo && <span className="absolute left-0 -top-1 -translate-y-full text-xs text-gray-400">
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
</span>}
<div className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}>
{pinInfo && (
<span className="absolute left-0 -top-1 -translate-y-full text-xs text-gray-400">
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
</span>
)}
<div
className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}
>
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
<Tooltip
delay={200}
@@ -154,13 +169,18 @@ const Message: FC<IProps> = ({
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</time>
</Tooltip>
{failed && <span className="text-red-500 text-xs flex items-center gap-1">
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
</span>}
{failed && (
<span className="text-red-500 text-xs flex items-center gap-1">
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
</span>
)}
</div>
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
sending && "opacity-90",
)}>
<div
className={clsx(
`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
sending && "opacity-90"
)}
>
{reply_mid && <Reply key={reply_mid} mid={reply_mid} />}
{edit ? (
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
+12 -8
View File
@@ -1,14 +1,15 @@
// import React from "react";
import dayjs from "dayjs";
import i18n from "@/i18n";
import dayjs from "dayjs";
import { ContentTypes } from "@/app/config";
import ForwardedMessage from "./ForwardedMessage";
import MarkdownRender from "../MarkdownRender";
import FileMessage from "../FileMessage";
import { ContentType } from "@/types/message";
import LinkifyText from '../LinkifyText';
import VoiceMessage from "../VoiceMessage";
import { ChatContext } from "@/types/common";
import { ContentType } from "@/types/message";
import FileMessage from "../FileMessage";
import LinkifyText from "../LinkifyText";
import MarkdownRender from "../MarkdownRender";
import VoiceMessage from "../VoiceMessage";
import ForwardedMessage from "./ForwardedMessage";
type Props = {
context: ChatContext;
@@ -41,7 +42,10 @@ const renderContent = ({
<>
<LinkifyText text={content} cid={to} />
{edited && (
<span className="ml-1 text-gray-500 text-[10px]" title={dayjs(+edited).format("YYYY-MM-DD h:mm:ss A")}>
<span
className="ml-1 text-gray-500 text-[10px]"
title={dayjs(+edited).format("YYYY-MM-DD h:mm:ss A")}
>
({i18n.t("edited", { ns: "chat" })})
</span>
)}
+11 -7
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { ContentTypes } from "@/app/config";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import useCopy from "@/hooks/useCopy";
import usePinMessage from "@/hooks/usePinMessage";
import DeleteMessageConfirm from "../DeleteMessageConfirm";
import ForwardModal from "../ForwardModal";
import PinMessageModal from "./PinMessageModal";
import { ContentTypes } from "@/app/config";
import useCopy from "@/hooks/useCopy";
import usePinMessage from "@/hooks/usePinMessage";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
interface Params {
mid: number;
@@ -74,7 +75,10 @@ export default function useMessageOperation({ mid, context, contextId }: Params)
properties?.content_type.startsWith("image");
const enableEdit =
loginUser?.uid == from_uid && [ContentTypes.text, ContentTypes.markdown].includes(content_type);
const canDelete = loginUser?.uid == from_uid || loginUser?.is_admin || (channel && channel.owner == loginUser?.uid);
const canDelete =
loginUser?.uid == from_uid ||
loginUser?.is_admin ||
(channel && channel.owner == loginUser?.uid);
const canCopy = [ContentTypes.text, ContentTypes.markdown].includes(content_type) || isImage;
return {
copyContent: isImage ? copyContent.bind(null, true) : copyContent.bind(null, false),
+2
View File
@@ -1,7 +1,9 @@
import { FC } from "react";
import { Helmet } from "react-helmet";
import BASE_URL from "@/app/config";
import { useGetServerQuery } from "@/app/services/server";
type Props = {};
const Meta: FC<Props> = () => {
const { data, isSuccess } = useGetServerQuery();
+41 -38
View File
@@ -1,29 +1,31 @@
import { useRef, useEffect, ClipboardEvent, FC } from "react";
import { useKey } from "rooks";
import { Editor, Transforms } from "slate";
import { ClipboardEvent, FC, useEffect, useRef } from "react";
import {
createPlateUI,
Plate,
createExitBreakPlugin,
createTrailingBlockPlugin,
createMentionPlugin,
createNodeIdPlugin,
createParagraphPlugin,
createSoftBreakPlugin,
createMentionPlugin,
findMentionInput,
createPlateUI,
createPlugins,
createSoftBreakPlugin,
createTrailingBlockPlugin,
ELEMENT_PARAGRAPH,
findMentionInput,
getPlateEditorRef,
MentionCombobox
MentionCombobox,
Plate
} from "@udecode/plate";
import { createComboboxPlugin } from "@udecode/plate-combobox";
import { useKey } from "rooks";
import { Editor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import useUploadFile from "@/hooks/useUploadFile";
import { CONFIG } from "./config";
import User from "../User";
import { useAppSelector } from "@/app/store";
import { isMobile } from "@/utils";
import { ChatContext } from "@/types/common";
import useUploadFile from "@/hooks/useUploadFile";
import { isMobile } from "@/utils";
import User from "../User";
import { CONFIG } from "./config";
export const TEXT_EDITOR_PREFIX = "_text_editor";
let components = createPlateUI({
@@ -49,7 +51,6 @@ const Plugins: FC<Props> = ({
updateMessages,
members = []
}) => {
// const { getMenuProps, getItemProps } = useComboboxControls();
const [context, to] = id.split("_") as [ChatContext, number];
const { addStageFile } = useUploadFile({ context, id: to });
@@ -99,7 +100,7 @@ const Plugins: FC<Props> = ({
},
{
when: !isMobile(),
target: editableRef,
target: editableRef
}
);
const pluginArr = [
@@ -112,21 +113,21 @@ const Plugins: FC<Props> = ({
const plugins = createPlugins(
enableMentions
? pluginArr.concat([
createComboboxPlugin(),
createMentionPlugin({
options: {
createMentionNode: (item) => {
// console.log("mention", item);
const {
text,
data: { uid }
} = item;
return { value: `@${text}`, uid };
},
insertSpaceAfterMention: true
}
})
])
createComboboxPlugin(),
createMentionPlugin({
options: {
createMentionNode: (item) => {
// console.log("mention", item);
const {
text,
data: { uid }
} = item;
return { value: `@${text}`, uid };
},
insertSpaceAfterMention: true
}
})
])
: pluginArr,
{
components
@@ -168,21 +169,23 @@ const Plugins: FC<Props> = ({
const arr = tmps.map((tmp) => {
return Array.isArray(tmp)
? {
type: "text",
content: tmp.map((t) => t.content).join("\n"),
properties: {
mentions: tmp.map((t) => t.properties?.mentions || []).flat()
type: "text",
content: tmp.map((t) => t.content).join("\n"),
properties: {
mentions: tmp.map((t) => t.properties?.mentions || []).flat()
}
}
}
: tmp;
});
const msgs = arr.filter(({ content }) => !!content.trim());
updateMessages(msgs);
};
return (
<div className="input w-full pr-14 md:pr-0 max-h-[50vh] overflow-auto text-sm text-gray-600 dark:text-white" ref={editableRef}>
<div
className="input w-full pr-14 md:pr-0 max-h-[50vh] overflow-auto text-sm text-gray-600 dark:text-white"
ref={editableRef}
>
<Plate
id={`${TEXT_EDITOR_PREFIX}_${id}`}
onChange={handleChange}
+19 -16
View File
@@ -1,6 +1,7 @@
import { FC, useState, useEffect } from "react";
import { FC, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { KEY_MOBILE_APP_TIP } from "@/app/config";
import Button from "./styled/Button";
@@ -19,23 +20,25 @@ const Container = (props: ContainerProps) => {
localStorage.removeItem(KEY_MOBILE_APP_TIP);
toast.dismiss(id);
};
return <div className="flex flex-col md:flex-row items-center gap-2 whitespace-nowrap">
<div>
<Trans i18nKey={"mobile_app"}>
<strong className="font-bold" />
</Trans>
return (
<div className="flex flex-col md:flex-row items-center gap-2 whitespace-nowrap">
<div>
<Trans i18nKey={"mobile_app"}>
<strong className="font-bold" />
</Trans>
</div>
<div className="flex gap-1">
<Button className="mini main" onClick={handleOpen}>
{t("action.open")}
</Button>
<Button className="mini cancel" onClick={handleClose}>
{t("action.dismiss")}
</Button>
</div>
</div>
<div className="flex gap-1">
<Button className="mini main" onClick={handleOpen}>
{t("action.open")}
</Button>
<Button className="mini cancel" onClick={handleClose}>
{t("action.dismiss")}
</Button>
</div>
</div>;
);
};
interface Props { }
interface Props {}
const Index: FC<Props> = () => {
const [visible, setVisible] = useState(false);
useEffect(() => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { FC, useEffect, useState, PropsWithChildren } from "react";
import { FC, PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom";
interface Props {
+1
View File
@@ -1,6 +1,7 @@
import { FC, useEffect } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { KEY_MOBILE_APP_TIP } from "@/app/config";
import Button from "./styled/Button";
+4 -2
View File
@@ -1,8 +1,10 @@
import { useEffect, memo } from "react";
import { memo, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import useDeviceToken from "./useDeviceToken";
import { vapidKey } from "@/app/config";
import { useUpdateDeviceTokenMutation } from "@/app/services/auth";
import useDeviceToken from "./useDeviceToken";
let updated = false;
let updating = false;
const Notification = () => {
+9 -7
View File
@@ -1,15 +1,15 @@
import dayjs from "dayjs";
import { FC } from "react";
import dayjs from "dayjs";
import renderContent from "./Message/renderContent";
import Avatar from "./Avatar";
import { MessagePayload } from "@/app/slices/message";
import { useAppSelector } from "@/app/store";
import { PinnedMessage } from "@/types/channel";
import { normalizeFileMessage } from "../utils";
import { MessagePayload } from "@/app/slices/message";
import Avatar from "./Avatar";
import renderContent from "./Message/renderContent";
interface Props {
data: PinnedMessage
data: PinnedMessage;
}
const PinnedMessageView: FC<Props> = ({ data }) => {
@@ -32,8 +32,10 @@ const PinnedMessageView: FC<Props> = ({ data }) => {
</div>
<div className="w-full flex flex-col items-start gap-1 text-sm">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-500 dark:text-gray-400">{name}</span>
<time className="text-xs text-gray-400">{dayjs(created_at).format("YYYY-MM-DD h:mm:ss A")}</time>
<span className="text-gray-500">{name}</span>
<time className="text-xs text-gray-400">
{dayjs(created_at).format("YYYY-MM-DD h:mm:ss A")}
</time>
</div>
<div className={`select-text text-gray-600 break-all whitespace-pre-wrap dark:text-white`}>
{renderContent({
+8 -8
View File
@@ -1,15 +1,15 @@
import { useGetAgoraStatusQuery } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
import IconCall from "@/assets/icons/call.svg";
import IconMessage from "@/assets/icons/message.svg";
import IconMore from "@/assets/icons/more.svg";
import useUserOperation from "@/hooks/useUserOperation";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { FC, memo } from "react";
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { useGetAgoraStatusQuery } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
import useUserOperation from "@/hooks/useUserOperation";
import IconCall from "@/assets/icons/call.svg";
import IconMessage from "@/assets/icons/message.svg";
import IconMore from "@/assets/icons/more.svg";
import Avatar from "../Avatar";
interface Props {
+33 -32
View File
@@ -1,36 +1,37 @@
// import React from 'react';
import QR from 'qrcode.react';
import { useAppSelector } from '@/app/store';
import QR from "qrcode.react";
import { useAppSelector } from "@/app/store";
type Props = {
link: string,
size?: number,
level?: "L" | "M" | "H" | "Q"
}
const QRCode = ({ link, size = 512, level = "L" }: Props) => {
const logo = useAppSelector(store => store.server.logo);
return (
<div className="p-2 bg-white dark:bg-slate-200 rounded">
<QR
renderAs='svg'
value={link}
className="rounded border border-solid border-gray-200 dark:border-none !w-full !h-full"
size={size}
bgColor={"#fff"}
fgColor={"#000"}
level={level}
// includeMargin={true}
imageSettings={{
src: logo,
x: undefined,
y: undefined,
height: size / 6,
width: size / 6,
excavate: true,
}} />
</div>
);
link: string;
size?: number;
level?: "L" | "M" | "H" | "Q";
};
export default QRCode;
const QRCode = ({ link, size = 512, level = "L" }: Props) => {
const logo = useAppSelector((store) => store.server.logo);
return (
<div className="p-2 bg-white dark:bg-slate-200 rounded">
<QR
renderAs="svg"
value={link}
className="rounded border border-solid border-gray-200 dark:border-none !w-full !h-full"
size={size}
bgColor={"#fff"}
fgColor={"#000"}
level={level}
// includeMargin={true}
imageSettings={{
src: logo,
x: undefined,
y: undefined,
height: size / 6,
width: size / 6,
excavate: true
}}
/>
</div>
);
};
export default QRCode;
+40 -37
View File
@@ -1,51 +1,54 @@
import { FC, useEffect } from "react";
import toast from "react-hot-toast";
import Modal from "./Modal";
import StyledModal from "./styled/Modal";
import Button from "./styled/Button";
import useLogout from "@/hooks/useLogout";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { updateRoleChanged } from "@/app/slices/auth.data";
import useLogout from "@/hooks/useLogout";
import Modal from "./Modal";
import Button from "./styled/Button";
import StyledModal from "./styled/Modal";
interface Props {
reasonType?: "role_changed",
reasonType?: "role_changed";
}
const ReLoginModal: FC<Props> = ({ reasonType = "role_changed" }) => {
const dispatch = useDispatch();
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
const { logout, exited, exiting } = useLogout();
const handleLogout = () => {
logout();
};
const handleReset = () => {
dispatch(updateRoleChanged(false));
};
useEffect(() => {
if (exited) {
toast.success(ct("tip.logout"));
const dispatch = useDispatch();
const { t } = useTranslation("auth");
const { t: ct } = useTranslation();
const { logout, exited, exiting } = useLogout();
const handleLogout = () => {
logout();
};
const handleReset = () => {
dispatch(updateRoleChanged(false));
};
useEffect(() => {
if (exited) {
toast.success(ct("tip.logout"));
}
}, [exited]);
return (
<Modal id="modal-modal">
<StyledModal
buttons={
<>
<Button className="cancel" onClick={handleReset}>
{t("logout.later")}
</Button>
<Button onClick={handleLogout} className="danger">
{exiting ? "Logging out" : ct("action.re_login")}
</Button>
</>
}
}, [exited]);
return (
<Modal id="modal-modal">
<StyledModal
buttons={
<>
<Button className="cancel" onClick={handleReset}>{t("logout.later")}</Button>
<Button onClick={handleLogout} className="danger">
{exiting ? "Logging out" : ct("action.re_login")}
</Button>
</>
}
>
<div className="text-sm text-gray-400 flex justify-end items-center">
{t(`logout.${reasonType}`)}
</div>
</StyledModal>
</Modal>
);
>
<div className="text-sm text-gray-400 flex justify-end items-center">
{t(`logout.${reasonType}`)}
</div>
</StyledModal>
</Modal>
);
};
export default ReLoginModal;
+7 -6
View File
@@ -1,12 +1,13 @@
import { FC, ReactElement } from "react";
import EmojiThumbUp from "@/assets/icons/emoji.thumb.up.svg";
import EmojiThumbDown from "@/assets/icons/emoji.thumb.down.svg";
import EmojiSmile from "@/assets/icons/emoji.smile.svg";
import EmojiCelebrate from "@/assets/icons/emoji.celebrate.svg";
import EmojiUnhappy from "@/assets/icons/emoji.unhappy.svg";
import EmojiHeart from "@/assets/icons/emoji.heart.svg";
import EmojiRocket from "@/assets/icons/emoji.rocket.svg";
import EmojiLook from "@/assets/icons/emoji.look.svg";
import EmojiRocket from "@/assets/icons/emoji.rocket.svg";
import EmojiSmile from "@/assets/icons/emoji.smile.svg";
import EmojiThumbDown from "@/assets/icons/emoji.thumb.down.svg";
import EmojiThumbUp from "@/assets/icons/emoji.thumb.up.svg";
import EmojiUnhappy from "@/assets/icons/emoji.unhappy.svg";
export interface Emojis {
"👍": ReactElement;
@@ -26,7 +27,7 @@ export const ReactionMap = {
"👎": ":thumb_down:",
"😄": ":smile:",
"👀": ":eyes:",
"🚀": ":rocket:",
"🚀": ":rocket:"
};
const emojis: Emojis = {
"👍": <EmojiThumbUp className="emoji w-full h-full min-w-[16px] min-h-[16px]" />,
+6 -2
View File
@@ -1,5 +1,6 @@
import { FC, ReactElement } from "react";
import { Navigate, useLocation, matchRoutes } from "react-router-dom";
import { matchRoutes, Navigate, useLocation } from "react-router-dom";
import { GuestRoutes, KEY_LOCAL_TRY_PATH } from "@/app/config";
import { useGetInitializedQuery } from "@/app/services/auth";
import { useGetLoginConfigQuery } from "@/app/services/server";
@@ -34,7 +35,10 @@ const RequireAuth: FC<Props> = ({ children, redirectTo = "/login" }) => {
if (!token) {
// 记录下当前的路径,登录后跳转回来
const ignorePath = `/setting/my_account`;
localStorage.setItem(KEY_LOCAL_TRY_PATH, ignorePath == location.pathname ? "/" : `${location.pathname}${location.search}`);
localStorage.setItem(
KEY_LOCAL_TRY_PATH,
ignorePath == location.pathname ? "/" : `${location.pathname}${location.search}`
);
return <Navigate to={redirectTo} replace />;
}
const tryPath = localStorage.getItem(KEY_LOCAL_TRY_PATH);
+1
View File
@@ -1,5 +1,6 @@
import { FC, ReactElement } from "react";
import { Navigate } from "react-router-dom";
import { useGetInitializedQuery } from "@/app/services/auth";
import { useAppSelector } from "@/app/store";
+1
View File
@@ -1,4 +1,5 @@
import { FC, ReactElement } from "react";
import useTabBroadcast from "@/hooks/useTabBroadcast";
import InactiveScreen from "./InactiveScreen";
+12 -7
View File
@@ -1,8 +1,8 @@
// import clsx from "clsx";
import { FC, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import Button from './styled/Button';
import Button from "./styled/Button";
interface Props {
saveHandler: (e: MouseEvent) => void;
@@ -13,16 +13,21 @@ const SaveTip: FC<Props> = ({ saveHandler, resetHandler }) => {
const { t } = useTranslation("setting");
// const btnClass=clsx("")
return (
<div className="z-[999] w-full max-w-lg p-2 fixed bottom-4 md:bottom-16
<div
className="z-[999] w-full max-w-lg p-2 fixed bottom-4 md:bottom-16
flex flex-col md:flex-row items-center justify-between font-semibold text-gray-700 border
border-solid border-gray-200 dark:border-gray-400 bg-white dark:bg-gray-600 shadow-2xl dark:shadow-primary-400/50 rounded-full">
<span className="p-2 text-sm dark:text-gray-200">{t('save_tip')}</span>
border-solid border-gray-200 dark:border-gray-400 bg-white dark:bg-gray-600 shadow-2xl dark:shadow-primary-400/50 rounded-full"
>
<span className="p-2 text-sm dark:text-gray-200">{t("save_tip")}</span>
<div className="flex items-center gap-3">
<Button className="small ghost border_less !text-gray-700 !shadow-none dark:!text-gray-100" onClick={resetHandler}>
{t('reset')}
<Button
className="small ghost border_less !text-gray-700 !shadow-none dark:!text-gray-100"
onClick={resetHandler}
>
{t("reset")}
</Button>
<Button className="small !rounded-full" onClick={saveHandler}>
{t('save_change')}
{t("save_change")}
</Button>
</div>
</div>
+72 -27
View File
@@ -1,18 +1,18 @@
// import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { FC, ChangeEvent, useState, useRef, FormEvent } from "react";
import { ChangeEvent, FC, FormEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import clsx from "clsx";
import BASE_URL from "@/app/config";
import { useSearchUserMutation, useUpdateContactStatusMutation } from "@/app/services/user";
import { useAppSelector } from "@/app/store";
import IconClose from "@/assets/icons/close.svg";
import IconSearch from "@/assets/icons/search.svg";
import { useSearchUserMutation, useUpdateContactStatusMutation } from "@/app/services/user";
import BASE_URL from "@/app/config";
import { useAppSelector } from "@/app/store";
import StyledButton from "./styled/Button";
import Input from "./styled/Input";
import Avatar from "./Avatar";
import Modal from "./Modal";
import StyledButton from "./styled/Button";
import Input from "./styled/Input";
type Props = {
closeModal: () => void;
@@ -21,7 +21,7 @@ type Type = "id" | "email";
const SearchUser: FC<Props> = ({ closeModal }) => {
const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation();
const usersData = useAppSelector(store => store.users.byId);
const usersData = useAppSelector((store) => store.users.byId);
const { t } = useTranslation();
const navigateTo = useNavigate();
const inputRef = useRef(null);
@@ -35,11 +35,11 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
const tmp = {
[type]: evt.target.value
};
setInput(prev => ({ ...prev, ...tmp }));
setInput((prev) => ({ ...prev, ...tmp }));
};
const resetInput = () => {
reset();
setInput(prev => ({ ...prev, [type]: "" }));
setInput((prev) => ({ ...prev, [type]: "" }));
};
const handleSubmit = (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
@@ -77,32 +77,77 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
<Modal>
<div className=" relative flex flex-col gap-2 w-96 px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-900 text-slate-900 dark:text-slate-100">
<div className="flex items-center gap-2 py-2">
<StyledButton className={clsx("mini", type == "email" && "ghost !border-none !shadow-none")} onClick={handleChangeKeyword.bind(null, "id")}>
<StyledButton
className={clsx("mini", type == "email" && "ghost !border-none !shadow-none")}
onClick={handleChangeKeyword.bind(null, "id")}
>
{t("search_by_id", { ns: "member" })}
</StyledButton>
<StyledButton className={clsx("mini", type == "id" && "ghost !border-none !shadow-none")} onClick={handleChangeKeyword.bind(null, "email")}>
<StyledButton
className={clsx("mini", type == "id" && "ghost !border-none !shadow-none")}
onClick={handleChangeKeyword.bind(null, "email")}
>
{t("search_by_email", { ns: "member" })}
</StyledButton>
</div>
<form className="w-full" ref={inputRef} action="/" onSubmit={handleSubmit}>
<Input required type={inputType} className="none" disabled={isLoading} prefix={<IconSearch className="dark:fill-gray-400 w-6 h-6 shrink-0" />} value={input[type]} placeholder={`${t("action.search")}...`} onChange={handleInput} />
<Input
required
type={inputType}
className="none"
disabled={isLoading}
prefix={<IconSearch className="dark:fill-gray-400 w-6 h-6 shrink-0" />}
value={input[type]}
placeholder={`${t("action.search")}...`}
onChange={handleInput}
/>
</form>
<div className="min-h-[280px] flex-center pb-10">
{isSuccess ? (data ? <div className="flex flex-col items-center pt-10">
<Avatar className="rounded-full" src={data.avatar_updated_at === 0 ? "" : `${BASE_URL}/resource/avatar?uid=${data.uid}&t=${data.avatar_updated_at}`} name={data.name} width={120} height={120} />
<span className="my-2 dark:text-gray-100 text-gray-950">{data.name}</span>
<div className="flex gap-2 my-2">
<StyledButton className="mini ghost" onClick={resetInput}>{t("action.cancel")}</StyledButton>
<StyledButton disabled={adding} onClick={handleChat.bind(null, inContact)} className="mini">{inContact ? t(`chat`) : `Add to Contact`}</StyledButton>
</div>
</div> : <div className="w-full h-full text-center flex flex-col gap-3 items-center">
<span className="text-sm text-gray-800 dark:text-gray-200">
{t("search_not_found", { ns: "member" })}
</span>
<StyledButton className="mini" onClick={resetInput}>Ok</StyledButton>
</div>) : null}
{isSuccess ? (
data ? (
<div className="flex flex-col items-center pt-10">
<Avatar
className="rounded-full"
src={
data.avatar_updated_at === 0
? ""
: `${BASE_URL}/resource/avatar?uid=${data.uid}&t=${data.avatar_updated_at}`
}
name={data.name}
width={120}
height={120}
/>
<span className="my-2 dark:text-gray-100 text-gray-950">{data.name}</span>
<div className="flex gap-2 my-2">
<StyledButton className="mini ghost" onClick={resetInput}>
{t("action.cancel")}
</StyledButton>
<StyledButton
disabled={adding}
onClick={handleChat.bind(null, inContact)}
className="mini"
>
{inContact ? t(`chat`) : `Add to Contact`}
</StyledButton>
</div>
</div>
) : (
<div className="w-full h-full text-center flex flex-col gap-3 items-center">
<span className="text-sm text-gray-800 dark:text-gray-200">
{t("search_not_found", { ns: "member" })}
</span>
<StyledButton className="mini" onClick={resetInput}>
Ok
</StyledButton>
</div>
)
) : null}
</div>
<IconClose role="button" className="absolute top-2 right-2 dark:fill-white w-5 h-5" onClick={closeModal} />
<IconClose
role="button"
className="absolute top-2 right-2 dark:fill-white w-5 h-5"
onClick={closeModal}
/>
</div>
</Modal>
);
+21 -12
View File
@@ -1,11 +1,10 @@
import { useState, useRef, useEffect } from "react";
import { useOutsideClick } from "rooks";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { Picker } from 'emoji-mart';
import { Picker } from "emoji-mart";
import { useOutsideClick } from "rooks";
import Tooltip from "../Tooltip";
import SmileIcon from "@/assets/icons/emoji.smile.svg";
import Tooltip from "../Tooltip";
export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string) => void }) {
const ref = useRef<HTMLDivElement>(null);
@@ -18,9 +17,7 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
ref.current.innerHTML = "";
new Picker({
data: async () => {
const response = await fetch(
'https://cdn.jsdelivr.net/npm/@emoji-mart/data',
);
const response = await fetch("https://cdn.jsdelivr.net/npm/@emoji-mart/data");
return response.json();
},
@@ -29,7 +26,7 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
console.log("eee333", item);
selectEmoji(item.native);
},
}
// onClickOutside: () => {
// setVisible(false);
// }
@@ -44,7 +41,9 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
if (!clickEle) return;
const ignore =
(clickEle.nodeName == "svg" && clickEle.dataset.emoji == "toggler") ||
(clickEle.nodeName == "path" && clickEle.parentElement && clickEle.parentElement.dataset.emoji == "toggler");
(clickEle.nodeName == "path" &&
clickEle.parentElement &&
clickEle.parentElement.dataset.emoji == "toggler");
if (ignore) return;
setVisible(false);
},
@@ -54,10 +53,20 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
return (
<Tooltip placement="top" tip="Emojis" disabled={visible}>
<div className="hidden md:flex relative w-fit items-center">
<div ref={ref} className={clsx(`z-50 absolute -top-5 -left-5 -translate-y-full`, visible ? 'block' : 'hidden')}>
<div
ref={ref}
className={clsx(
`z-50 absolute -top-5 -left-5 -translate-y-full`,
visible ? "block" : "hidden"
)}
>
{/* emoji picker */}
</div>
<SmileIcon data-emoji="toggler" className="cursor-pointer select-none !w-[22px] !h-[22px]" onClick={togglePickerVisible} />
<SmileIcon
data-emoji="toggler"
className="cursor-pointer select-none !w-[22px] !h-[22px]"
onClick={togglePickerVisible}
/>
</div>
</Tooltip>
);
+6 -6
View File
@@ -1,13 +1,13 @@
import { ContentTypes } from "@/app/config";
import MarkdownRender from "../MarkdownRender";
import { MessagePayload } from "@/app/slices/message";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import useSendMessage from "@/hooks/useSendMessage";
import { getFileIcon, isImage } from "@/utils";
import IconClose from "@/assets/icons/close.circle.svg";
import pictureIcon from "@/assets/icons/picture.svg?url";
import { getFileIcon, isImage } from "@/utils";
import useSendMessage from "@/hooks/useSendMessage";
import { useAppSelector } from "@/app/store";
import { MessagePayload } from "@/app/slices/message";
import LinkifyText from "../LinkifyText";
import { ChatContext } from "@/types/common";
import MarkdownRender from "../MarkdownRender";
const renderContent = (data: MessagePayload) => {
const { content_type, content, thumbnail = "", properties } = data;
+50 -32
View File
@@ -1,13 +1,14 @@
import { ChangeEvent, useRef, FC } from "react";
import Tooltip from "../Tooltip";
import { ChangeEvent, FC, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
import useUploadFile from "@/hooks/useUploadFile";
import AddIcon from "@/assets/icons/add.solid.svg";
import ExitFullscreenIcon from "@/assets/icons/fullscreen.exit.svg";
import FullscreenIcon from "@/assets/icons/fullscreen.svg";
import MarkdownIcon from "@/assets/icons/markdown.svg";
import SendIcon from "@/assets/icons/send.svg";
import FullscreenIcon from "@/assets/icons/fullscreen.svg";
import ExitFullscreenIcon from "@/assets/icons/fullscreen.exit.svg";
import useUploadFile from "@/hooks/useUploadFile";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
import Tooltip from "../Tooltip";
type Props = {
sendMessages: () => void;
@@ -17,7 +18,7 @@ type Props = {
mode: "markdown" | "text";
to: number;
context: ChatContext;
sendVisible: boolean
sendVisible: boolean;
};
const Toolbar: FC<Props> = ({
sendMessages,
@@ -49,17 +50,23 @@ const Toolbar: FC<Props> = ({
// setFiles([...evt.target.files]);
};
const isMarkdown = mode == 'markdown';
const isMarkdown = mode == "markdown";
return (
<div className={`hidden md:flex flex-col md:flex-row items-center justify-end gap-2.5`}>
<div className="flex cursor-pointer gap-3.5">
<Tooltip placement="top" tip="Markdown">
<MarkdownIcon className={isMarkdown ? "fill-primary-400" : "dark:fill-gray-300"} onClick={toggleMode} />
<MarkdownIcon
className={isMarkdown ? "fill-primary-400" : "dark:fill-gray-300"}
onClick={toggleMode}
/>
</Tooltip>
{isMarkdown &&
(fullscreen ? (
<Tooltip placement="top" tip="Exit Fullscreen">
<ExitFullscreenIcon onClick={toggleMarkdownFullscreen} className="dark:fill-gray-300" />
<ExitFullscreenIcon
onClick={toggleMarkdownFullscreen}
className="dark:fill-gray-300"
/>
</Tooltip>
) : (
<Tooltip placement="top" tip="Fullscreen">
@@ -67,27 +74,38 @@ const Toolbar: FC<Props> = ({
</Tooltip>
))}
</div>
{!isMarkdown && <><Tooltip placement="top" tip={t("action.upload")}>
<div className="cursor-pointer relative w-6 h-6">
<AddIcon className="dark:fill-gray-300" />
<label htmlFor="file" className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0">
<input
className="hidden"
size={24}
ref={fileInputRef}
multiple={true}
onChange={handleUpload}
type="file"
name="file"
id="file"
/>
</label>
</div>
</Tooltip>
{sendVisible && <Tooltip placement="top" tip="Send">
<SendIcon className={"w-6 h-6 cursor-pointer animate-zoomIn dark:stroke-gray-300"} onClick={sendMessages} />
</Tooltip>}
</>}
{!isMarkdown && (
<>
<Tooltip placement="top" tip={t("action.upload")}>
<div className="cursor-pointer relative w-6 h-6">
<AddIcon className="dark:fill-gray-300" />
<label
htmlFor="file"
className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0"
>
<input
className="hidden"
size={24}
ref={fileInputRef}
multiple={true}
onChange={handleUpload}
type="file"
name="file"
id="file"
/>
</label>
</div>
</Tooltip>
{sendVisible && (
<Tooltip placement="top" tip="Send">
<SendIcon
className={"w-6 h-6 cursor-pointer animate-zoomIn dark:stroke-gray-300"}
onClick={sendMessages}
/>
</Tooltip>
)}
</>
)}
</div>
);
};
@@ -1,9 +1,9 @@
import { ChangeEvent, useState } from "react";
import CloseIcon from "@/assets/icons/close.svg";
import Modal from "../../Modal";
import Button from "../../styled/Button";
import Input from "../../styled/Input";
import CloseIcon from "@/assets/icons/close.svg";
export default function EditFileDetails({
name,
@@ -30,7 +30,9 @@ export default function EditFileDetails({
File Details <CloseIcon className="cursor-pointer dark:fill-white" onClick={closeModal} />
</h4>
<div className="py-4 flex flex-col gap-2">
<label className="font-semibold text-sm text-gray-600 dark:text-gray-200" htmlFor="name">Name</label>
<label className="font-semibold text-sm text-gray-600 dark:text-gray-200" htmlFor="name">
Name
</label>
<Input id="name" value={fileName} onChange={handleNameChange} />
</div>
<div className="flex justify-end gap-4 mt-8 w-full">
+24 -17
View File
@@ -1,23 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { ChatContext } from "@/types/common";
import useUploadFile from "@/hooks/useUploadFile";
import { formatBytes, getFileIcon } from "@/utils";
import DeleteIcon from "@/assets/icons/delete.svg";
import EditIcon from "@/assets/icons/edit.svg";
import { useMixedEditor } from "../../MixedInput";
import EditFileDetailsModal from "./EditFileDetails";
import { getFileIcon, formatBytes } from "@/utils";
import useUploadFile from "@/hooks/useUploadFile";
import EditIcon from "@/assets/icons/edit.svg";
import DeleteIcon from "@/assets/icons/delete.svg";
import { ChatContext } from "@/types/common";
type EditProps = {
index: number;
name: string;
};
export default function UploadFileList({
context,
id
}: {
context: ChatContext;
id: number;
}) {
export default function UploadFileList({ context, id }: { context: ChatContext; id: number }) {
const editor = useMixedEditor(`${context}_${id}`);
const [editInfo, setEditInfo] = useState<EditProps | null>(null);
const { stageFiles, updateStageFile, removeStageFile } = useUploadFile({
@@ -57,14 +52,26 @@ export default function UploadFileList({
<ul className="w-full overflow-auto flex gap-2 justify-start p-4 pt-6 bg-gray-200 dark:bg-gray-800 rounded-t-lg">
{stageFiles.map(({ name, url, size, type }, idx: number) => {
return (
<li className="group relative flex flex-col bg-gray-100 dark:bg-gray-700 rounded p-2" key={url}>
<li
className="group relative flex flex-col bg-gray-100 dark:bg-gray-700 rounded p-2"
key={url}
>
<div className="flex-center w-40 h-40">
{type.startsWith("image") ? <img className="w-full h-full object-cover" src={url} alt="image" /> : getFileIcon(type, name)}
{type.startsWith("image") ? (
<img className="w-full h-full object-cover" src={url} alt="image" />
) : (
getFileIcon(type, name)
)}
</div>
<h4 className="w-40 mt-4 mb-0.5 font-semibold text-sm text-gray-800 dark:text-gray-100 truncate">{name}</h4>
<h4 className="w-40 mt-4 mb-0.5 font-semibold text-sm text-gray-800 dark:text-gray-100 truncate">
{name}
</h4>
<span className="text-xs text-gray-500">{formatBytes(size)}</span>
<ul className="invisible group-hover:visible bg-inherit border border-solid border-black/10 box-border rounded-md flex items-center absolute -right-5 -top-2.5">
<li className="p-1 cursor-pointer edit" onClick={handleOpenEditModal.bind(null, idx)}>
<li
className="p-1 cursor-pointer edit"
onClick={handleOpenEditModal.bind(null, idx)}
>
<EditIcon />
</li>
<li
+43 -28
View File
@@ -1,26 +1,25 @@
import { useEffect, useState, FC } from "react";
import clsx from "clsx";
import { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import useSendMessage from "@/hooks/useSendMessage";
import useAddLocalFileMessage from "@/hooks/useAddLocalFileMessage";
import { updateInputMode } from "@/app/slices/ui";
import { ContentTypes, ChatPrefixes } from "@/app/config";
import clsx from "clsx";
// import StyledSend from "./styled";
import UploadFileList from "./UploadFileList";
import { ChatPrefixes, ContentTypes } from "@/app/config";
import { updateInputMode } from "@/app/slices/ui";
import { useAppDispatch, useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import MarkdownEditor from "@/components/MarkdownEditor";
import useAddLocalFileMessage from "@/hooks/useAddLocalFileMessage";
import useDraft from "@/hooks/useDraft";
import useSendMessage from "@/hooks/useSendMessage";
import useUploadFile from "@/hooks/useUploadFile";
import useUserOperation from "@/hooks/useUserOperation";
import MixedInput, { useMixedEditor } from "../MixedInput";
import StyledButton from "../styled/Button";
import TextInput from "../TextInput";
import EmojiPicker from "./EmojiPicker";
import Replying from "./Replying";
import Toolbar from "./Toolbar";
import EmojiPicker from "./EmojiPicker";
import MarkdownEditor from "../MarkdownEditor";
import MixedInput, { useMixedEditor } from "../MixedInput";
import useDraft from "@/hooks/useDraft";
import useUploadFile from "@/hooks/useUploadFile";
import { useAppDispatch, useAppSelector } from "@/app/store";
import TextInput from "../TextInput";
import useUserOperation from "@/hooks/useUserOperation";
import StyledButton from "../styled/Button";
import { ChatContext } from "@/types/common";
// import StyledSend from "./styled";
import UploadFileList from "./UploadFileList";
const Modes = {
text: "text",
@@ -36,7 +35,10 @@ const Send: FC<IProps> = ({
id
}) => {
const { t } = useTranslation("chat");
const { unblockThisContact, blocked } = useUserOperation({ uid: context == "dm" ? id : undefined, cid: context == "channel" ? id : undefined });
const { unblockThisContact, blocked } = useUserOperation({
uid: context == "dm" ? id : undefined,
cid: context == "channel" ? id : undefined
});
const { resetStageFiles } = useUploadFile({ context, id });
const { getDraft, getUpdateDraft } = useDraft({ context, id });
const editor = useMixedEditor(`${context}_${id}`);
@@ -88,7 +90,7 @@ const Send: FC<IProps> = ({
// send text msgs
for await (const msg of msgs) {
const { type: content_type, content, properties = {} } = msg;
if ((content as string).trim() === '') continue; // 空消息不发送
if ((content as string).trim() === "") continue; // 空消息不发送
properties.local_id = properties.local_id ?? +new Date();
await sendMessage({
id,
@@ -146,10 +148,14 @@ const Send: FC<IProps> = ({
context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : [];
const isMarkdownMode = mode == Modes.markdown;
if (context == "dm" && blocked) {
return <div className="p-5 bg-gray-200 rounded-lg w-full dark:bg-gray-600 text-red-300">
{t("contact_block_tip")}
<StyledButton className="mini ml-4" onClick={unblockThisContact}>{t("unblock")}</StyledButton>
</div>;
return (
<div className="p-5 bg-gray-200 rounded-lg w-full dark:bg-gray-600 text-red-300">
{t("contact_block_tip")}
<StyledButton className="mini ml-4" onClick={unblockThisContact}>
{t("unblock")}
</StyledButton>
</div>
);
}
return (
<>
@@ -157,13 +163,22 @@ const Send: FC<IProps> = ({
<TextInput sendMessage={handleSendMessage} placeholder={placeholder} />
{/* PC input */}
<div
className={clsx(`send hidden md:block relative bg-gray-200 rounded-lg w-full dark:bg-gray-600 ${mode} ${markdownFullscreen ? "fullscreen" : ""} ${replying_mid ? "reply" : ""
} ${context}`, isMarkdownMode && markdownFullscreen && '-mt-9')}
className={clsx(
`send hidden md:block relative bg-gray-200 rounded-lg w-full dark:bg-gray-600 ${mode} ${
markdownFullscreen ? "fullscreen" : ""
} ${replying_mid ? "reply" : ""} ${context}`,
isMarkdownMode && markdownFullscreen && "-mt-9"
)}
>
{replying_mid && <Replying context={context} mid={replying_mid} id={id} />}
{mode == Modes.text && <UploadFileList context={context} id={id} />}
<div className={clsx(`flex justify-between items-center gap-4 px-4 py-3.5`, isMarkdownMode && `grid grid-cols-[1fr_1fr] grid-rows-[auto_auto] gap-0`)}>
<div
className={clsx(
`flex justify-between items-center gap-4 px-4 py-3.5`,
isMarkdownMode && `grid grid-cols-[1fr_1fr] grid-rows-[auto_auto] gap-0`
)}
>
<EmojiPicker selectEmoji={insertEmoji} />
{mode == Modes.text && (
<MixedInput
+21 -8
View File
@@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next";
import { NavLink, useLocation } from "react-router-dom";
import Tippy from "@tippyjs/react";
import IconAdd from "@/assets/icons/add.svg";
import Tooltip from "./Tooltip";
import AddEntriesMenu from "./AddEntriesMenu";
import { useAppSelector } from "@/app/store";
import { useTranslation } from "react-i18next";
import IconAdd from "@/assets/icons/add.svg";
import AddEntriesMenu from "./AddEntriesMenu";
import Tooltip from "./Tooltip";
type Props = {
readonly?: boolean;
@@ -25,13 +26,19 @@ export default function Server({ readonly = false }: Props) {
<NavLink to={"/"} className="relative flex items-center justify-between gap-2 px-4 py-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8">
<img alt={`${name} logo`} className="w-full h-full object-cover rounded-full" src={logo} />
<img
alt={`${name} logo`}
className="w-full h-full object-cover rounded-full"
src={logo}
/>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-gray-600 dark:text-gray-100" title={description}>
{name}
</h3>
<span className="text-xs text-gray-500">{userCount} {t("members")}</span>
<span className="text-xs text-gray-500">
{userCount} {t("members")}
</span>
</div>
</div>
</NavLink>
@@ -42,13 +49,19 @@ export default function Server({ readonly = false }: Props) {
<NavLink to={`/setting/overview?f=${pathname}`}>
<div className="flex items-center gap-2">
<div className="w-8 h-8">
<img alt={`${name} logo`} className="w-full h-full object-cover rounded-full" src={logo} />
<img
alt={`${name} logo`}
className="w-full h-full object-cover rounded-full"
src={logo}
/>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-gray-600 font-bold dark:text-gray-100" title={description}>
{name}
</h3>
<span className="text-xs text-gray-500">{userCount} {t("members")}</span>
<span className="text-xs text-gray-500">
{userCount} {t("members")}
</span>
</div>
</div>
</NavLink>
+35 -25
View File
@@ -1,35 +1,45 @@
import { ReactElement } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { compareVersion } from '../utils';
import { useAppSelector } from '../app/store';
import { ReactElement } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useAppSelector } from "../app/store";
import { compareVersion } from "../utils";
type Props = {
empty?: boolean,
version: string,
children: ReactElement
}
empty?: boolean;
version: string;
children: ReactElement;
};
const ServerVersionChecker = ({ empty = false, version, children }: Props) => {
const { t } = useTranslation();
const currentVersion = useAppSelector(store => store.server.version);
const currentVersion = useAppSelector((store) => store.server.version);
if (!currentVersion) return null;
const res = compareVersion(currentVersion, version);
if (res < 0) return empty ? null : <div className='flex flex-col gap-2 items-start border border-solid border-orange-500 p-3 rounded-lg w-fit'>
<span className='text-gray-400 text-sm'>
<Trans i18nKey={"server_update.version_needed"}>
<strong className='font-bold'>{{ version }}</strong>
</Trans>
</span>
<span className='text-gray-400 text-sm'>
<Trans i18nKey={"server_update.current_version"}>
<strong className='font-bold'>{{ version: currentVersion }}</strong>
</Trans>
</span>
<span className='text-gray-400 text-sm'>{t("server_update.update_tip")}</span>
<a className='text-blue-500 underline' href="https://doc.voce.chat/install/install-by-docker#update-vocechat-docker" target="_blank" rel="noopener noreferrer">
{t("server_update.howto")} 📖 </a>
</div>;
if (res < 0)
return empty ? null : (
<div className="flex flex-col gap-2 items-start border border-solid border-orange-500 p-3 rounded-lg w-fit">
<span className="text-gray-400 text-sm">
<Trans i18nKey={"server_update.version_needed"}>
<strong className="font-bold">{{ version }}</strong>
</Trans>
</span>
<span className="text-gray-400 text-sm">
<Trans i18nKey={"server_update.current_version"}>
<strong className="font-bold">{{ version: currentVersion }}</strong>
</Trans>
</span>
<span className="text-gray-400 text-sm">{t("server_update.update_tip")}</span>
<a
className="text-blue-500 underline"
href="https://doc.voce.chat/install/install-by-docker#update-vocechat-docker"
target="_blank"
rel="noopener noreferrer"
>
{t("server_update.howto")} 📖{" "}
</a>
</div>
);
return children;
};
export default ServerVersionChecker;
export default ServerVersionChecker;
+20 -19
View File
@@ -1,9 +1,9 @@
import clsx from 'clsx';
import React from 'react';
import React from "react";
import clsx from "clsx";
type Props = {
strength?: number
}
strength?: number;
};
// 0:质量未知。
// 1:质量极好。
// 2:用户主观感觉和极好差不多,但码率可能略低于极好。
@@ -13,20 +13,21 @@ type Props = {
// 6: 网络连接断开,完全无法沟通。
const Signal = ({ strength = 0 }: Props) => {
const finalStrength = 6 - strength;
const color = finalStrength <= 2 ? `bg-red-700` : (finalStrength == 3 ? `bg-yellow-700` : `bg-green-700`);
let bgColor = ` bg-gray-500`;
return (
<div className='w-6 h-6 flex-center'>
<div className="w-[18px] h-[15px] flex items-end gap-[2px]">
<span className={clsx('h-1/5 w-[2px]', finalStrength == 0 ? bgColor : color)}></span>
<span className={clsx('h-2/5 w-[2px]', finalStrength <= 1 ? bgColor : color)}></span>
<span className={clsx('h-3/5 w-[2px]', finalStrength <= 2 ? bgColor : color)}></span>
<span className={clsx('h-4/5 w-[2px]', finalStrength <= 3 ? bgColor : color)}></span>
<span className={clsx('h-full w-[2px]', finalStrength <= 4 ? bgColor : color)}></span>
</div>
</div>
);
const finalStrength = 6 - strength;
const color =
finalStrength <= 2 ? `bg-red-700` : finalStrength == 3 ? `bg-yellow-700` : `bg-green-700`;
let bgColor = ` bg-gray-500`;
return (
<div className="w-6 h-6 flex-center">
<div className="w-[18px] h-[15px] flex items-end gap-[2px]">
<span className={clsx("h-1/5 w-[2px]", finalStrength == 0 ? bgColor : color)}></span>
<span className={clsx("h-2/5 w-[2px]", finalStrength <= 1 ? bgColor : color)}></span>
<span className={clsx("h-3/5 w-[2px]", finalStrength <= 2 ? bgColor : color)}></span>
<span className={clsx("h-4/5 w-[2px]", finalStrength <= 3 ? bgColor : color)}></span>
<span className={clsx("h-full w-[2px]", finalStrength <= 4 ? bgColor : color)}></span>
</div>
</div>
);
};
export default Signal;
export default Signal;
+66 -16
View File
@@ -1,10 +1,12 @@
import { FC, PropsWithChildren, ReactNode } from "react";
import { NavLink } from "react-router-dom";
import IconBack from "@/assets/icons/arrow.left.svg";
import { Nav } from "../routes/settingChannel/navs";
import clsx from "clsx";
import GoBackNav from "./GoBackNav";
import IconBack from "@/assets/icons/arrow.left.svg";
import MobileNavs from "../routes/home/MobileNavs";
import { Nav } from "../routes/settingChannel/navs";
import GoBackNav from "./GoBackNav";
// import ErrorCatcher from "./ErrorCatcher";
export interface Danger {
title: string;
@@ -12,7 +14,7 @@ export interface Danger {
}
interface Props {
pathPrefix?: string,
pathPrefix?: string;
closeModal: () => void;
title?: string;
navs: Nav[];
@@ -29,24 +31,59 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
nav,
children
}) => {
return (
<>
<div className="w-screen h-screen flex">
<div className={clsx("h-full w-full overflow-y-scroll md:max-w-[212px] px-4 py-8 bg-neutral-100 dark:bg-gray-800", nav && "hidden md:block")}>
<h2 onClick={closeModal} className="hidden md:flex gap-2 items-center text-sm md:text-base cursor-pointer mb-8 font-bold text-gray-800 dark:text-white">
<div
className={clsx(
"h-full w-full overflow-y-scroll md:max-w-[212px] px-4 py-8 bg-neutral-100 dark:bg-gray-800",
nav && "hidden md:block"
)}
>
<h2
onClick={closeModal}
className="hidden md:flex gap-2 items-center text-sm md:text-base cursor-pointer mb-8 font-bold text-gray-800 dark:text-white"
>
<IconBack className="dark:fill-gray-400" /> {title}
</h2>
{navs.map(({ title, items }) => {
return (
<ul key={title} data-title={title} className="flex flex-col gap-0.5 mb-5 md:mb-9 before:md:pl-3 before:content-[attr(data-title)] before:font-bold before:text-xs before:text-gray-400">
<ul
key={title}
data-title={title}
className="flex flex-col gap-0.5 mb-5 md:mb-9 before:md:pl-3 before:content-[attr(data-title)] before:font-bold before:text-xs before:text-gray-400"
>
{items.map(({ name, link, title }) => {
if (link) return <li key={name} className={clsx(`md:text-sm font-semibold text-gray-600 whitespace-nowrap dark:text-gray-200 md:rounded md:hover:bg-stone-200 md:dark:hover:bg-slate-500/20`, name == nav?.name && "bg-stone-200 dark:bg-slate-500/20")}>
<a href={link} target="_blank" className="block md:px-3 py-1" rel="noreferrer">{title} <span className="text-xs mx-1">🔗</span></a>
</li>;
if (link)
return (
<li
key={name}
className={clsx(
`md:text-sm font-semibold text-gray-600 whitespace-nowrap dark:text-gray-200 md:rounded md:hover:bg-stone-200 md:dark:hover:bg-slate-500/20`,
name == nav?.name && "bg-stone-200 dark:bg-slate-500/20"
)}
>
<a
href={link}
target="_blank"
className="block md:px-3 py-1"
rel="noreferrer"
>
{title} <span className="text-xs mx-1">🔗</span>
</a>
</li>
);
return (
<li key={name} className={clsx(`md:text-sm font-semibold text-gray-600 whitespace-nowrap dark:text-gray-200 md:rounded md:hover:bg-stone-200 md:dark:hover:bg-slate-500/20`, name == nav?.name && "bg-stone-200 dark:bg-slate-500/20")}>
<NavLink to={`${pathPrefix}/${name}`} className="block md:px-3 py-1">{title}</NavLink>
<li
key={name}
className={clsx(
`md:text-sm font-semibold text-gray-600 whitespace-nowrap dark:text-gray-200 md:rounded md:hover:bg-stone-200 md:dark:hover:bg-slate-500/20`,
name == nav?.name && "bg-stone-200 dark:bg-slate-500/20"
)}
>
<NavLink to={`${pathPrefix}/${name}`} className="block md:px-3 py-1">
{title}
</NavLink>
</li>
);
})}
@@ -59,7 +96,11 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
if (typeof d === "boolean" || !d) return null;
const { title, handler } = d;
return (
<li key={title} onClick={handler} className="rounded cursor-pointer py-1.5 md:px-3">
<li
key={title}
onClick={handler}
className="rounded cursor-pointer py-1.5 md:px-3"
>
{title}
</li>
);
@@ -67,9 +108,18 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
</ul>
) : null}
</div>
<div className={clsx("relative bg-white w-full max-h-full overflow-auto px-4 md:px-8 py-2 md:py-8 dark:bg-gray-700", !nav ? "hidden md:block" : "!pb-4")}>
<div
className={clsx(
"relative bg-white w-full max-h-full overflow-auto px-4 md:px-8 py-2 md:py-8 dark:bg-gray-700",
!nav ? "hidden md:block" : "!pb-4"
)}
>
<GoBackNav path={pathPrefix} className="!left-1 top-1.5" />
{nav && <h4 className="font-bold text-xl text-center md:text-left text-gray-600 mb-4 md:mb-8 pl-4 md:pl-0 dark:text-gray-100">{nav.title}</h4>}
{nav && (
<h4 className="font-bold text-xl text-center md:text-left text-gray-600 mb-4 md:mb-8 pl-4 md:pl-0 dark:text-gray-100">
{nav.title}
</h4>
)}
{/* <ErrorCatcher> */}
{children}
{/* </ErrorCatcher> */}
+13 -11
View File
@@ -1,15 +1,15 @@
import { ChangeEvent, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChangeEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import TextareaAutoSize from "react-textarea-autosize";
import { useKey } from 'rooks';
import { isMobile } from '../../utils';
import { useKey } from "rooks";
import { isMobile } from "../../utils";
import Button from "../styled/Button";
type Props = {
placeholder: string,
sendMessage: any
}
placeholder: string;
sendMessage: any;
};
const TextInput = ({ sendMessage, placeholder }: Props) => {
const { t } = useTranslation();
const inputRef = useRef(null);
@@ -34,11 +34,11 @@ const TextInput = ({ sendMessage, placeholder }: Props) => {
},
{
when: !isMobile(),
target: inputRef,
target: inputRef
}
);
return (
<div className='md:hidden relative mb-1 p-1 flex items-center w-full text-gray-600 dark:text-white bg-gray-200 dark:bg-gray-600 rounded-lg'>
<div className="md:hidden relative mb-1 p-1 flex items-center w-full text-gray-600 dark:text-white bg-gray-200 dark:bg-gray-600 rounded-lg">
<TextareaAutoSize
// autoFocus
onFocus={(e) =>
@@ -56,9 +56,11 @@ const TextInput = ({ sendMessage, placeholder }: Props) => {
value={currMsg}
placeholder={placeholder}
/>
<Button onClick={handleSend} className="mini absolute right-1.5 bottom-1.5">{t("action.send")}</Button>
<Button onClick={handleSend} className="mini absolute right-1.5 bottom-1.5">
{t("action.send")}
</Button>
</div>
);
};
export default TextInput;
export default TextInput;
+10 -8
View File
@@ -1,16 +1,16 @@
import { FC } from "react";
import Tippy, { TippyProps } from "@tippyjs/react";
import clsx from "clsx";
import { isMobile } from "../utils";
import { isMobile } from "../utils";
const Triangle: FC<Pick<TippyProps, "placement">> = ({ placement }) => {
if (placement == "left") return null;
const cls = clsx("w-3 h-3 bg-inherit absolute rounded-[1px] origin-center rotate-45",
const cls = clsx(
"w-3 h-3 bg-inherit absolute rounded-[1px] origin-center rotate-45",
placement == "right" && "left-0 top-1/2 -translate-x-1/2 -translate-y-1/2",
placement == "top" && "left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2",
placement == "bottom" && "top-0 left-1/2 -translate-x-1/2 -translate-y-1/2",
placement == "bottom" && "top-0 left-1/2 -translate-x-1/2 -translate-y-1/2"
);
return <i className={cls}></i>;
};
@@ -29,10 +29,12 @@ const Tooltip: FC<Props> = ({ tip = "", placement = "right", delay = null, child
duration={delay ? defaultDuration : 0}
delay={delay ?? [150, 0]}
placement={placement}
content={<div className="relative bg-white dark:bg-gray-800 px-3 py-2 text-xs rounded-lg drop-shadow text-gray-700 dark:text-gray-100">
<Triangle placement={placement} />
{tip}
</div>}
content={
<div className="relative bg-white dark:bg-gray-800 px-3 py-2 text-xs rounded-lg drop-shadow text-gray-700 dark:text-gray-100">
<Triangle placement={placement} />
{tip}
</div>
}
{...rest}
>
{children}
+62 -60
View File
@@ -1,72 +1,74 @@
import { useEffect } from 'react';
import { useAppSelector } from '../app/store';
import getUnreadCount from '../routes/chat/utils';
import { useEffect } from "react";
import { useAppSelector } from "../app/store";
import getUnreadCount from "../routes/chat/utils";
// type Props = {}
let total = 0;
let title = "";
const UnreadTabTip = () => {
const { userData, dmMids, channelMids, messageData, readChannels, readUsers, loginUid } = useAppSelector(store => {
return {
userData: store.users.byId,
dmMids: store.userMessage.byId,
channelMids: store.channelMessage,
messageData: store.message,
readChannels: store.footprint.readChannels,
readUsers: store.footprint.readUsers,
loginUid: store.authData.user?.uid ?? 0
};
const { userData, dmMids, channelMids, messageData, readChannels, readUsers, loginUid } =
useAppSelector((store) => {
return {
userData: store.users.byId,
dmMids: store.userMessage.byId,
channelMids: store.channelMessage,
messageData: store.message,
readChannels: store.footprint.readChannels,
readUsers: store.footprint.readUsers,
loginUid: store.authData.user?.uid ?? 0
};
});
useEffect(() => {
total = 0;
// dm
Object.entries(dmMids).forEach(([id, mids]) => {
if (userData[+id]) {
const { unreads = 0 } = getUnreadCount({
mids,
readIndex: readUsers[+id],
messageData,
loginUid
});
if (unreads > 0) {
console.log("unreadsss", id, mids);
}
total += unreads;
}
useEffect(() => {
total = 0;
// dm
Object.entries(dmMids).forEach(([id, mids]) => {
if (userData[+id]) {
const { unreads = 0 } = getUnreadCount({
mids,
readIndex: readUsers[+id],
messageData,
loginUid
});
// channel
Object.entries(channelMids).map(([id, mids]) => {
const { unreads = 0 } = getUnreadCount({
mids,
readIndex: readChannels[+id],
messageData,
loginUid
});
total += unreads;
});
const handler = () => {
console.log("changed", document.hidden, total);
if (document.hidden) {
title = document.title;
if (total > 0) {
document.title = `[${total}]-${title}`;
}
} else {
document.title = title;
}
};
document.addEventListener("visibilitychange", handler);
if (document.hidden && total > 0) {
document.title = `[${total}]-${title}`;
if (unreads > 0) {
console.log("unreadsss", id, mids);
}
return () => {
document.removeEventListener("visibilitychange", handler);
};
}, [userData, dmMids, channelMids, readChannels, messageData, loginUid, readUsers]);
total += unreads;
}
});
// channel
Object.entries(channelMids).map(([id, mids]) => {
const { unreads = 0 } = getUnreadCount({
mids,
readIndex: readChannels[+id],
messageData,
loginUid
});
total += unreads;
});
const handler = () => {
console.log("changed", document.hidden, total);
return null;
if (document.hidden) {
title = document.title;
if (total > 0) {
document.title = `[${total}]-${title}`;
}
} else {
document.title = title;
}
};
document.addEventListener("visibilitychange", handler);
if (document.hidden && total > 0) {
document.title = `[${total}]-${title}`;
}
return () => {
document.removeEventListener("visibilitychange", handler);
};
}, [userData, dmMids, channelMids, readChannels, messageData, loginUid, readUsers]);
return null;
};
export default UnreadTabTip;
export default UnreadTabTip;
+2 -2
View File
@@ -1,8 +1,9 @@
import { FC, ReactElement } from "react";
import { useTranslation } from "react-i18next";
import Tippy from "@tippyjs/react";
import useUserOperation from "@/hooks/useUserOperation";
import ContextMenu, { Item } from "../ContextMenu";
import { useTranslation } from "react-i18next";
interface Props {
enable?: boolean;
@@ -75,7 +76,6 @@ const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, c
title: t("remove"),
handler: removeUser
}
].filter(Boolean) as Item[]
}
/>
+37 -12
View File
@@ -1,14 +1,15 @@
import { FC, memo } from "react";
import { useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react";
import IconOwner from "@/assets/icons/owner.svg";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import useContextMenu from "@/hooks/useContextMenu";
import IconBot from "@/assets/icons/bot.svg";
import IconOwner from "@/assets/icons/owner.svg";
import Avatar from "../Avatar";
import Profile from "../Profile";
import ContextMenu from "./ContextMenu";
import useContextMenu from "@/hooks/useContextMenu";
import { useAppSelector } from "@/app/store";
import clsx from "clsx";
interface Props {
uid: number;
@@ -36,18 +37,30 @@ const User: FC<Props> = ({
const navigate = useNavigate();
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
const { curr, loginUid, showStatus } = useAppSelector((store) => {
return { curr: store.users.byId[uid], loginUid: store.authData.user?.uid, showStatus: store.server.show_user_online_status };
return {
curr: store.users.byId[uid],
loginUid: store.authData.user?.uid,
showStatus: store.server.show_user_online_status
};
});
const handleDoubleClick = () => {
navigate(`/chat/dm/${uid}`);
};
if (!curr) return null;
const online = curr.online || curr.uid == loginUid;
const containerClass = clsx(`relative flex items-center justify-start gap-2 rounded-lg select-none`, interactive && "md:hover:bg-gray-500/10", compact ? "p-0" : "p-2");
const nameClass = clsx(`text-sm text-gray-500 max-w-[190px] truncate font-semibold dark:text-white`);
const statusClass = clsx(`absolute -bottom-[2.5px] -right-[2.5px] border-content rounded-full border-[1px] border-white dark:border-gray-300`,
const containerClass = clsx(
`relative flex items-center justify-start gap-2 rounded-lg select-none`,
interactive && "md:hover:bg-gray-500/10",
compact ? "p-0" : "p-2"
);
const nameClass = clsx(
`text-sm text-gray-500 max-w-[190px] truncate font-semibold dark:text-white`
);
const statusClass = clsx(
`absolute -bottom-[2.5px] -right-[2.5px] border-content rounded-full border-[1px] border-white dark:border-gray-300`,
online ? "bg-green-500" : "bg-zinc-400",
compact ? "w-[15px] h-[15px]" : "w-3 h-3");
compact ? "w-[15px] h-[15px]" : "w-3 h-3"
);
const statusElement = showStatus ? <div className={statusClass}></div> : null;
if (!popover)
return (
@@ -63,7 +76,10 @@ const User: FC<Props> = ({
onDoubleClick={dm ? handleDoubleClick : undefined}
onContextMenu={enableContextMenu ? handleContextMenuEvent : undefined}
>
<div className="cursor-pointer relative" style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}>
<div
className="cursor-pointer relative"
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
>
<Avatar
className="w-full h-full rounded-full object-cover"
width={avatarSize}
@@ -72,7 +88,13 @@ const User: FC<Props> = ({
name={curr.name}
alt="avatar"
/>
{curr.is_bot ? <IconBot className={clsx("absolute -bottom-[2.5px] -right-[2.5px]", "!w-[15px] !h-[15px]")} /> : statusElement}
{curr.is_bot ? (
<IconBot
className={clsx("absolute -bottom-[2.5px] -right-[2.5px]", "!w-[15px] !h-[15px]")}
/>
) : (
statusElement
)}
</div>
{!compact && (
<span className={nameClass} title={curr?.name}>
@@ -103,7 +125,10 @@ const User: FC<Props> = ({
onDoubleClick={dm ? handleDoubleClick : undefined}
onContextMenu={enableContextMenu ? handleContextMenuEvent : undefined}
>
<div className="cursor-pointer relative" style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}>
<div
className="cursor-pointer relative"
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
>
<Avatar
className="w-full h-full rounded-full object-cover"
width={avatarSize}
+16 -9
View File
@@ -1,11 +1,11 @@
import { ChangeEvent, useRef, FC } from "react";
import { ChangeEvent, FC, useRef } from "react";
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import { useOutsideClick } from "rooks";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import User from "./User";
import Modal from "./Modal";
import { useTranslation } from "react-i18next";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import Modal from "./Modal";
import User from "./User";
interface Props {
closeModal: () => void;
@@ -21,12 +21,19 @@ const UsersModal: FC<Props> = ({ closeModal }) => {
updateInput(evt.target.value);
};
return (
<Modal>
<div className="flex flex-col w-80 md:w-[440px] max-h-[402px] bg-white dark:bg-gray-900 drop-shadow rounded-lg" ref={wrapperRef}>
<div
className="flex flex-col w-80 md:w-[440px] max-h-[402px] bg-white dark:bg-gray-900 drop-shadow rounded-lg"
ref={wrapperRef}
>
<div className="shadow-md p-2">
<input className="p-2 text-sm bg-transparent dark:text-white w-full outline-none" value={input} onChange={handleSearch} placeholder={t("search_user_placeholder")} />
<input
className="p-2 text-sm bg-transparent dark:text-white w-full outline-none"
value={input}
onChange={handleSearch}
placeholder={t("search_user_placeholder")}
/>
</div>
{users && (
<ul className="flex flex-col overflow-y-scroll h-[260px] py-4">
@@ -34,7 +41,7 @@ const UsersModal: FC<Props> = ({ closeModal }) => {
const { uid = 0 } = u || {};
return (
<li key={uid} className="cursor-pointer px-2 md:hover:bg-gray-600/10">
<NavLink className={'w-full'} onClick={closeModal} to={`/chat/dm/${uid}`}>
<NavLink className={"w-full"} onClick={closeModal} to={`/chat/dm/${uid}`}>
<User uid={uid} interactive={false} />
</NavLink>
</li>
+20 -8
View File
@@ -1,13 +1,15 @@
import dayjs from "dayjs";
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { Ring } from "@uiball/loaders";
import Button from "./styled/Button";
import { unregister } from '../serviceWorkerRegistration';
import dayjs from "dayjs";
import { useAppSelector } from "@/app/store";
import { unregister } from "../serviceWorkerRegistration";
import Button from "./styled/Button";
type Props = {};
const Version: FC<Props> = () => {
const serverVersion = useAppSelector(store => store.server.version);
const serverVersion = useAppSelector((store) => store.server.version);
const [syncing, setSyncing] = useState(false);
const { t } = useTranslation("setting", { keyPrefix: "version" });
const ts = (process.env.REACT_APP_BUILD_TIME ?? 0) as number;
@@ -20,11 +22,21 @@ const Version: FC<Props> = () => {
};
return (
<ul className="flex flex-col gap-2 dark:text-white">
<li>{t("client_version")}: {process.env.VERSION}</li>
<li>{t("server_version")}: {serverVersion}</li>
<li>{t("build_time")}: {ts} <span className="text-gray-700 dark:text-gray-300">({dayjs(ts * 1000).fromNow()})</span></li>
<li>
<Button disabled={syncing} onClick={handleSync}> {syncing ? <Ring size={18} color="#fff" /> : t("sync")}</Button>
{t("client_version")}: {process.env.VERSION}
</li>
<li>
{t("server_version")}: {serverVersion}
</li>
<li>
{t("build_time")}: {ts}{" "}
<span className="text-gray-700 dark:text-gray-300">({dayjs(ts * 1000).fromNow()})</span>
</li>
<li>
<Button disabled={syncing} onClick={handleSync}>
{" "}
{syncing ? <Ring size={18} color="#fff" /> : t("sync")}
</Button>
</li>
</ul>
);
+115 -97
View File
@@ -1,113 +1,131 @@
// import React from 'react';
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
import { useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
// import { useTranslation } from 'react-i18next';
import { Waveform } from '@uiball/loaders';
import { useDispatch } from 'react-redux';
import { motion } from 'framer-motion';
import { useAppSelector } from '../../app/store';
import { Waveform } from "@uiball/loaders";
import clsx from "clsx";
import { motion } from "framer-motion";
import IconCallOff from "@/assets/icons/call.off.svg";
import IconCallAnswer from "@/assets/icons/call.svg";
import { updateCalling } from "../../app/slices/voice";
import { useAppSelector } from "../../app/store";
import { playAgoraVideo } from "../../utils";
import Avatar from "../Avatar";
import Tooltip from "../Tooltip";
import IconCallOff from '@/assets/icons/call.off.svg';
import IconCallAnswer from '@/assets/icons/call.svg';
import Avatar from '../Avatar';
import { updateCalling } from '../../app/slices/voice';
import useVoice from './useVoice';
import { playAgoraVideo } from '../../utils';
import { useLocation, useNavigate } from 'react-router-dom';
import useVoice from "./useVoice";
type Props = {
from: number
to?: number
}
from: number;
to?: number;
};
const DMCalling = ({ from, to = 0 }: Props) => {
const navigate = useNavigate();
const { pathname } = useLocation();
const dispatch = useDispatch();
const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" });
const containerRef = useRef(null);
const { calling, voicingMembers, fromUser, toUser, loginUser } = useAppSelector(store => {
return {
calling: store.voice.calling,
// voicingInfo: store.voice.voicing ?? {},
voicingMembers: store.voice.voicingMembers,
fromUser: store.users.byId[from],
toUser: store.users.byId[to],
loginUser: store.authData.user
};
const navigate = useNavigate();
const { pathname } = useLocation();
const dispatch = useDispatch();
const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" });
const containerRef = useRef(null);
const { calling, voicingMembers, fromUser, toUser, loginUser } = useAppSelector((store) => {
return {
calling: store.voice.calling,
// voicingInfo: store.voice.voicing ?? {},
voicingMembers: store.voice.voicingMembers,
fromUser: store.users.byId[from],
toUser: store.users.byId[to],
loginUser: store.authData.user
};
});
const sendByMe = loginUser?.uid !== toUser.uid;
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach((id) => {
playAgoraVideo(id);
});
const sendByMe = loginUser?.uid !== toUser.uid;
}, [voicingMembers.ids]);
const handleCancel = () => {
console.log("cancel");
if (sendByMe || voicingMembers.ids.length == 2) {
leave();
}
// 拒绝
dispatch(updateCalling(false));
};
const handleAnswer = () => {
joinVoice();
navigate(`/chat/dm/${from}`);
};
const { name, avatar } = sendByMe ? toUser : fromUser;
const connected = voicingMembers.ids.length == 2;
console.log("dm calling", !fromUser, !toUser, connected);
const atChatPath = sendByMe ? pathname == `/chat/dm/${to}` : pathname == `/chat/dm/${from}`;
if (!fromUser || !toUser || connected || atChatPath || !calling) return null;
useEffect(() => {
const ids = voicingMembers.ids;
ids.forEach(id => {
playAgoraVideo(id);
});
}, [voicingMembers.ids]);
const handleCancel = () => {
console.log('cancel');
if (sendByMe || voicingMembers.ids.length == 2) {
leave();
}
// 拒绝
dispatch(updateCalling(false));
};
const handleAnswer = () => {
joinVoice();
navigate(`/chat/dm/${from}`);
};
const { name, avatar } = sendByMe ? toUser : fromUser;
const connected = voicingMembers.ids.length == 2;
console.log("dm calling", !fromUser, !toUser, connected);
const atChatPath = sendByMe ? pathname == `/chat/dm/${to}` : pathname == `/chat/dm/${from}`;
if (!fromUser || !toUser || connected || atChatPath || !calling) return null;
return (
<div ref={containerRef} className="fixed top-0 left-0 w-full h-screen z-[999] pointer-events-none flex items-center justify-end pr-10">
<motion.aside drag dragConstraints={containerRef} dragMomentum={false} whileDrag={{ scale: 1.1 }} className={clsx(`pointer-events-auto
return (
<div
ref={containerRef}
className="fixed top-0 left-0 w-full h-screen z-[999] pointer-events-none flex items-center justify-end pr-10"
>
<motion.aside
drag
dragConstraints={containerRef}
dragMomentum={false}
whileDrag={{ scale: 1.1 }}
className={clsx(`pointer-events-auto
rounded bg-gray-800 relative
shadow-lg shadow-slate-200 dark:shadow-slate-800
cursor-move overflow-hidden
w-64 h-80
`)}>
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
{/* to camera video */}
</div>
<div className={clsx("absolute left-0 top-0 py-5 w-full h-full flex flex-col justify-between items-center", connected ? "bg-transparent" : "bg-gray-800")}>
<div className="flex flex-col gap-2 items-center">
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
<Avatar name={name} src={avatar} width={80} height={80} className='h-20' />
</div>
<span className='text-white mb-2'>{name}</span>
</div>
<div className='flex flex-col gap-1 items-center my-4'>
<Waveform
size={18}
lineWeight={3}
speed={1}
color='#aaa'
/>
<span className='text-xs text-gray-600 dark:text-gray-400'>{sendByMe ? `Calling` : `Incoming call`}</span>
</div>
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
<Tooltip tip={"Disconnect"} placement="top">
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg'>
<IconCallOff className="w-6 h-6" />
</button>
</Tooltip>
{!sendByMe &&
<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-lg'>
<IconCallAnswer className="w-6 h-6 fill-white" />
</button>
</Tooltip>
}
</div>
</div>
</motion.aside>
`)}
>
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
{/* to camera video */}
</div>
);
<div
className={clsx(
"absolute left-0 top-0 py-5 w-full h-full flex flex-col justify-between items-center",
connected ? "bg-transparent" : "bg-gray-800"
)}
>
<div className="flex flex-col gap-2 items-center">
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
<Avatar name={name} src={avatar} width={80} height={80} className="h-20" />
</div>
<span className="text-white mb-2">{name}</span>
</div>
<div className="flex flex-col gap-1 items-center my-4">
<Waveform size={18} lineWeight={3} speed={1} color="#aaa" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{sendByMe ? `Calling` : `Incoming call`}
</span>
</div>
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
<Tooltip tip={"Disconnect"} placement="top">
<button
onClick={handleCancel}
className="flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg"
>
<IconCallOff className="w-6 h-6" />
</button>
</Tooltip>
{!sendByMe && (
<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-lg"
>
<IconCallAnswer className="w-6 h-6 fill-white" />
</button>
</Tooltip>
)}
</div>
</div>
</motion.aside>
</div>
);
};
export default DMCalling;
export default DMCalling;
+272 -185
View File
@@ -1,200 +1,287 @@
import { MouseEvent, SetStateAction, useState, Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { useDispatch } from 'react-redux';
import Tippy from '@tippyjs/react';
import { Dispatch, MouseEvent, SetStateAction, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import Tippy from "@tippyjs/react";
import { ICameraVideoTrack, IMicrophoneAudioTrack } from "agora-rtc-sdk-ng";
import clsx from "clsx";
import Tooltip from '../Tooltip';
import IconMicOff from '@/assets/icons/mic.off.svg';
import IconMic from '@/assets/icons/mic.on.svg';
import IconCameraOff from '@/assets/icons/camera.off.svg';
import IconArrow from '@/assets/icons/arrow.down.mini.svg';
import IconCamera from '@/assets/icons/camera.svg';
import IconFullscreen from '@/assets/icons/fullscreen.svg';
import IconScreen from '@/assets/icons/share.screen.svg';
import IconCheck from '@/assets/icons/check.sign.svg';
import IconCallOff from '@/assets/icons/call.off.svg';
import { ChatContext } from '@/types/common';
import useVoice from './useVoice';
// import { updateChannelVisibleAside, updateDMVisibleAside } from '@/app/slices/footprint';
import { DeviceInfo, MediaDeviceKind, updateSelectDeviceId } from '@/app/slices/voice';
import { useAppSelector } from '@/app/store';
import { ICameraVideoTrack, IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng';
import { DeviceInfo, MediaDeviceKind, updateSelectDeviceId } from "@/app/slices/voice";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import IconArrow from "@/assets/icons/arrow.down.mini.svg";
import IconCallOff from "@/assets/icons/call.off.svg";
import IconCameraOff from "@/assets/icons/camera.off.svg";
import IconCamera from "@/assets/icons/camera.svg";
import IconCheck from "@/assets/icons/check.sign.svg";
import IconFullscreen from "@/assets/icons/fullscreen.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 Tooltip from "../Tooltip";
import useVoice from "./useVoice";
type DeviceType = "audio" | "video";
// https://docportal.shengwang.cn/cn/video-call-4.x/test_switch_device_web_ng?platform=Web
const DeviceList = ({ type, visible, setVisible, devices }: {
type: DeviceType,
visible: VisibleType,
setVisible: Dispatch<SetStateAction<VisibleType>>,
devices: {
title: string,
list: DeviceInfo[],
selected: string
}[],
const DeviceList = ({
type,
visible,
setVisible,
devices
}: {
type: DeviceType;
visible: VisibleType;
setVisible: Dispatch<SetStateAction<VisibleType>>;
devices: {
title: string;
list: DeviceInfo[];
selected: string;
}[];
}) => {
const loginUid = useAppSelector(store => store.authData.user?.uid ?? 0);
const dispatch = useDispatch();
// const { t } = useTranslation("chat");
const toggleVisible = (evt: MouseEvent<HTMLDivElement>) => {
evt.stopPropagation();
setVisible((prev: VisibleType) => prev == type ? "" : type);
};
const handleSelect = (evt: MouseEvent<HTMLLIElement>) => {
// evt.stopPropagation();
const { deviceId = "", kind, selected = "" } = evt.currentTarget.dataset;
if (selected == deviceId || !kind) return;
switch (kind as MediaDeviceKind) {
case "audiooutput":
case "audioinput": {
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
if (localAudioTrack) {
localAudioTrack.setDevice(deviceId).then(() => {
console.log("audioinput setDevice", deviceId);
}
).catch((err) => {
console.log("audioinput setDevice error", err);
});
}
}
break;
case "videoinput": {
const localCameraTrack = window.VIDEO_TRACK_MAP[loginUid] as ICameraVideoTrack;
if (localCameraTrack) {
localCameraTrack.setDevice(deviceId).then(() => {
console.log("videoinput setDevice", deviceId);
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
}
).catch((err) => {
console.log("videoinput setDevice error", err);
});
}
}
break;
default:
break;
const loginUid = useAppSelector((store) => store.authData.user?.uid ?? 0);
const dispatch = useDispatch();
// const { t } = useTranslation("chat");
const toggleVisible = (evt: MouseEvent<HTMLDivElement>) => {
evt.stopPropagation();
setVisible((prev: VisibleType) => (prev == type ? "" : type));
};
const handleSelect = (evt: MouseEvent<HTMLLIElement>) => {
// evt.stopPropagation();
const { deviceId = "", kind, selected = "" } = evt.currentTarget.dataset;
if (selected == deviceId || !kind) return;
switch (kind as MediaDeviceKind) {
case "audiooutput":
case "audioinput":
{
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
if (localAudioTrack) {
localAudioTrack
.setDevice(deviceId)
.then(() => {
console.log("audioinput setDevice", deviceId);
})
.catch((err) => {
console.log("audioinput setDevice error", err);
});
}
}
};
const handleBlockClick = (evt: MouseEvent<HTMLDivElement>) => {
evt.stopPropagation();
};
return <Tippy
onClickOutside={() => setVisible("")}
interactive
popperOptions={{ strategy: "fixed" }}
visible={visible == type}
placement="top-start"
content={<div onClick={handleBlockClick} className="px-3 pb-3 bg-white dark:bg-gray-800 overflow-auto rounded-lg flex flex-col gap-3 divide-gray-500/50 divide-y-[1px] items-start relative drop-shadow">
{devices.map(({ title, list, selected }) => {
console.log("device selected", title, selected);
if (list.length == 0) return null;
return <div key={title} className="w-full flex flex-col items-start gap-2 pt-3">
<p className="text-gray-500 text-xs">{title}</p>
<ul className="w-full flex flex-col gap-4">
{list.map(({ deviceId, kind, label }) => {
return (
<li data-selected={selected} data-kind={kind} data-device-id={deviceId} key={label} className="relative rounded-sm cursor-pointer flex items-center justify-between gap-4 text-gray-500 hover:text-gray-900 dark:text-gray-300 hover:dark:text-gray-100 font-semibold text-sm whitespace-nowrap"
onClick={handleSelect}>
{label}
<i className='w-4 h-3'>
{selected == deviceId &&
<IconCheck className={clsx("shrink-0")} />
}
</i>
</li>
);
})}
</ul>
</div>;
})}
break;
case "videoinput":
{
const localCameraTrack = window.VIDEO_TRACK_MAP[loginUid] as ICameraVideoTrack;
if (localCameraTrack) {
localCameraTrack
.setDevice(deviceId)
.then(() => {
console.log("videoinput setDevice", deviceId);
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
})
.catch((err) => {
console.log("videoinput setDevice error", err);
});
}
}
</div>}
>
<div onClick={toggleVisible} className="group p-1 absolute rounded-sm top-0.5 right-0.5 hover:bg-gray-300/50" role='button' >
<IconArrow className={clsx("w-2 fill-gray-600 group-hover:fill-gray-900 dark:fill-white transition-transform", visible == type && "rotate-180")} />
break;
default:
break;
}
};
const handleBlockClick = (evt: MouseEvent<HTMLDivElement>) => {
evt.stopPropagation();
};
return (
<Tippy
onClickOutside={() => setVisible("")}
interactive
popperOptions={{ strategy: "fixed" }}
visible={visible == type}
placement="top-start"
content={
<div
onClick={handleBlockClick}
className="px-3 pb-3 bg-white dark:bg-gray-800 overflow-auto rounded-lg flex flex-col gap-3 divide-gray-500/50 divide-y-[1px] items-start relative drop-shadow"
>
{devices.map(({ title, list, selected }) => {
console.log("device selected", title, selected);
if (list.length == 0) return null;
return (
<div key={title} className="w-full flex flex-col items-start gap-2 pt-3">
<p className="text-gray-500 text-xs">{title}</p>
<ul className="w-full flex flex-col gap-4">
{list.map(({ deviceId, kind, label }) => {
return (
<li
data-selected={selected}
data-kind={kind}
data-device-id={deviceId}
key={label}
className="relative rounded-sm cursor-pointer flex items-center justify-between gap-4 text-gray-500 hover:text-gray-900 dark:text-gray-300 hover:dark:text-gray-100 font-semibold text-sm whitespace-nowrap"
onClick={handleSelect}
>
{label}
<i className="w-4 h-3">
{selected == deviceId && <IconCheck className={clsx("shrink-0")} />}
</i>
</li>
);
})}
</ul>
</div>
);
})}
</div>
</Tippy>;
}
>
<div
onClick={toggleVisible}
className="group p-1 absolute rounded-sm top-0.5 right-0.5 hover:bg-gray-300/50"
role="button"
>
<IconArrow
className={clsx(
"w-2 fill-gray-600 group-hover:fill-gray-900 dark:fill-white transition-transform",
visible == type && "rotate-180"
)}
/>
</div>
</Tippy>
);
};
type VisibleType = "audio" | "video" | "";
type Props = {
mode?: "channel" | "dm" | "fullscreen",
id: number,
context: ChatContext
}
const Operations = ({ id, context, mode = "channel" }: Props) => {
const [panelVisible, setPanelVisible] = useState<VisibleType>("");
const { enterFullscreen, voicingInfo,
leave, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen,
audioInputDevices, audioOutputDevices, videoInputDevices, videoInputDeviceId, audioInputDeviceId, audioOutputDeviceId
} = useVoice({ id, context });
const { t } = useTranslation("chat");
if (!voicingInfo) return null;
const { muted, video, shareScreen } = voicingInfo;
const baseButtonClass = clsx("flex-center py-2 px-3 rounded bg-gray-100 dark:bg-gray-900 relative disabled:pointer-events-none disabled:opacity-50");
const baseIconClass = clsx("w-[25px] h-6 m-auto fill-gray-700 dark:fill-gray-300");
return <>
<Tooltip disabled={panelVisible == "audio"} tip={muted ? t("unmute") : t("mute")} placement="top">
<button disabled={audioInputDevices.length == 0 && audioOutputDevices.length == 0} onClick={setMute.bind(null, !muted)} className={baseButtonClass}>
{muted ? <IconMicOff className={baseIconClass} /> : <IconMic className={baseIconClass} />}
<DeviceList
visible={panelVisible}
setVisible={setPanelVisible}
type='audio'
devices={[
{
title: "Input Device",
list: audioInputDevices,
selected: audioInputDeviceId
},
{
title: "Output Device",
list: audioOutputDevices,
selected: audioOutputDeviceId
}
]}
/>
</button>
</Tooltip>
<Tooltip disabled={panelVisible == "video"} tip={video ? t("camera_off") : t("camera_on")} placement="top">
<button disabled={videoInputDevices.length == 0} onClick={video ? closeCamera : openCamera} className={baseButtonClass}>
{video ? <IconCamera className={baseIconClass} /> : <IconCameraOff className={baseIconClass} />}
<DeviceList
visible={panelVisible}
setVisible={setPanelVisible}
type='video'
devices={[
{
title: "Camera Device",
list: videoInputDevices,
selected: videoInputDeviceId
}
]}
/>
</button>
</Tooltip>
<Tooltip tip={"Share Screen"} placement="top">
<button onClick={shareScreen ? stopShareScreen : startShareScreen} className={clsx("py-2 px-3 rounded", shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900")}>
<IconScreen className={clsx("w-6 h-6 dark:fill-gray-300", shareScreen ? "fill-gray-200" : "fill-gray-800")} />
</button>
</Tooltip>
{mode !== "fullscreen" && <Tooltip tip={"Fullscreen"} placement="top">
<button onClick={enterFullscreen.bind(null, undefined)} className={baseButtonClass}>
<IconFullscreen className={baseIconClass} />
</button>
</Tooltip>}
{mode !== "dm" && <Tooltip tip={t("leave_voice")} placement="top" >
<button onClick={leave} className={clsx('py-2 px-3 rounded bg-red-600 hover:bg-red-700', mode !== "fullscreen" && "col-span-4")}>
<IconCallOff className="m-auto w-[25px] h-6" />
</button>
</Tooltip>}
</>;
mode?: "channel" | "dm" | "fullscreen";
id: number;
context: ChatContext;
};
export default Operations;
const Operations = ({ id, context, mode = "channel" }: Props) => {
const [panelVisible, setPanelVisible] = useState<VisibleType>("");
const {
enterFullscreen,
voicingInfo,
leave,
setMute,
closeCamera,
openCamera,
startShareScreen,
stopShareScreen,
audioInputDevices,
audioOutputDevices,
videoInputDevices,
videoInputDeviceId,
audioInputDeviceId,
audioOutputDeviceId
} = useVoice({ id, context });
const { t } = useTranslation("chat");
if (!voicingInfo) return null;
const { muted, video, shareScreen } = voicingInfo;
const baseButtonClass = clsx(
"flex-center py-2 px-3 rounded bg-gray-100 dark:bg-gray-900 relative disabled:pointer-events-none disabled:opacity-50"
);
const baseIconClass = clsx("w-[25px] h-6 m-auto fill-gray-700 dark:fill-gray-300");
return (
<>
<Tooltip
disabled={panelVisible == "audio"}
tip={muted ? t("unmute") : t("mute")}
placement="top"
>
<button
disabled={audioInputDevices.length == 0 && audioOutputDevices.length == 0}
onClick={setMute.bind(null, !muted)}
className={baseButtonClass}
>
{muted ? <IconMicOff className={baseIconClass} /> : <IconMic className={baseIconClass} />}
<DeviceList
visible={panelVisible}
setVisible={setPanelVisible}
type="audio"
devices={[
{
title: "Input Device",
list: audioInputDevices,
selected: audioInputDeviceId
},
{
title: "Output Device",
list: audioOutputDevices,
selected: audioOutputDeviceId
}
]}
/>
</button>
</Tooltip>
<Tooltip
disabled={panelVisible == "video"}
tip={video ? t("camera_off") : t("camera_on")}
placement="top"
>
<button
disabled={videoInputDevices.length == 0}
onClick={video ? closeCamera : openCamera}
className={baseButtonClass}
>
{video ? (
<IconCamera className={baseIconClass} />
) : (
<IconCameraOff className={baseIconClass} />
)}
<DeviceList
visible={panelVisible}
setVisible={setPanelVisible}
type="video"
devices={[
{
title: "Camera Device",
list: videoInputDevices,
selected: videoInputDeviceId
}
]}
/>
</button>
</Tooltip>
<Tooltip tip={"Share Screen"} placement="top">
<button
onClick={shareScreen ? stopShareScreen : startShareScreen}
className={clsx(
"py-2 px-3 rounded",
shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900"
)}
>
<IconScreen
className={clsx(
"w-6 h-6 dark:fill-gray-300",
shareScreen ? "fill-gray-200" : "fill-gray-800"
)}
/>
</button>
</Tooltip>
{mode !== "fullscreen" && (
<Tooltip tip={"Fullscreen"} placement="top">
<button onClick={enterFullscreen.bind(null, undefined)} className={baseButtonClass}>
<IconFullscreen className={baseIconClass} />
</button>
</Tooltip>
)}
{mode !== "dm" && (
<Tooltip tip={t("leave_voice")} placement="top">
<button
onClick={leave}
className={clsx(
"py-2 px-3 rounded bg-red-600 hover:bg-red-700",
mode !== "fullscreen" && "col-span-4"
)}
>
<IconCallOff className="m-auto w-[25px] h-6" />
</button>
</Tooltip>
)}
</>
);
};
export default Operations;
+217 -193
View File
@@ -1,204 +1,228 @@
import AgoraRTC from 'agora-rtc-sdk-ng';
import { memo, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useGetAgoraChannelsQuery, useGetAgoraStatusQuery, useLazyGetAgoraUsersByChannelQuery } from '../../app/services/server';
import { addVoiceMember, removeVoiceMember, updateCallInfo, updateConnectionState, updateDevices, updateVoicingMember, updateVoicingNetworkQuality } from '../../app/slices/voice';
import { useAppSelector } from '../../app/store';
import { playAgoraVideo } from '../../utils';
import useVoice from './useVoice';
import DMCalling from './DMCalling';
import { memo, useEffect } from "react";
import { useDispatch } from "react-redux";
import AgoraRTC from "agora-rtc-sdk-ng";
AgoraRTC.setLogLevel(process.env.NODE_ENV === 'development' ? 0 : 4);
import {
useGetAgoraChannelsQuery,
useGetAgoraStatusQuery,
useLazyGetAgoraUsersByChannelQuery
} from "../../app/services/server";
import {
addVoiceMember,
removeVoiceMember,
updateCallInfo,
updateConnectionState,
updateDevices,
updateVoicingMember,
updateVoicingNetworkQuality
} from "../../app/slices/voice";
import { useAppSelector } from "../../app/store";
import { playAgoraVideo } from "../../utils";
import DMCalling from "./DMCalling";
import useVoice from "./useVoice";
AgoraRTC.setLogLevel(process.env.NODE_ENV === "development" ? 0 : 4);
window.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {};
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
// let tmpUids: number[] = [];
const Voice = () => {
const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector(store => {
return {
voicingInfo: store.voice.voicing,
voiceList: store.voice.list,
from: store.voice.callingFrom,
to: store.voice.callingTo,
loginUid: store.authData.user?.uid
};
});
const { data: enabled } = useGetAgoraStatusQuery();
const [getUsersByChannel] = useLazyGetAgoraUsersByChannelQuery();
useGetAgoraChannelsQuery({ page_no: 0, page_size: 100 }, {
skip: !enabled || !navigator.onLine,
pollingInterval: 5000
});
const dispatch = useDispatch();
useEffect(() => {
const initializeAgoraClient = async () => {
// 创建agora客户端实例
const agoraEngine = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
// 无论频道内是否有人说话,都会每两秒返回提示音量
agoraEngine.enableAudioVolumeIndicator();
// Listen for the "user-published" event to retrieve an AgoraRTCRemoteUser object.
agoraEngine.on("user-published", async (user, mediaType) => {
// Subscribe to the remote user when the SDK triggers the "user-published" event.
await agoraEngine.subscribe(user, mediaType);
console.log(user, " has published at the channel");
if (mediaType == "audio" && user.hasAudio) {
// 播放远端音频
user.audioTrack?.play();
window.VOICE_TRACK_MAP[+user.uid] = user.audioTrack;
}
if (mediaType == "video" && user.hasVideo) {
// const label = user.videoTrack?.getMediaStreamTrack()?.label;
// console.log("labell", label);
// if (label?.includes("screen")) {
// // 远端用户共享屏幕
// // const screenTrack = user.videoTrack as ShareScreenTrack;
// // screenTrack.on("track-ended", () => {
// // // 远端用户停止共享屏幕
// dispatch(updateVoicingMember({ uid: +user.uid, info: { shareScreen: true } }));
// }
dispatch(updateVoicingMember({ uid: +user.uid, info: { video: true } }));
window.VIDEO_TRACK_MAP[+user.uid] = user.videoTrack;
playAgoraVideo(+user.uid);
}
agoraEngine.on("user-unpublished", (user) => {
if (!user.hasAudio) {
// 远端用户取消了音频(muted)
dispatch(updateVoicingMember({ uid: +user.uid, info: { muted: true } }));
}
if (!user.hasVideo) {
// 远端用户取消了视频
dispatch(updateVoicingMember({ uid: +user.uid, info: { video: false, shareScreen: false } }));
// 注销本地视频变量
window.VIDEO_TRACK_MAP[+user.uid] = null;
// 关闭视频后,需要把视频的高度设置回去
const playerEle = document.querySelector(`#CAMERA_${user.uid}`) as HTMLElement;
playerEle.classList.remove("h-[120px]");
}
});
//remote user leave
agoraEngine.on("user-left", (user, reason) => {
console.log(user, "has left the channel", reason);
switch (reason) {
case "Quit":
case "ServerTimeOut": {
dispatch(removeVoiceMember(+user.uid as number));
// clear tracks
window.VOICE_TRACK_MAP[+user.uid] = null;
window.VIDEO_TRACK_MAP[+user.uid] = null;
// if (voicingInfo && voicingInfo.context == "dm") {
// // 有人离开,就断开
// dispatch(updateCallInfo({ from: 0 }));
// }
}
break;
default:
break;
}
});
// 报告频道内正在说话的远端用户及其音量的回调。
agoraEngine.on("volume-indicator", (vols) => {
vols.forEach((vol, index) => {
console.log(`${index} UID ${vol.uid} Level ${vol.level}`);
const { uid, level } = vol;
dispatch(updateVoicingMember({ uid: +uid, info: { speakingVolume: level } }));
});
});
// 信号强度
agoraEngine.on("network-quality", (qlt) => {
const { downlinkNetworkQuality } = qlt;
dispatch(updateVoicingNetworkQuality(downlinkNetworkQuality));
});
// 连接状态有变化
agoraEngine.on("connection-state-change", (state, prevState, reason) => {
console.log("connection-state-change", state, prevState, reason);
dispatch(updateConnectionState(state));
});
// 用户状态变化
agoraEngine.on("user-info-updated", (uid, msg) => {
console.log("user-info-updated", uid, msg);
switch (msg) {
case "mute-audio":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { muted: true } }));
break;
case "unmute-audio":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { muted: false } }));
break;
case "mute-video":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { video: false } }));
break;
case "unmute-video":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { video: true } }));
break;
default:
break;
}
});
});
// 有新用户加入
agoraEngine.on("user-joined", async (user) => {
console.log(user.uid, " has joined the channel", user);
dispatch(addVoiceMember(+user.uid));
});
AgoraRTC.onMicrophoneChanged = (info) => {
console.log("onMicrophoneChanged", info);
};
AgoraRTC.getDevices().then((devices) => {
console.log("devices", devices);
dispatch(updateDevices(devices.map((d) => {
const { deviceId, groupId, label, kind } = d;
return { deviceId, groupId, label, kind };
})));
}).catch((err) => {
console.log("err", err);
});
window.VOICE_CLIENT = agoraEngine;
};
const handlePageUnload = (evt: BeforeUnloadEvent) => {
console.log("unload");
if (window.VOICE_CLIENT?.connectionState === "CONNECTED") {
evt.preventDefault();
return (evt.returnValue = "");
}
};
window.addEventListener("beforeunload", handlePageUnload, { capture: true });
if (!window.VOICE_CLIENT) {
initializeAgoraClient();
const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector((store) => {
return {
voicingInfo: store.voice.voicing,
voiceList: store.voice.list,
from: store.voice.callingFrom,
to: store.voice.callingTo,
loginUid: store.authData.user?.uid
};
});
const { data: enabled } = useGetAgoraStatusQuery();
const [getUsersByChannel] = useLazyGetAgoraUsersByChannelQuery();
useGetAgoraChannelsQuery(
{ page_no: 0, page_size: 100 },
{
skip: !enabled || !navigator.onLine,
pollingInterval: 5000
}
);
const dispatch = useDispatch();
useEffect(() => {
const initializeAgoraClient = async () => {
// 创建agora客户端实例
const agoraEngine = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
// 无论频道内是否有人说话,都会每两秒返回提示音量
agoraEngine.enableAudioVolumeIndicator();
// Listen for the "user-published" event to retrieve an AgoraRTCRemoteUser object.
agoraEngine.on("user-published", async (user, mediaType) => {
// Subscribe to the remote user when the SDK triggers the "user-published" event.
await agoraEngine.subscribe(user, mediaType);
console.log(user, " has published at the channel");
if (mediaType == "audio" && user.hasAudio) {
// 播放远端音频
user.audioTrack?.play();
window.VOICE_TRACK_MAP[+user.uid] = user.audioTrack;
}
if (mediaType == "video" && user.hasVideo) {
// const label = user.videoTrack?.getMediaStreamTrack()?.label;
// console.log("labell", label);
return () => {
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
};
}, [voicingInfo]);
useEffect(() => {
// 有人呼叫我
const callMeList = voiceList.filter(item => item.context == "dm" && item.id == loginUid);
if (callMeList.length) {
const [firstCall] = callMeList;
const { memberCount, channelName, id } = firstCall;
if (memberCount == 1) {
// 呼叫的人在频道里
getUsersByChannel(channelName).then(resp => {
const [uid] = resp.data ?? [];
if (uid && uid != loginUid) {
dispatch(updateCallInfo({ from: uid, to: id }));
}
});
}
// if (label?.includes("screen")) {
// // 远端用户共享屏幕
// // const screenTrack = user.videoTrack as ShareScreenTrack;
// // screenTrack.on("track-ended", () => {
// // // 远端用户停止共享屏幕
// dispatch(updateVoicingMember({ uid: +user.uid, info: { shareScreen: true } }));
// }
dispatch(updateVoicingMember({ uid: +user.uid, info: { video: true } }));
window.VIDEO_TRACK_MAP[+user.uid] = user.videoTrack;
playAgoraVideo(+user.uid);
}
// else {
// dispatch(updateCallInfo({ from: 0, to: 0, calling: false }));
// }
}, [voiceList, loginUid]);
// return <DMCalling uid={1} sendByMe={calling !== loginUid} />;
if (from !== 0) return <DMCalling from={from} to={to} />;
return null;
agoraEngine.on("user-unpublished", (user) => {
if (!user.hasAudio) {
// 远端用户取消了音频(muted)
dispatch(updateVoicingMember({ uid: +user.uid, info: { muted: true } }));
}
if (!user.hasVideo) {
// 远端用户取消了视频
dispatch(
updateVoicingMember({ uid: +user.uid, info: { video: false, shareScreen: false } })
);
// 注销本地视频变量
window.VIDEO_TRACK_MAP[+user.uid] = null;
// 关闭视频后,需要把视频的高度设置回去
const playerEle = document.querySelector(`#CAMERA_${user.uid}`) as HTMLElement;
playerEle.classList.remove("h-[120px]");
}
});
//remote user leave
agoraEngine.on("user-left", (user, reason) => {
console.log(user, "has left the channel", reason);
switch (reason) {
case "Quit":
case "ServerTimeOut":
{
dispatch(removeVoiceMember(+user.uid as number));
// clear tracks
window.VOICE_TRACK_MAP[+user.uid] = null;
window.VIDEO_TRACK_MAP[+user.uid] = null;
// if (voicingInfo && voicingInfo.context == "dm") {
// // 有人离开,就断开
// dispatch(updateCallInfo({ from: 0 }));
// }
}
break;
default:
break;
}
});
// 报告频道内正在说话的远端用户及其音量的回调。
agoraEngine.on("volume-indicator", (vols) => {
vols.forEach((vol, index) => {
console.log(`${index} UID ${vol.uid} Level ${vol.level}`);
const { uid, level } = vol;
dispatch(updateVoicingMember({ uid: +uid, info: { speakingVolume: level } }));
});
});
// 信号强度
agoraEngine.on("network-quality", (qlt) => {
const { downlinkNetworkQuality } = qlt;
dispatch(updateVoicingNetworkQuality(downlinkNetworkQuality));
});
// 连接状态有变化
agoraEngine.on("connection-state-change", (state, prevState, reason) => {
console.log("connection-state-change", state, prevState, reason);
dispatch(updateConnectionState(state));
});
// 用户状态变化
agoraEngine.on("user-info-updated", (uid, msg) => {
console.log("user-info-updated", uid, msg);
switch (msg) {
case "mute-audio":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { muted: true } }));
break;
case "unmute-audio":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { muted: false } }));
break;
case "mute-video":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { video: false } }));
break;
case "unmute-video":
// todo
dispatch(updateVoicingMember({ uid: +uid, info: { video: true } }));
break;
default:
break;
}
});
});
// 有新用户加入
agoraEngine.on("user-joined", async (user) => {
console.log(user.uid, " has joined the channel", user);
dispatch(addVoiceMember(+user.uid));
});
AgoraRTC.onMicrophoneChanged = (info) => {
console.log("onMicrophoneChanged", info);
};
AgoraRTC.getDevices()
.then((devices) => {
console.log("devices", devices);
dispatch(
updateDevices(
devices.map((d) => {
const { deviceId, groupId, label, kind } = d;
return { deviceId, groupId, label, kind };
})
)
);
})
.catch((err) => {
console.log("err", err);
});
window.VOICE_CLIENT = agoraEngine;
};
const handlePageUnload = (evt: BeforeUnloadEvent) => {
console.log("unload");
if (window.VOICE_CLIENT?.connectionState === "CONNECTED") {
evt.preventDefault();
return (evt.returnValue = "");
}
};
window.addEventListener("beforeunload", handlePageUnload, { capture: true });
if (!window.VOICE_CLIENT) {
initializeAgoraClient();
}
return () => {
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
};
}, [voicingInfo]);
useEffect(() => {
// 有人呼叫我
const callMeList = voiceList.filter((item) => item.context == "dm" && item.id == loginUid);
if (callMeList.length) {
const [firstCall] = callMeList;
const { memberCount, channelName, id } = firstCall;
if (memberCount == 1) {
// 呼叫的人在频道里
getUsersByChannel(channelName).then((resp) => {
const [uid] = resp.data ?? [];
if (uid && uid != loginUid) {
dispatch(updateCallInfo({ from: uid, to: id }));
}
});
}
}
// else {
// dispatch(updateCallInfo({ from: 0, to: 0, calling: false }));
// }
}, [voiceList, loginUid]);
// return <DMCalling uid={1} sendByMe={calling !== loginUid} />;
if (from !== 0) return <DMCalling from={from} to={to} />;
return null;
};
export { useVoice };
export default memo(Voice);
export default memo(Voice);
+134 -117
View File
@@ -1,127 +1,144 @@
import { useEffect, useRef, useState } from 'react';
import dayjs from 'dayjs';
import clsx from 'clsx';
import WaveSurfer from 'wavesurfer.js';
import IconPause from '@/assets/icons/pause.svg';
import IconRefresh from '@/assets/icons/refresh.svg';
import IconPlay from '@/assets/icons/play.circle.svg';
import BASE_URL from '../app/config';
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import dayjs from "dayjs";
import WaveSurfer from "wavesurfer.js";
import IconPause from "@/assets/icons/pause.svg";
import IconPlay from "@/assets/icons/play.circle.svg";
import IconRefresh from "@/assets/icons/refresh.svg";
import BASE_URL from "../app/config";
export type VoiceMessageProps = {
type: string,
url: string,
secure_url: string,
}
type: string;
url: string;
secure_url: string;
};
// 全局存储Voice信息
const VoiceMap: { [key: string]: WaveSurfer | null } = {};
const VoiceMessage = ({ file_path }: { file_path: string }) => {
// const waveRef = useRef<WaveSurfer | null>(null);
const containerRef = useRef(null);
const [status, setStatus] = useState<"loading" | "error" | "ready">("loading");
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState("");
// const [audio, setAudio] = useState<HTMLAudioElement>(new Audio(`${BASE_URL}/resource/file?file_path=${encodeURIComponent(
// file_path
// )}`));
const initWave = (file_path: string) => {
let wave: WaveSurfer | null = null;
const audioSrc = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(
file_path
)}`;
wave = WaveSurfer.create({
container: containerRef.current ?? "",
// maxCanvasWidth: 200,
height: 32,
waveColor: '#0BA5EC',
cursorColor: '#0BA5AA',
progressColor: "#0BA5AA",
hideScrollbar: true,
// mediaControls: true,
normalize: true,
responsive: true
});
wave.load(audioSrc);
// const waveRef = useRef<WaveSurfer | null>(null);
const containerRef = useRef(null);
const [status, setStatus] = useState<"loading" | "error" | "ready">("loading");
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState("");
// const [audio, setAudio] = useState<HTMLAudioElement>(new Audio(`${BASE_URL}/resource/file?file_path=${encodeURIComponent(
// file_path
// )}`));
const initWave = (file_path: string) => {
let wave: WaveSurfer | null = null;
const audioSrc = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(file_path)}`;
wave = WaveSurfer.create({
container: containerRef.current ?? "",
// maxCanvasWidth: 200,
height: 32,
waveColor: "#0BA5EC",
cursorColor: "#0BA5AA",
progressColor: "#0BA5AA",
hideScrollbar: true,
// mediaControls: true,
normalize: true,
responsive: true
});
wave.load(audioSrc);
wave.on('error', function (err) {
console.log("voice message error", err);
setStatus("error");
const current = VoiceMap[file_path];
if (current) {
current.destroy();
}
});
wave.on('play', function () {
setPlaying(true);
});
wave.on('pause', function () {
setPlaying(false);
});
wave.on('ready', function () {
setStatus("ready");
const current = VoiceMap[file_path];
if (current) {
const dur = current.getDuration();
const num = Math.ceil(dur);
const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss');
setDuration(durDisplay);
}
// tricky
if (containerRef.current) {
const container = containerRef.current as HTMLDivElement;
const w = (container.querySelector("wave>canvas") as HTMLCanvasElement)?.width;
container.style.width = `${w}px`;
}
});
VoiceMap[file_path] = wave;
wave.on("error", function (err) {
console.log("voice message error", err);
setStatus("error");
const current = VoiceMap[file_path];
if (current) {
current.destroy();
}
});
wave.on("play", function () {
setPlaying(true);
});
wave.on("pause", function () {
setPlaying(false);
});
wave.on("ready", function () {
setStatus("ready");
const current = VoiceMap[file_path];
if (current) {
const dur = current.getDuration();
const num = Math.ceil(dur);
const durDisplay = dayjs.duration(num, "seconds").format("mm:ss");
setDuration(durDisplay);
}
// tricky
if (containerRef.current) {
const container = containerRef.current as HTMLDivElement;
const w = (container.querySelector("wave>canvas") as HTMLCanvasElement)?.width;
container.style.width = `${w}px`;
}
});
VoiceMap[file_path] = wave;
};
useEffect(() => {
if (containerRef.current && file_path) {
initWave(file_path);
}
return () => {
const current = VoiceMap[file_path];
if (current) {
current.destroy();
}
};
useEffect(() => {
if (containerRef.current && file_path) {
initWave(file_path);
}
return () => {
const current = VoiceMap[file_path];
if (current) {
current.destroy();
}, [file_path]);
const handleClick = () => {
const current = VoiceMap[file_path];
if (current) {
if (!current.isPlaying()) {
// 先停掉其他的
Object.keys(VoiceMap).forEach((key) => {
try {
const item = VoiceMap[key];
if (item && item.backend && item.isPlaying()) {
item.stop();
}
};
}, [file_path]);
const handleClick = () => {
const current = VoiceMap[file_path];
if (current) {
if (!current.isPlaying()) {
// 先停掉其他的
Object.keys(VoiceMap).forEach((key) => {
try {
const item = VoiceMap[key];
if (item && item.backend && item.isPlaying()) {
item.stop();
}
} catch (error) {
console.log(error);
}
}
);
}
current.playPause();
}
};
const handleReload = () => {
initWave(file_path);
};
const notReady = status !== "ready";
return (
<div className={clsx("relative whitespace-nowrap select-none flex items-center gap-2 p-2 rounded-lg max-w-sm", status === "error" ? "bg-red-200" : "bg-primary-100 dark:bg-primary-900")}>
<button className='disabled:opacity-60' onClick={handleClick} disabled={notReady}>
{playing ? <IconPause className="stroke-primary-500" /> : <IconPlay className="stroke-primary-500" />}
</button>
<div ref={containerRef} className={clsx('flex-1 h-8 min-w-[100px]')} >
{status == "loading" && <span className='text-xs'>Loading voice message...</span>}
{status == "error" && <span className='text-xs text-red-800'>Load voice message error</span>}
</div>
{status !== "error" && <time className='text-primary-500 text-xs whitespace-nowrap text-left'>{duration}</time>}
{status === "error" && <IconRefresh role="button" className="absolute -right-6 top-1/2 -translate-y-1/2 w-4 h-4 stroke-primary-600" onClick={handleReload} />}
</div>
);
} catch (error) {
console.log(error);
}
});
}
current.playPause();
}
};
const handleReload = () => {
initWave(file_path);
};
const notReady = status !== "ready";
return (
<div
className={clsx(
"relative whitespace-nowrap select-none flex items-center gap-2 p-2 rounded-lg max-w-sm",
status === "error" ? "bg-red-200" : "bg-primary-100 dark:bg-primary-900"
)}
>
<button className="disabled:opacity-60" onClick={handleClick} disabled={notReady}>
{playing ? (
<IconPause className="stroke-primary-500" />
) : (
<IconPlay className="stroke-primary-500" />
)}
</button>
<div ref={containerRef} className={clsx("flex-1 h-8 min-w-[100px]")}>
{status == "loading" && <span className="text-xs">Loading voice message...</span>}
{status == "error" && (
<span className="text-xs text-red-800">Load voice message error</span>
)}
</div>
{status !== "error" && (
<time className="text-primary-500 text-xs whitespace-nowrap text-left">{duration}</time>
)}
{status === "error" && (
<IconRefresh
role="button"
className="absolute -right-6 top-1/2 -translate-y-1/2 w-4 h-4 stroke-primary-600"
onClick={handleReload}
/>
)}
</div>
);
};
export default VoiceMessage;
export default VoiceMessage;

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