build: format the code with prettier
This commit is contained in:
+2
-2
@@ -19,8 +19,8 @@
|
|||||||
"^@/assets/(.*)$",
|
"^@/assets/(.*)$",
|
||||||
"^[./]"
|
"^[./]"
|
||||||
],
|
],
|
||||||
|
"importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
|
||||||
"importOrderCaseInsensitive": true,
|
"importOrderCaseInsensitive": true,
|
||||||
"importOrderMergeDuplicateImports": true,
|
"importOrderMergeDuplicateImports": true,
|
||||||
"importOrderSeparation": true,
|
"importOrderSeparation": true
|
||||||
"importOrderGroupNamespaceSpecifiers": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -62,5 +62,8 @@
|
|||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { hideAll } from "tippy.js";
|
import { hideAll } from "tippy.js";
|
||||||
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import IconInvite from "@/assets/icons/add.person.svg";
|
import IconInvite from "@/assets/icons/add.person.svg";
|
||||||
import IconMention from "@/assets/icons/mention.svg";
|
import IconMention from "@/assets/icons/mention.svg";
|
||||||
import IconSearch from "@/assets/icons/search.svg";
|
import IconSearch from "@/assets/icons/search.svg";
|
||||||
import ChannelIcon from "./ChannelIcon";
|
import ChannelIcon from "./ChannelIcon";
|
||||||
import ChannelModal from "./ChannelModal";
|
import ChannelModal from "./ChannelModal";
|
||||||
import UsersModal from "./UsersModal";
|
|
||||||
import InviteModal from "./InviteModal";
|
import InviteModal from "./InviteModal";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import SearchUser from "./SearchUser";
|
import SearchUser from "./SearchUser";
|
||||||
import ServerVersionChecker from "./ServerVersionChecker";
|
import ServerVersionChecker from "./ServerVersionChecker";
|
||||||
|
import UsersModal from "./UsersModal";
|
||||||
|
|
||||||
export default function AddEntriesMenu() {
|
export default function AddEntriesMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -53,7 +54,8 @@ export default function AddEntriesMenu() {
|
|||||||
setChannelModalVisible(false);
|
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";
|
const iconClass = "w-5 h-5 dark:fill-gray-300";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useUpdateAutoDeleteMsgMutation } from "@/app/services/user";
|
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 SaveTip from "./SaveTip";
|
||||||
import StyledRadio from "./styled/Radio";
|
|
||||||
import { ChatContext } from '../types/common';
|
|
||||||
import StyledButton from "./styled/Button";
|
import StyledButton from "./styled/Button";
|
||||||
import { useLazyClearChannelMessageQuery } from '../app/services/channel';
|
import StyledRadio from "./styled/Radio";
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: number,
|
id: number;
|
||||||
type?: ChatContext,
|
type?: ChatContext;
|
||||||
// expires_in?: number
|
// expires_in?: number
|
||||||
}
|
};
|
||||||
const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
|
const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
|
||||||
const { setting, channel, loginUser } = useAppSelector(store => {
|
const { setting, channel, loginUser } = useAppSelector((store) => {
|
||||||
return {
|
return {
|
||||||
setting: type == "channel" ? store.footprint.autoDeleteMsgChannels.find(item => item.gid == id) : store.footprint.autoDeleteMsgUsers.find(item => item.uid == id),
|
setting:
|
||||||
|
type == "channel"
|
||||||
|
? store.footprint.autoDeleteMsgChannels.find((item) => item.gid == id)
|
||||||
|
: store.footprint.autoDeleteMsgUsers.find((item) => item.uid == id),
|
||||||
loginUser: store.authData.user,
|
loginUser: store.authData.user,
|
||||||
channel: type == "channel" ? store.channels.byId[id] : null
|
channel: type == "channel" ? store.channels.byId[id] : null
|
||||||
};
|
};
|
||||||
@@ -36,10 +38,13 @@ const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
|
|||||||
{ title: t("10_min"), value: 10 * 60 },
|
{ title: t("10_min"), value: 10 * 60 },
|
||||||
{ title: t("1_hour"), value: 60 * 60 },
|
{ title: t("1_hour"), value: 60 * 60 },
|
||||||
{ title: t("1_day"), value: 24 * 60 * 60 },
|
{ title: t("1_day"), value: 24 * 60 * 60 },
|
||||||
{ title: t("1_week"), value: 7 * 24 * 60 * 60 },
|
{ title: t("1_week"), value: 7 * 24 * 60 * 60 }
|
||||||
];
|
];
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const dto = type == "dm" ? { users: [{ uid: id, expires_in: value }] } : { groups: [{ gid: id, expires_in: value }] };
|
const dto =
|
||||||
|
type == "dm"
|
||||||
|
? { users: [{ uid: id, expires_in: value }] }
|
||||||
|
: { groups: [{ gid: id, expires_in: value }] };
|
||||||
updateSetting(dto);
|
updateSetting(dto);
|
||||||
};
|
};
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -68,7 +73,7 @@ const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<section className="max-w-[512px] h-full relative">
|
<section className="max-w-[512px] h-full relative">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<h2 className='dark:text-white' >{t("title")}</h2>
|
<h2 className="dark:text-white">{t("title")}</h2>
|
||||||
<p className="text-gray-400">{t("desc")}</p>
|
<p className="text-gray-400">{t("desc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -80,16 +85,19 @@ const AutoDeleteMessages = ({ id, type = "channel" }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{originalVal !== value && <SaveTip saveHandler={handleSave} resetHandler={handleReset} />}
|
{originalVal !== value && <SaveTip saveHandler={handleSave} resetHandler={handleReset} />}
|
||||||
{showClear && <>
|
{showClear && (
|
||||||
|
<>
|
||||||
<div className="text-sm mt-8">
|
<div className="text-sm mt-8">
|
||||||
<h2 className='dark:text-white' >{t("clear_title")}</h2>
|
<h2 className="dark:text-white">{t("clear_title")}</h2>
|
||||||
<p className="text-gray-400">{t("clear_desc")}</p>
|
<p className="text-gray-400">{t("clear_desc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<StyledButton className="danger" onClick={handleClear}>{t("clear")}</StyledButton>
|
<StyledButton className="danger" onClick={handleClear}>
|
||||||
|
{t("clear")}
|
||||||
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FC, ImgHTMLAttributes, useState } from "react";
|
import { FC, ImgHTMLAttributes, useState } from "react";
|
||||||
|
|
||||||
import { getInitials } from "../utils";
|
import { getInitials } from "../utils";
|
||||||
|
|
||||||
interface Props extends ImgHTMLAttributes<HTMLImageElement> {
|
interface Props extends ImgHTMLAttributes<HTMLImageElement> {
|
||||||
@@ -53,7 +54,9 @@ const Avatar: FC<Props> = ({
|
|||||||
color: type === "channel" ? "#475467" : "#FFFFFF"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ChangeEvent, FC, useState } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import uploadIcon from "@/assets/icons/upload.image.svg?url";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
type UID = number;
|
type UID = number;
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -41,11 +42,20 @@ const AvatarUploader: FC<Props> = ({
|
|||||||
setUploading(false);
|
setUploading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
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">
|
<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 && (
|
{!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">
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import Linkify from "linkify-react";
|
import Linkify from "linkify-react";
|
||||||
|
|
||||||
import ChannelModal from "./ChannelModal";
|
import { useAppSelector } from "@/app/store";
|
||||||
import InviteModal from "./InviteModal";
|
|
||||||
import IconEdit from "@/assets/icons/edit.svg";
|
import IconEdit from "@/assets/icons/edit.svg";
|
||||||
import IconChat from "@/assets/icons/placeholder.chat.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 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 UsersModal from "./UsersModal";
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type?: "chat" | "user";
|
type?: "chat" | "user";
|
||||||
@@ -24,7 +23,13 @@ const classes = {
|
|||||||
};
|
};
|
||||||
const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
|
const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
|
||||||
const { t } = useTranslation("welcome");
|
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 [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||||
const [createChannelVisible, setCreateChannelVisible] = useState(false);
|
const [createChannelVisible, setCreateChannelVisible] = useState(false);
|
||||||
const [userListVisible, setUserListVisible] = useState(false);
|
const [userListVisible, setUserListVisible] = useState(false);
|
||||||
@@ -37,33 +42,46 @@ const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
|
|||||||
const toggleInviteModalVisible = () => {
|
const toggleInviteModalVisible = () => {
|
||||||
setInviteModalVisible((prev) => !prev);
|
setInviteModalVisible((prev) => !prev);
|
||||||
};
|
};
|
||||||
const chatTip =
|
const chatTip = type == "chat" ? t("start_by_channel") : t("start_by_dm");
|
||||||
type == "chat" ? t("start_by_channel") : t("start_by_dm");
|
|
||||||
const chatHandler = type == "chat" ? toggleChannelModalVisible : toggleUserListVisible;
|
const chatHandler = type == "chat" ? toggleChannelModalVisible : toggleUserListVisible;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8 -mt-[50px] dark:bg-gray-700">
|
<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">
|
<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">
|
<p className="text-sm text-gray-400 max-w-md text-center relative whitespace-normal">
|
||||||
<Linkify options={
|
<Linkify
|
||||||
{
|
options={{
|
||||||
render: {
|
render: {
|
||||||
url: ({ content, attributes: { href: link } }) => {
|
url: ({ content, attributes: { href: link } }) => {
|
||||||
return <>
|
return (
|
||||||
<a className="text-primary-400" target="_blank" href={link} rel="noreferrer">
|
<>
|
||||||
|
<a
|
||||||
|
className="text-primary-400"
|
||||||
|
target="_blank"
|
||||||
|
href={link}
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
</>;
|
</>
|
||||||
},
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}>
|
}}
|
||||||
|
>
|
||||||
{server.description ? server.description : t("desc")}
|
{server.description ? server.description : t("desc")}
|
||||||
</Linkify>
|
</Linkify>
|
||||||
{isAdmin && <NavLink to={"/setting/overview"} className="absolute top-0 -right-7 invisible group-hover:visible">
|
{isAdmin && (
|
||||||
|
<NavLink
|
||||||
|
to={"/setting/overview"}
|
||||||
|
className="absolute top-0 -right-7 invisible group-hover:visible"
|
||||||
|
>
|
||||||
<IconEdit />
|
<IconEdit />
|
||||||
</NavLink>}
|
</NavLink>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 grid-rows-2 gap-2 md:gap-6 m-auto">
|
<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} />
|
<IconInvite className={classes.boxIcon} />
|
||||||
<div className={classes.boxTip}>{t("invite")}</div>
|
<div className={classes.boxTip}>{t("invite")}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={chatHandler} className={classes.box} >
|
<button onClick={chatHandler} className={classes.box}>
|
||||||
<IconChat className={classes.boxIcon} />
|
<IconChat className={classes.boxIcon} />
|
||||||
<div className={classes.boxTip}>{chatTip}</div>
|
<div className={classes.boxTip}>{chatTip}</div>
|
||||||
</button>
|
</button>
|
||||||
{!upgraded && <>
|
{!upgraded && (
|
||||||
<a href={"https://voce.chat#download"} target={"_blank"} rel="noreferrer" className={classes.box} >
|
<>
|
||||||
|
<a
|
||||||
|
href={"https://voce.chat#download"}
|
||||||
|
target={"_blank"}
|
||||||
|
rel="noreferrer"
|
||||||
|
className={classes.box}
|
||||||
|
>
|
||||||
<IconDownload className={classes.boxIcon} />
|
<IconDownload className={classes.boxIcon} />
|
||||||
<div className={classes.boxTip}>{t("download")}</div>
|
<div className={classes.boxTip}>{t("download")}</div>
|
||||||
</a>
|
</a>
|
||||||
<a href={"https://doc.voce.chat"} target={"_blank"} rel="noreferrer" className={classes.box} >
|
<a
|
||||||
|
href={"https://doc.voce.chat"}
|
||||||
|
target={"_blank"}
|
||||||
|
rel="noreferrer"
|
||||||
|
className={classes.box}
|
||||||
|
>
|
||||||
<IconAsk className={classes.boxIcon} />
|
<IconAsk className={classes.boxIcon} />
|
||||||
<div className={classes.boxTip}>{t("help")}</div>
|
<div className={classes.boxTip}>{t("help")}</div>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{createChannelVisible && (
|
{createChannelVisible && (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
@@ -24,9 +24,16 @@ const Channel: FC<Props> = ({ interactive = true, id, compact = false, avatarSiz
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
<Avatar
|
||||||
width={avatarSize}
|
width={avatarSize}
|
||||||
height={avatarSize}
|
height={avatarSize}
|
||||||
@@ -39,7 +46,8 @@ const Channel: FC<Props> = ({ interactive = true, id, compact = false, avatarSiz
|
|||||||
</div>
|
</div>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="flex text-sm text-gray-500 font-semibold">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import HashIcon from "@/assets/icons/channel.svg";
|
|
||||||
import LockHashIcon from "@/assets/icons/channel.private.svg";
|
import LockHashIcon from "@/assets/icons/channel.private.svg";
|
||||||
|
import HashIcon from "@/assets/icons/channel.svg";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
personal?: boolean;
|
personal?: boolean;
|
||||||
@@ -11,7 +12,11 @@ interface Props {
|
|||||||
const ChannelIcon: FC<Props> = ({ personal = false, muted = false, className = "" }) => {
|
const ChannelIcon: FC<Props> = ({ personal = false, muted = false, className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${muted ? "!text-gray-400" : ""} ${className}`}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import i18n from "@/i18n";
|
||||||
import clsx from "clsx";
|
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 { useCreateChannelMutation, useSendChannelMsgMutation } from "@/app/services/channel";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import { CreateChannelDTO } from "@/types/channel";
|
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 {
|
interface Props {
|
||||||
personal?: boolean;
|
personal?: boolean;
|
||||||
@@ -66,7 +66,7 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
|
|||||||
}, [isError]);
|
}, [isError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = typeof newChannel == 'object' ? newChannel.gid : newChannel;
|
const id = typeof newChannel == "object" ? newChannel.gid : newChannel;
|
||||||
if (isSuccess && id && channelData[id]) {
|
if (isSuccess && id && channelData[id]) {
|
||||||
const name = channelData[id].name;
|
const name = channelData[id].name;
|
||||||
// 发个欢迎消息
|
// 发个欢迎消息
|
||||||
@@ -78,7 +78,6 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
|
|||||||
}
|
}
|
||||||
}, [isSuccess, newChannel, channelData]);
|
}, [isSuccess, newChannel, channelData]);
|
||||||
|
|
||||||
|
|
||||||
const handleNameInput = (evt: ChangeEvent<HTMLInputElement>) => {
|
const handleNameInput = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||||
setData((prev) => ({ ...prev, name: evt.target.value }));
|
setData((prev) => ({ ...prev, name: evt.target.value }));
|
||||||
};
|
};
|
||||||
@@ -138,18 +137,31 @@ const ChannelModal: FC<Props> = ({ personal = false, closeModal }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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")}>
|
<div
|
||||||
<h3 className="font-semibold text-xl text-gray-600 mb-4 dark:text-white">{t("create_channel")}</h3>
|
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">
|
<p className="text-gray-400 mb-2 md:mb-12 text-sm font-normal">
|
||||||
{!is_public
|
{!is_public ? t("create_private_channel_desc") : t("create_channel_desc")}
|
||||||
? t("create_private_channel_desc")
|
|
||||||
: t("create_channel_desc")}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full flex flex-col justify-start gap-2 mb-2 md:mb-8">
|
<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>
|
<span className="text-gray-400 text-sm font-normal">{t("channel_name")}</span>
|
||||||
<div className="relative">
|
<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" />
|
<input
|
||||||
<ChannelIcon personal={!is_public} className="absolute left-2 top-1/2 -translate-y-1/2" />
|
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>
|
</div>
|
||||||
<div className="w-full flex items-center justify-between mb-8 md:mb-12">
|
<div className="w-full flex items-center justify-between mb-8 md:mb-12">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import useDeleteMessage from "@/hooks/useDeleteMessage";
|
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 PreviewMessage from "./Message/PreviewMessage";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import Button from "./styled/Button";
|
||||||
|
import StyledModal from "./styled/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
closeModal: (b: boolean) => void;
|
closeModal: (b: boolean) => void;
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider: FC<Props> = ({ content, className = "" }) => {
|
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)}>
|
return (
|
||||||
<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
|
||||||
</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;
|
export default Divider;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
type FallbackProps = {
|
type FallbackProps = {
|
||||||
error: Error,
|
error: Error;
|
||||||
resetErrorBoundary: (...args: Array<unknown>) => void
|
resetErrorBoundary: (...args: Array<unknown>) => void;
|
||||||
}
|
};
|
||||||
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className='text-lg text-red-600'>
|
<div role="alert" className="text-lg text-red-600">
|
||||||
<p>Something went wrong:</p>
|
<p>Something went wrong:</p>
|
||||||
<pre>{error.message}</pre>
|
<pre>{error.message}</pre>
|
||||||
<button onClick={resetErrorBoundary}>Try again</button>
|
<button onClick={resetErrorBoundary}>Try again</button>
|
||||||
@@ -15,13 +15,11 @@ function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ErrorCatcher = ({ children }: Props) => {
|
const ErrorCatcher = ({ children }: Props) => {
|
||||||
return (
|
return <ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>;
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ErrorCatcher;
|
export default ErrorCatcher;
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import { formatBytes, fromNowTime, getFileIcon } from "@/utils";
|
||||||
|
import IconDownload from "@/assets/icons/download.svg";
|
||||||
import {
|
import {
|
||||||
VideoPreview,
|
|
||||||
AudioPreview,
|
AudioPreview,
|
||||||
|
CodePreview,
|
||||||
|
DocPreview,
|
||||||
ImagePreview,
|
ImagePreview,
|
||||||
PdfPreview,
|
PdfPreview,
|
||||||
CodePreview,
|
VideoPreview
|
||||||
DocPreview
|
|
||||||
} from "./preview";
|
} from "./preview";
|
||||||
import { getFileIcon, formatBytes, fromNowTime } from "@/utils";
|
|
||||||
import IconDownload from "@/assets/icons/download.svg";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
file_type: string;
|
file_type: string;
|
||||||
@@ -86,12 +87,19 @@ const FileBox: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="w-full p-2 flex items-center justify-between gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<div className="flex flex-col gap-1 w-full overflow-hidden">
|
<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">
|
<em className="text-xs text-gray-500 flex gap-4 not-italic">
|
||||||
<span className="size">{formatBytes(size)}</span>
|
<span className="size">{formatBytes(size)}</span>
|
||||||
<span className="hidden md:block time">{fromNowTime(created_at)}</span>
|
<span className="hidden md:block time">{fromNowTime(created_at)}</span>
|
||||||
@@ -100,7 +108,11 @@ const FileBox: FC<Props> = ({
|
|||||||
</span>
|
</span>
|
||||||
</em>
|
</em>
|
||||||
</div>
|
</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" />
|
<IconDownload className="fill-gray-500 dark:fill-gray-300" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -17,7 +17,11 @@ const Code: FC<Props> = ({ url }) => {
|
|||||||
}, [url]);
|
}, [url]);
|
||||||
if (!content) return null;
|
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;
|
export default Code;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -17,7 +17,9 @@ const Doc: FC<Props> = ({ url }) => {
|
|||||||
}, [url]);
|
}, [url]);
|
||||||
if (!content) return null;
|
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;
|
export default Doc;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||||
import { LineWobble } from "@uiball/loaders";
|
import { LineWobble } from "@uiball/loaders";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { useLazyPreCheckFileFromUrlQuery } from "@/app/services/message";
|
import { useLazyPreCheckFileFromUrlQuery } from "@/app/services/message";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@@ -46,13 +47,23 @@ const ImageBox: FC<Props> = ({ url, alt }) => {
|
|||||||
}
|
}
|
||||||
}, [isSuccess, error, url]);
|
}, [isSuccess, error, url]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("h-[218px] overflow-hidden flex-center", status == "error" && "bg-red-100 dark:bg-red-200/60")}>
|
<div
|
||||||
{status == "loaded" ? <img className="w-full h-full object-cover" src={url} alt={alt} /> : (
|
className={clsx(
|
||||||
status == "loading" ? <span><LineWobble color="rgb(21,91,117)" /></span> :
|
"h-[218px] overflow-hidden flex-center",
|
||||||
|
status == "error" && "bg-red-100 dark:bg-red-200/60"
|
||||||
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>
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import VideoPreview from "./Video";
|
|
||||||
import AudioPreview from "./Audio";
|
import AudioPreview from "./Audio";
|
||||||
import ImagePreview from "./Image";
|
|
||||||
import PdfPreview from "./Pdf";
|
|
||||||
import CodePreview from "./Code";
|
import CodePreview from "./Code";
|
||||||
import DocPreview from "./Doc";
|
import DocPreview from "./Doc";
|
||||||
|
import ImagePreview from "./Image";
|
||||||
|
import PdfPreview from "./Pdf";
|
||||||
|
import VideoPreview from "./Video";
|
||||||
|
|
||||||
export { VideoPreview, AudioPreview, ImagePreview, PdfPreview, CodePreview, DocPreview };
|
export { VideoPreview, AudioPreview, ImagePreview, PdfPreview, CodePreview, DocPreview };
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { formatBytes } from '../../utils';
|
import clsx from "clsx";
|
||||||
|
|
||||||
import IconDownload from "@/assets/icons/download.svg";
|
import IconDownload from "@/assets/icons/download.svg";
|
||||||
import IconAudio from "@/assets/icons/file.audio.svg";
|
import IconAudio from "@/assets/icons/file.audio.svg";
|
||||||
import clsx from 'clsx';
|
import { formatBytes } from "../../utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string,
|
url: string;
|
||||||
name: string,
|
name: string;
|
||||||
size: number,
|
size: number;
|
||||||
download: string
|
download: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AudioMessage = ({ url, name, size, download }: Props) => {
|
const AudioMessage = ({ url, name, size, download }: Props) => {
|
||||||
const [canPlay, setCanPlay] = useState(false);
|
const [canPlay, setCanPlay] = useState(false);
|
||||||
@@ -18,18 +19,27 @@ const AudioMessage = ({ url, name, size, download }: Props) => {
|
|||||||
};
|
};
|
||||||
const _size = formatBytes(size);
|
const _size = formatBytes(size);
|
||||||
return (
|
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="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 justify-between z-30 overflow-hidden">
|
||||||
<div className="flex gap-2 ">
|
<div className="flex gap-2 ">
|
||||||
<IconAudio className="w-9 h-auto" />
|
<IconAudio className="w-9 h-auto" />
|
||||||
<div className="flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-300">
|
<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 title={name} className="font-bold w-[240px] truncate">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
<span>{_size}</span>
|
<span>{_size}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={download} className="mt-2 hidden md:block"><IconDownload className="fill-gray-500 dark:fill-gray-400" /></a>
|
<a href={download} className="mt-2 hidden md:block">
|
||||||
|
<IconDownload className="fill-gray-500 dark:fill-gray-400" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<audio src={url} onCanPlay={handleCanPlay} controls className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")} />
|
<audio
|
||||||
|
src={url}
|
||||||
|
onCanPlay={handleCanPlay}
|
||||||
|
controls
|
||||||
|
className={clsx("w-full object-cover z-10", canPlay ? "visible" : "invisible")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import { Ping, LineWobble } from '@uiball/loaders';
|
import { LineWobble, Ping } from "@uiball/loaders";
|
||||||
|
|
||||||
import { getDefaultSize, isMobile } from "@/utils";
|
import { getDefaultSize, isMobile } from "@/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,7 +22,10 @@ const ImageMessage: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const url = thumbnail || content;
|
const url = thumbnail || content;
|
||||||
const [status, setStatus] = useState<"loading" | "error" | "loaded">("loading");
|
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(() => {
|
useEffect(() => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -34,25 +38,29 @@ const ImageMessage: FC<Props> = ({
|
|||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative`} style={{
|
<div
|
||||||
|
className={`relative`}
|
||||||
|
style={{
|
||||||
width: width ? `${width}px` : "",
|
width: width ? `${width}px` : "",
|
||||||
height: height ? `${height}px` : ""
|
height: height ? `${height}px` : ""
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div className="absolute left-0 top-0 w-full h-full bg-white/50 flex flex-col justify-center items-center gap-1">
|
<div className="absolute left-0 top-0 w-full h-full bg-white/50 flex flex-col justify-center items-center gap-1">
|
||||||
<Ping
|
<Ping size={45} speed={2} color="#555" />
|
||||||
size={45}
|
|
||||||
speed={2}
|
|
||||||
color="#555"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">{progress}%</span>
|
<span className="text-xs text-gray-500">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status == "error" ? <p className="w-full h-full flex-center text-lg text-red-800 bg-red-100">load image error</p> :
|
{status == "error" ? (
|
||||||
status == "loading" ? <p className="w-full h-full flex-center bg-primary-50/80 dark:bg-primary-900/70">
|
<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 />
|
<LineWobble />
|
||||||
</p> : <img
|
</p>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
className="h-auto w-full cursor-zoom-in object-cover preview"
|
className="h-auto w-full cursor-zoom-in object-cover preview"
|
||||||
// style={{
|
// style={{
|
||||||
// width: width ? `${width}px` : "",
|
// width: width ? `${width}px` : "",
|
||||||
@@ -62,7 +70,8 @@ const ImageMessage: FC<Props> = ({
|
|||||||
data-origin={content}
|
data-origin={content}
|
||||||
data-download={download}
|
data-download={download}
|
||||||
src={url}
|
src={url}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number;
|
value: number;
|
||||||
width?: string;
|
width?: string;
|
||||||
@@ -7,7 +8,10 @@ interface Props {
|
|||||||
const Progress: FC<Props> = ({ value, width = "100%" }) => {
|
const Progress: FC<Props> = ({ value, width = "100%" }) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 rounded h-2 overflow-hidden" style={{ width }}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { SyntheticEvent, useState } from 'react';
|
import { SyntheticEvent, useState } from "react";
|
||||||
import { Orbit } from "@uiball/loaders";
|
import { Orbit } from "@uiball/loaders";
|
||||||
import { formatBytes } from '../../utils';
|
|
||||||
import IconDownload from "@/assets/icons/download.svg";
|
import IconDownload from "@/assets/icons/download.svg";
|
||||||
import IconVideo from "@/assets/icons/file.video.svg";
|
import IconVideo from "@/assets/icons/file.video.svg";
|
||||||
|
import { formatBytes } from "../../utils";
|
||||||
|
|
||||||
// import clsx from 'clsx';
|
// import clsx from 'clsx';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string,
|
url: string;
|
||||||
name: string,
|
name: string;
|
||||||
size: number,
|
size: number;
|
||||||
download: string
|
download: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const VideoMessage = ({ url, name, size, download }: Props) => {
|
const VideoMessage = ({ url, name, size, download }: Props) => {
|
||||||
const [canPlay, setCanPlay] = useState(false);
|
const [canPlay, setCanPlay] = useState(false);
|
||||||
@@ -35,29 +37,39 @@ const VideoMessage = ({ url, name, size, download }: Props) => {
|
|||||||
setCanPlay(false);
|
setCanPlay(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
};
|
};
|
||||||
const tipClass = 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2';
|
const tipClass = "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2";
|
||||||
return (
|
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="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 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="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 ">
|
<div className="flex gap-2 ">
|
||||||
<IconVideo className="hidden md:block w-9 h-auto" />
|
<IconVideo className="hidden md:block w-9 h-auto" />
|
||||||
<div className="flex flex-col gap-1 text-sm text-white">
|
<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 title={name} className="font-bold w-56 md:w-[240px] truncate">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
<span>{_size}</span>
|
<span>{_size}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={download} className="hidden md:block mt-2"><IconDownload className="fill-white" /></a>
|
<a href={download} className="hidden md:block mt-2">
|
||||||
|
<IconDownload className="fill-white" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{!canPlay ?
|
{!canPlay ? (
|
||||||
<div className={tipClass}>
|
<div className={tipClass}>
|
||||||
<Orbit color='#fff' />
|
<Orbit color="#fff" />
|
||||||
</div> :
|
</div>
|
||||||
(error ?
|
) : error ? (
|
||||||
<span className={`${tipClass} text-red-500`}>Error</span> :
|
<span className={`${tipClass} text-red-500`}>Error</span>
|
||||||
null)
|
) : null}
|
||||||
}
|
<video
|
||||||
<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">
|
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>
|
<source src={url} type="video/mp4"></source>
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
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 clsx from "clsx";
|
||||||
|
|
||||||
|
import { useAppSelector } from "@/app/store";
|
||||||
import { ChatContext } from "@/types/common";
|
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) => {
|
const isLocalFile = (content: string) => {
|
||||||
return content.startsWith("blob:");
|
return content.startsWith("blob:");
|
||||||
@@ -55,7 +56,14 @@ const FileMessage: FC<Props> = ({
|
|||||||
from: from_uid,
|
from: from_uid,
|
||||||
to
|
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 fromUser = useAppSelector((store) => store.users.byId[from_uid]);
|
||||||
const { size = 0, name, content_type } = properties ?? {};
|
const { size = 0, name, content_type } = properties ?? {};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,30 +153,23 @@ const FileMessage: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
// video
|
// video
|
||||||
if (content_type.startsWith("video") && !sending)
|
if (content_type.startsWith("video") && !sending)
|
||||||
return (
|
return <VideoMessage size={size} url={content} name={name} download={download} />;
|
||||||
<VideoMessage
|
|
||||||
size={size}
|
|
||||||
url={content}
|
|
||||||
name={name}
|
|
||||||
download={download}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
// audio
|
// audio
|
||||||
if (content_type.startsWith("audio") && !sending)
|
if (content_type.startsWith("audio") && !sending)
|
||||||
|
return <AudioMessage size={size} url={content} name={name} download={download} />;
|
||||||
return (
|
return (
|
||||||
<AudioMessage
|
<div
|
||||||
size={size}
|
className={clsx(
|
||||||
url={content}
|
`bg-slate-50 dark:bg-slate-900 border border-solid border-gray-300 dark:border-gray-500 box-border md:w-[370px] rounded-md`,
|
||||||
name={name}
|
sending && "opacity-90"
|
||||||
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="px-2 py-3 flex items-center justify-between gap-2">
|
<div className="px-2 py-3 flex items-center justify-between gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<div className="flex flex-col gap-1 w-full overflow-hidden">
|
<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">
|
<span className="hidden md:flex whitespace-nowrap text-xs text-gray-500 dark:text-gray-300 gap-4">
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<Progress value={progress} width={"80%"} />
|
<Progress value={progress} width={"80%"} />
|
||||||
@@ -188,7 +189,11 @@ const FileMessage: FC<Props> = ({
|
|||||||
{sending ? (
|
{sending ? (
|
||||||
<IconClose className="cursor-pointer" onClick={handleCancel} />
|
<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" />
|
<IconDownload className="fill-gray-500 dark:fill-gray-400" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 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 useFilteredChannels from "@/hooks/useFilteredChannels";
|
||||||
import useFilteredUsers from "@/hooks/useFilteredUsers";
|
import useFilteredUsers from "@/hooks/useFilteredUsers";
|
||||||
|
import useForwardMessage from "@/hooks/useForwardMessage";
|
||||||
|
import useSendMessage from "@/hooks/useSendMessage";
|
||||||
import CloseIcon from "@/assets/icons/close.circle.svg";
|
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 StyledCheckbox from "../styled/Checkbox";
|
||||||
import { useTranslation } from "react-i18next";
|
import Input from "../styled/Input";
|
||||||
|
import User from "../User";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mids: number[];
|
mids: number[];
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
@@ -140,7 +141,10 @@ const ForwardModal: FC<IProps> = ({ mids, closeModal }) => {
|
|||||||
return (
|
return (
|
||||||
<li key={uid} className="relative">
|
<li key={uid} className="relative">
|
||||||
<User key={uid} uid={uid} interactive={false} />
|
<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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -158,7 +162,7 @@ const ForwardModal: FC<IProps> = ({ mids, closeModal }) => {
|
|||||||
></Input>
|
></Input>
|
||||||
<div className="w-full flex items-center justify-end gap-4">
|
<div className="w-full flex items-center justify-end gap-4">
|
||||||
<Button onClick={closeModal} className="normal cancel">
|
<Button onClick={closeModal} className="normal cancel">
|
||||||
{t('action.cancel')}
|
{t("action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="normal" disabled={sendButtonDisabled} onClick={handleForward}>
|
<Button className="normal" disabled={sendButtonDisabled} onClick={handleForward}>
|
||||||
Send To {selectedCount == 0 ? null : `(${selectedCount})`}
|
Send To {selectedCount == 0 ? null : `(${selectedCount})`}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import IconGithub from "@/assets/icons/github.svg";
|
|
||||||
import Button from "./styled/Button";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import IconGithub from "@/assets/icons/github.svg";
|
||||||
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
source?: "widget" | "webapp",
|
source?: "widget" | "webapp";
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
type?: "login" | "register";
|
type?: "login" | "register";
|
||||||
};
|
};
|
||||||
@@ -15,14 +15,14 @@ const GithubLoginButton: FC<Props> = ({ type = "login", source = "webapp", clien
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGithubLoginStatusChange = (evt: StorageEvent) => {
|
const handleGithubLoginStatusChange = (evt: StorageEvent) => {
|
||||||
const { key, newValue } = evt;
|
const { key, newValue } = evt;
|
||||||
if (key == 'widget' && !!newValue) {
|
if (key == "widget" && !!newValue) {
|
||||||
console.log("github logged in");
|
console.log("github logged in");
|
||||||
localStorage.removeItem("widget");
|
localStorage.removeItem("widget");
|
||||||
if (window.location !== window.parent.location) {
|
if (window.location !== window.parent.location) {
|
||||||
// in iframe
|
// in iframe
|
||||||
const parentWindow = window.parent;
|
const parentWindow = window.parent;
|
||||||
if (parentWindow) {
|
if (parentWindow) {
|
||||||
parentWindow.postMessage("RELOAD_WITH_OPEN", '*');
|
parentWindow.postMessage("RELOAD_WITH_OPEN", "*");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 刷新页面
|
// 刷新页面
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useMatch, useNavigate } from 'react-router-dom';
|
import { useMatch, useNavigate } from "react-router-dom";
|
||||||
import IconArrow from '@/assets/icons/arrow.left.svg';
|
|
||||||
|
import IconArrow from "@/assets/icons/arrow.left.svg";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
path?: string,
|
path?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const GoBackNav = ({ path, className = "" }: Props) => {
|
const GoBackNav = ({ path, className = "" }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FC, Suspense, useEffect, useState } from "react";
|
import { FC, Suspense, useEffect, useState } from "react";
|
||||||
import { GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
|
|
||||||
import toast from "react-hot-toast";
|
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 { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
|
||||||
|
import { useLoginMutation } from "@/app/services/auth";
|
||||||
import IconGoogle from "@/assets/icons/google.svg";
|
import IconGoogle from "@/assets/icons/google.svg";
|
||||||
import Button from "./styled/Button";
|
import Button from "./styled/Button";
|
||||||
import { useLoginMutation } from "@/app/services/auth";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loadError?: boolean;
|
loadError?: boolean;
|
||||||
@@ -43,7 +43,10 @@ const GoogleLoginInner: FC<Props> = ({ type = "login", loaded, loadError }) => {
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
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">
|
<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" />
|
<IconGoogle className="w-6 h-6" />
|
||||||
{loadError
|
{loadError
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FC, ReactElement, useEffect } from "react";
|
import { FC, ReactElement, useEffect } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useGetInitializedQuery, useLazyGuestLoginQuery } from "@/app/services/auth";
|
import { useGetInitializedQuery, useLazyGuestLoginQuery } from "@/app/services/auth";
|
||||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import ImagePreviewModal, { PreviewImageData } from "./ImagePreviewModal";
|
import ImagePreviewModal, { PreviewImageData } from "./ImagePreviewModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context?: "chat" | "markdown",
|
context?: "chat" | "markdown";
|
||||||
container: HTMLElement | null
|
container: HTMLElement | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ImagePreview = ({ container, context = "chat" }: Props) => {
|
const ImagePreview = ({ container, context = "chat" }: Props) => {
|
||||||
const [previewImage, setPreviewImage] = useState<PreviewImageData | null>(null);
|
const [previewImage, setPreviewImage] = useState<PreviewImageData | null>(null);
|
||||||
@@ -38,15 +40,20 @@ const ImagePreview = ({ container, context = "chat" }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handler = context == 'chat' ? chatHandler : markdownHandler;
|
const handler = context == "chat" ? chatHandler : markdownHandler;
|
||||||
// 点击查看大图
|
// 点击查看大图
|
||||||
container.addEventListener("click", handler, true);
|
container.addEventListener("click", handler, true);
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener("click", handler, true);
|
container.removeEventListener("click", handler, true);
|
||||||
};
|
};
|
||||||
}, [container, context]);
|
}, [container, context]);
|
||||||
return previewImage ? <ImagePreviewModal download={context == "chat"} data={previewImage} closeModal={closePreviewModal} /> : null;
|
return previewImage ? (
|
||||||
|
<ImagePreviewModal
|
||||||
|
download={context == "chat"}
|
||||||
|
data={previewImage}
|
||||||
|
closeModal={closePreviewModal}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImagePreview;
|
export default ImagePreview;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useRef, useState, useEffect, FC } from "react";
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
import { useOutsideClick, useKey } from "rooks";
|
|
||||||
import { Ring } from "@uiball/loaders";
|
|
||||||
import Modal from "./Modal";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ring } from "@uiball/loaders";
|
||||||
|
import { useKey, useOutsideClick } from "rooks";
|
||||||
|
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
export interface PreviewImageData {
|
export interface PreviewImageData {
|
||||||
originUrl: string;
|
originUrl: string;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import useStreaming from "@/hooks/useStreaming";
|
import useStreaming from "@/hooks/useStreaming";
|
||||||
// import clsx from "clsx";
|
// import clsx from "clsx";
|
||||||
import Button from "./styled/Button";
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {}
|
||||||
}
|
|
||||||
|
|
||||||
const InactiveScreen: FC<Props> = () => {
|
const InactiveScreen: FC<Props> = () => {
|
||||||
const { t } = useTranslation();
|
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="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">
|
<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>
|
<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>
|
<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>
|
<Button className="mt-4 uppercase" onClick={handleReload}>
|
||||||
|
{t("action.reload")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import useInviteLink from "@/hooks/useInviteLink";
|
import useInviteLink from "@/hooks/useInviteLink";
|
||||||
import Input from "./styled/Input";
|
import IconQuestion from "@/assets/icons/question.svg";
|
||||||
import Button from "./styled/Button";
|
|
||||||
import QRCode from "./QRCode";
|
import QRCode from "./QRCode";
|
||||||
import IconQuestion from '@/assets/icons/question.svg';
|
import Button from "./styled/Button";
|
||||||
|
import Input from "./styled/Input";
|
||||||
|
|
||||||
type Props = {};
|
type Props = {};
|
||||||
const InviteLink: FC<Props> = () => {
|
const InviteLink: FC<Props> = () => {
|
||||||
@@ -18,13 +19,21 @@ const InviteLink: FC<Props> = () => {
|
|||||||
<div className="flex flex-col items-start pb-8">
|
<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">
|
<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")}
|
{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")}
|
<IconQuestion /> {t("invite_link_faq")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full md:w-[512px] mb-3 relative">
|
<div className="w-full md:w-[512px] mb-3 relative">
|
||||||
<Input readOnly className={"large !pr-16"} placeholder="Generating" value={link} />
|
<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" })}
|
{linkCopied ? "Copied" : t("action.copy", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 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 Button from "../styled/Button";
|
||||||
|
import StyledCheckbox from "../styled/Checkbox";
|
||||||
import Input from "../styled/Input";
|
import Input from "../styled/Input";
|
||||||
import User from "../User";
|
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 {
|
interface Props {
|
||||||
cid?: number;
|
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">
|
<ul className="flex items-center flex-wrap gap-1 w-full overflow-scroll">
|
||||||
{selects.map((uid) => {
|
{selects.map((uid) => {
|
||||||
return (
|
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}
|
{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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -97,7 +105,11 @@ const AddMembers: FC<Props> = ({ cid = 0, closeModal }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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}
|
{isAdding ? `Adding` : "Add"} to #{channel.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
|
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
|
||||||
import useInviteLink from "@/hooks/useInviteLink";
|
import useInviteLink from "@/hooks/useInviteLink";
|
||||||
import QRCode from "../QRCode";
|
import QRCode from "../QRCode";
|
||||||
@@ -12,7 +13,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InviteByEmail: FC<Props> = ({ cid }) => {
|
const InviteByEmail: FC<Props> = ({ cid }) => {
|
||||||
|
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const { t: ct } = useTranslation();
|
const { t: ct } = useTranslation();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -27,7 +27,6 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
|
|||||||
}, [linkCopied]);
|
}, [linkCopied]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
|
||||||
toast.success("Email Sent!");
|
toast.success("Email Sent!");
|
||||||
}
|
}
|
||||||
}, [isSuccess]);
|
}, [isSuccess]);
|
||||||
@@ -49,7 +48,9 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<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">
|
<div className="relative flex items-center gap-2">
|
||||||
<form ref={formRef} action="/" className="w-full">
|
<form ref={formRef} action="/" className="w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -63,26 +64,37 @@ const InviteByEmail: FC<Props> = ({ cid }) => {
|
|||||||
placeholder={enableSMTP ? "Enter Email" : t("enable_smtp")}
|
placeholder={enableSMTP ? "Enter Email" : t("enable_smtp")}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<Button disabled={!enableSMTP || !email || isLoading} className="send" onClick={handleSendEmail}>
|
<Button
|
||||||
|
disabled={!enableSMTP || !email || isLoading}
|
||||||
|
className="send"
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
>
|
||||||
{ct("action.send")}
|
{ct("action.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
<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">
|
<div className="relative flex items-center gap-2">
|
||||||
<Input readOnly className="!pr-[50px]" placeholder="Generating" value={link} />
|
<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")}
|
{ct("action.copy")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-44 h-44 my-2">
|
<div className="w-44 h-44 my-2">{!generating && <QRCode size={1200} link={link} />}</div>
|
||||||
{!generating && <QRCode size={1200} link={link} />}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-200">
|
<div className="text-xs text-gray-600 dark:text-gray-200">
|
||||||
{t("invite_link_expire")}
|
{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")}
|
{t("generate_new_link")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
interface Props {
|
||||||
type?: "server" | "channel";
|
type?: "server" | "channel";
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, FC } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useLeaveChannel from "@/hooks/useLeaveChannel";
|
import useLeaveChannel from "@/hooks/useLeaveChannel";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import StyledModal from "../styled/Modal";
|
|
||||||
import Button from "../styled/Button";
|
import Button from "../styled/Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import StyledModal from "../styled/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -31,11 +32,7 @@ const LeaveConfirmModal: FC<Props> = ({ id, closeModal, handleNextStep }) => {
|
|||||||
<StyledModal
|
<StyledModal
|
||||||
className="compact"
|
className="compact"
|
||||||
title={t("channel.leave")}
|
title={t("channel.leave")}
|
||||||
description={
|
description={isOwner ? t("channel.transfer_desc") : t("channel.leave_desc")}
|
||||||
isOwner
|
|
||||||
? t("channel.transfer_desc")
|
|
||||||
: t("channel.leave_desc")
|
|
||||||
}
|
|
||||||
buttons={
|
buttons={
|
||||||
<>
|
<>
|
||||||
<Button onClick={closeModal.bind(null, undefined)} className="cancel">
|
<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 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 { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import clsx from "clsx";
|
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 {
|
interface Props {
|
||||||
id: number;
|
id: number;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
@@ -72,7 +74,10 @@ const TransferOwnerModal: FC<Props> = ({ id, closeModal, withLeave = true }) =>
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={id}
|
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)}
|
onClick={handleSelectUser.bind(null, id)}
|
||||||
>
|
>
|
||||||
<User uid={id} interactive={false} />
|
<User uid={id} interactive={false} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, FC } from "react";
|
import { FC, useState } from "react";
|
||||||
import TransferOwnerModal from "./TransferOwnerModal";
|
|
||||||
import LeaveConfirmModal from "./LeaveConfirmModal";
|
import LeaveConfirmModal from "./LeaveConfirmModal";
|
||||||
|
import TransferOwnerModal from "./TransferOwnerModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,56 +1,68 @@
|
|||||||
// import { MouseEvent, useState } from 'react';
|
// import { MouseEvent, useState } from 'react';
|
||||||
// import { useTranslation } from 'react-i18next';
|
// import { useTranslation } from 'react-i18next';
|
||||||
import Linkify from "linkify-react";
|
import Linkify from "linkify-react";
|
||||||
import 'linkify-plugin-mention';
|
|
||||||
import URLPreview from './Message/URLPreview';
|
import "linkify-plugin-mention";
|
||||||
import Mention from './Message/Mention';
|
import Mention from "./Message/Mention";
|
||||||
|
import URLPreview from "./Message/URLPreview";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: boolean,
|
url?: boolean;
|
||||||
mention?: boolean,
|
mention?: boolean;
|
||||||
mentionTextOnly?: boolean,
|
mentionTextOnly?: boolean;
|
||||||
mentionPopOver?: boolean,
|
mentionPopOver?: boolean;
|
||||||
text: string,
|
text: string;
|
||||||
cid?: number
|
cid?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const LinkifyText = ({ url = true, mention = true, mentionTextOnly = false, mentionPopOver = true, text, cid }: Props) => {
|
const LinkifyText = ({
|
||||||
|
url = true,
|
||||||
|
mention = true,
|
||||||
|
mentionTextOnly = false,
|
||||||
|
mentionPopOver = true,
|
||||||
|
text,
|
||||||
|
cid
|
||||||
|
}: Props) => {
|
||||||
// const { t } = useTranslation();
|
// const { t } = useTranslation();
|
||||||
// const
|
// const
|
||||||
return (
|
return (
|
||||||
<Linkify options={
|
<Linkify
|
||||||
{
|
options={{
|
||||||
render: {
|
render: {
|
||||||
email: ({ content, attributes: { href: link } }) => {
|
email: ({ content, attributes: { href: link } }) => {
|
||||||
if (mentionTextOnly) return <>
|
if (mentionTextOnly) return <>{content}</>;
|
||||||
|
return (
|
||||||
|
<a className="link" href={link} rel="noreferrer">
|
||||||
{content}
|
{content}
|
||||||
</>;
|
</a>
|
||||||
return <a className="link" href={link} rel="noreferrer">
|
);
|
||||||
{content}
|
|
||||||
</a>;
|
|
||||||
},
|
},
|
||||||
url: ({ content, attributes: { href: link } }) => {
|
url: ({ content, attributes: { href: link } }) => {
|
||||||
// console.log("attr", link);
|
// console.log("attr", link);
|
||||||
if (!url) return <>{content}</>;
|
if (!url) return <>{content}</>;
|
||||||
return <>
|
return (
|
||||||
|
<>
|
||||||
<a className="link" target="_blank" href={link} rel="noreferrer">
|
<a className="link" target="_blank" href={link} rel="noreferrer">
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
<URLPreview url={link} />
|
<URLPreview url={link} />
|
||||||
</>;
|
</>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
mention: ({ content }) => {
|
mention: ({ content }) => {
|
||||||
if (!mention) return <>{content}</>;
|
if (!mention) return <>{content}</>;
|
||||||
// console.log();
|
// console.log();
|
||||||
if (/@[0-9]+/.test(content)) {
|
if (/@[0-9]+/.test(content)) {
|
||||||
const uid = content.trim().slice(1);
|
const uid = content.trim().slice(1);
|
||||||
return <Mention uid={+uid} cid={cid} popover={mentionPopOver} textOnly={mentionTextOnly} />;
|
return (
|
||||||
|
<Mention uid={+uid} cid={cid} popover={mentionPopOver} textOnly={mentionTextOnly} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <>{content}</>;
|
return <>{content}</>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Linkify>
|
</Linkify>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FC, useState, useEffect, memo } from "react";
|
import { FC, memo, useEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { Ring } from "@uiball/loaders";
|
import { Ring } from "@uiball/loaders";
|
||||||
import Button from "./styled/Button";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import useLogout from "@/hooks/useLogout";
|
import useLogout from "@/hooks/useLogout";
|
||||||
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
reload?: boolean;
|
reload?: boolean;
|
||||||
@@ -22,7 +23,6 @@ const Loading: FC<Props> = ({ reload = false, fullscreen = false }) => {
|
|||||||
inter = window.setTimeout(() => {
|
inter = window.setTimeout(() => {
|
||||||
setReloadVisible(true);
|
setReloadVisible(true);
|
||||||
}, 30 * 1000);
|
}, 30 * 1000);
|
||||||
|
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(inter);
|
clearTimeout(inter);
|
||||||
@@ -30,9 +30,17 @@ const Loading: FC<Props> = ({ reload = false, fullscreen = false }) => {
|
|||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
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" />
|
<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
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
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 Tippy from "@tippyjs/react";
|
||||||
import { hideAll } from "tippy.js";
|
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 { useUpdateUserMutation } from "@/app/services/user";
|
||||||
import User from "../User";
|
import { useAppSelector } from "@/app/store";
|
||||||
import IconMore from "@/assets/icons/more.svg";
|
import useUserOperation from "@/hooks/useUserOperation";
|
||||||
import IconOwner from "@/assets/icons/owner.svg";
|
|
||||||
import IconArrowDown from "@/assets/icons/arrow.down.mini.svg";
|
import IconArrowDown from "@/assets/icons/arrow.down.mini.svg";
|
||||||
import IconCheck from "@/assets/icons/check.sign.svg";
|
import IconCheck from "@/assets/icons/check.sign.svg";
|
||||||
import useUserOperation from "@/hooks/useUserOperation";
|
import IconMore from "@/assets/icons/more.svg";
|
||||||
import { useAppSelector } from "@/app/store";
|
import IconOwner from "@/assets/icons/owner.svg";
|
||||||
|
import User from "../User";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cid?: number;
|
cid?: number;
|
||||||
}
|
}
|
||||||
const MemberList: FC<Props> = ({ cid }) => {
|
const MemberList: FC<Props> = ({ cid }) => {
|
||||||
const ref = useRef<HTMLUListElement | null>(
|
const ref = useRef<HTMLUListElement | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
const { t } = useTranslation("member");
|
const { t } = useTranslation("member");
|
||||||
const { t: ct } = useTranslation();
|
const { t: ct } = useTranslation();
|
||||||
const { users, channels, loginUser } = useAppSelector((store) => {
|
const { users, channels, loginUser } = useAppSelector((store) => {
|
||||||
@@ -54,12 +53,12 @@ const MemberList: FC<Props> = ({ cid }) => {
|
|||||||
|
|
||||||
const channel = cid ? channels.byId[cid] : null;
|
const channel = cid ? channels.byId[cid] : null;
|
||||||
const uids = channel ? (channel.is_public ? users.ids : channel.members) : users.ids;
|
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}>
|
return (
|
||||||
<ViewportList
|
<ul
|
||||||
initialPrerender={15}
|
className="flex flex-col gap-1 w-full md:w-[512px] mb-44 max-h-[800px] overflow-y-scroll"
|
||||||
viewportRef={ref}
|
ref={ref}
|
||||||
items={uids}
|
|
||||||
>
|
>
|
||||||
|
<ViewportList initialPrerender={15} viewportRef={ref} items={uids}>
|
||||||
{(uid) => {
|
{(uid) => {
|
||||||
const currUser = users.byId[uid];
|
const currUser = users.byId[uid];
|
||||||
if (!currUser) return null;
|
if (!currUser) return null;
|
||||||
@@ -71,18 +70,24 @@ const MemberList: FC<Props> = ({ cid }) => {
|
|||||||
const canRemoveFromChannel =
|
const canRemoveFromChannel =
|
||||||
channel && channel.owner == loginUser?.uid && loginUser?.uid != uid;
|
channel && channel.owner == loginUser?.uid && loginUser?.uid != uid;
|
||||||
return (
|
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">
|
<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">
|
<div className="flex gap-4">
|
||||||
<User compact uid={uid} interactive={false} />
|
<User compact uid={uid} interactive={false} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-sm text-gray-600 dark:text-white flex items-center gap-1">
|
<span className="font-bold text-sm text-gray-600 dark:text-white flex items-center gap-1">
|
||||||
{name} {owner && <IconOwner />}
|
{name} {owner && <IconOwner />}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden md:block text-xs text-gray-500 dark:text-slate-50">{email}</span>
|
<span className="hidden md:block text-xs text-gray-500 dark:text-slate-50">
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-7">
|
<div className="flex items-center gap-7">
|
||||||
{switchRoleVisible ? <Tippy
|
{switchRoleVisible ? (
|
||||||
|
<Tippy
|
||||||
interactive
|
interactive
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@@ -117,9 +122,12 @@ const MemberList: FC<Props> = ({ cid }) => {
|
|||||||
{is_admin ? t("admin") : t("user")}
|
{is_admin ? t("admin") : t("user")}
|
||||||
<IconArrowDown className="dark:fill-slate-50" />
|
<IconArrowDown className="dark:fill-slate-50" />
|
||||||
</span>
|
</span>
|
||||||
</Tippy> :
|
</Tippy>
|
||||||
|
) : (
|
||||||
<span className="text-xs text-right text-gray-500 dark:text-slate-100 flex items-center gap-1">
|
<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>}
|
{is_admin ? t("admin") : t("user")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{dotsVisible && (
|
{dotsVisible && (
|
||||||
<Tippy
|
<Tippy
|
||||||
@@ -156,6 +164,7 @@ const MemberList: FC<Props> = ({ cid }) => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</ViewportList>
|
</ViewportList>
|
||||||
</ul>;
|
</ul>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default MemberList;
|
export default MemberList;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import InviteLink from "../InviteLink";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import InviteLink from "../InviteLink";
|
||||||
import MemberList from "./MemberList";
|
import MemberList from "./MemberList";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,9 +21,7 @@ const ManageMembers: FC<Props> = ({ cid }) => {
|
|||||||
{loginUser?.is_admin && <InviteLink />}
|
{loginUser?.is_admin && <InviteLink />}
|
||||||
<div className="flex flex-col mb-10">
|
<div className="flex flex-col mb-10">
|
||||||
<h4 className="font-bold text-gray-700 dark:text-white">{t("manage_members")}</h4>
|
<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">
|
<p className="text-gray-400 dark:text-gray-100 text-xs">{t("manage_tip")}</p>
|
||||||
{t("manage_tip")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<MemberList cid={cid} />
|
<MemberList cid={cid} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FC } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import IconClose from "@/assets/icons/close.svg";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import Button from "../styled/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
handleInstall?: () => void;
|
handleInstall?: () => void;
|
||||||
closePrompt?: () => void;
|
closePrompt?: () => void;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import Prompt from "./Prompt";
|
|
||||||
import usePWAInstallPrompt from "@/hooks/usePWAInstallPrompt";
|
|
||||||
import { BeforeInstallPromptEvent } from "@/types/global";
|
import { BeforeInstallPromptEvent } from "@/types/global";
|
||||||
interface IProps { }
|
import usePWAInstallPrompt from "@/hooks/usePWAInstallPrompt";
|
||||||
|
import Prompt from "./Prompt";
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
const Manifest: FC<IProps> = () => {
|
const Manifest: FC<IProps> = () => {
|
||||||
const { setCanceled, prompted, setDeferredPrompt, showPrompt } = usePWAInstallPrompt();
|
const { setCanceled, prompted, setDeferredPrompt, showPrompt } = usePWAInstallPrompt();
|
||||||
const [popup, setPopup] = useState(false);
|
const [popup, setPopup] = useState(false);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useRef, useEffect, FC } from "react";
|
import { FC, useEffect, useRef } from "react";
|
||||||
import { Editor } from "@toast-ui/react-editor";
|
import { Editor } from "@toast-ui/react-editor";
|
||||||
|
|
||||||
import "prismjs/themes/prism.css";
|
import "prismjs/themes/prism.css";
|
||||||
import "@toast-ui/editor/dist/toastui-editor.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 "@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 codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js";
|
||||||
|
|
||||||
import useUploadFile from "@/hooks/useUploadFile";
|
import useUploadFile from "@/hooks/useUploadFile";
|
||||||
import Button from "../styled/Button";
|
|
||||||
import { isDarkMode } from "@/utils";
|
import { isDarkMode } from "@/utils";
|
||||||
|
import Button from "../styled/Button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
updateDraft?: (draft: string) => void;
|
updateDraft?: (draft: string) => void;
|
||||||
@@ -32,7 +33,6 @@ const MarkdownEditor: FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef?.current;
|
const editor = editorRef?.current;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
|
||||||
const editorInstance = editor.getInstance();
|
const editorInstance = editor.getInstance();
|
||||||
editorInstance.removeHook("addImageBlobHook");
|
editorInstance.removeHook("addImageBlobHook");
|
||||||
editorInstance.addHook("addImageBlobHook", async (blob, callback) => {
|
editorInstance.addHook("addImageBlobHook", async (blob, callback) => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useRef, FC } from "react";
|
import { FC, useRef } from "react";
|
||||||
|
|
||||||
import "prismjs/themes/prism.css";
|
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 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/theme/toastui-editor-dark.css';
|
||||||
// import '@toast-ui/editor/dist/toastui-editor.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';
|
||||||
@@ -12,7 +14,6 @@ import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin
|
|||||||
import { isDarkMode } from "../utils";
|
import { isDarkMode } from "../utils";
|
||||||
import ImagePreview from "./ImagePreview";
|
import ImagePreview from "./ImagePreview";
|
||||||
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,11 @@ const MarkdownRender: FC<IProps> = ({ content }) => {
|
|||||||
<>
|
<>
|
||||||
<ImagePreview container={mdContainer.current} context="markdown" />
|
<ImagePreview container={mdContainer.current} context="markdown" />
|
||||||
<div ref={mdContainer} id="MARKDOWN_CONTAINER">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useDispatch } from "react-redux";
|
||||||
import Tippy from "@tippyjs/react";
|
import Tippy from "@tippyjs/react";
|
||||||
|
import clsx from "clsx";
|
||||||
import { hideAll } from "tippy.js";
|
import { hideAll } from "tippy.js";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { updateSelectMessages } from "@/app/slices/ui";
|
import { updateSelectMessages } from "@/app/slices/ui";
|
||||||
import ContextMenu, { Item } from "../ContextMenu";
|
import { ChatContext } from "@/types/common";
|
||||||
import Tooltip from "../Tooltip";
|
|
||||||
import useFavMessage from "@/hooks/useFavMessage";
|
import useFavMessage from "@/hooks/useFavMessage";
|
||||||
import useSendMessage from "@/hooks/useSendMessage";
|
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 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 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 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 useMessageOperation from "./useMessageOperation";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ChatContext } from "@/types/common";
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
@@ -29,7 +31,13 @@ type Props = {
|
|||||||
mid: number;
|
mid: number;
|
||||||
toggleEditMessage: () => void;
|
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 { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
canDelete,
|
canDelete,
|
||||||
@@ -89,10 +97,12 @@ const Commands: FC<Props> = ({ isSelf, context = "dm", contextId = 0, mid = 0, t
|
|||||||
<>
|
<>
|
||||||
<ul
|
<ul
|
||||||
ref={cmdsRef}
|
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`,
|
className={clsx(
|
||||||
tippyVisible && '!visible',
|
`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"
|
isSelf ? "left-2.5" : "right-2.5"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Tippy
|
<Tippy
|
||||||
onShow={handleTippyVisible.bind(null, true)}
|
onShow={handleTippyVisible.bind(null, true)}
|
||||||
onHide={handleTippyVisible.bind(null, false)}
|
onHide={handleTippyVisible.bind(null, false)}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement } from "react";
|
||||||
import Tippy from "@tippyjs/react";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDispatch } from "react-redux";
|
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 IconDelete from "@/assets/icons/delete.svg";
|
||||||
import IconEdit from "@/assets/icons/edit.svg";
|
import IconEdit from "@/assets/icons/edit.svg";
|
||||||
import IconReply from "@/assets/icons/reply.svg";
|
|
||||||
import IconForward from "@/assets/icons/forward.svg";
|
import IconForward from "@/assets/icons/forward.svg";
|
||||||
import IconPin from "@/assets/icons/pin.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 IconSelect from "@/assets/icons/select.svg";
|
||||||
import { updateSelectMessages } from "@/app/slices/ui";
|
import ContextMenu, { Item } from "../ContextMenu";
|
||||||
import useSendMessage from "@/hooks/useSendMessage";
|
|
||||||
import useMessageOperation from "./useMessageOperation";
|
import useMessageOperation from "./useMessageOperation";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChatContext } from "@/types/common";
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
contextId: number;
|
contextId: number;
|
||||||
|
|||||||
@@ -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 TextareaAutosize from "react-textarea-autosize";
|
||||||
import { useKey } from "rooks";
|
import { useKey } from "rooks";
|
||||||
import { useEditMessageMutation } from "@/app/services/message";
|
|
||||||
import { ContentTypes } from "@/app/config";
|
import { ContentTypes } from "@/app/config";
|
||||||
|
import { useEditMessageMutation } from "@/app/services/message";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -56,7 +57,6 @@ const EditMessage: FC<Props> = ({ mid, cancelEdit }) => {
|
|||||||
};
|
};
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="bg-gray-200 rounded-lg p-4">
|
<div className="bg-gray-200 rounded-lg p-4">
|
||||||
@@ -80,10 +80,16 @@ const EditMessage: FC<Props> = ({ mid, cancelEdit }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center p-1 gap-4 text-xs">
|
<div className="flex items-center p-1 gap-4 text-xs">
|
||||||
<span>
|
<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>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import dayjs from 'dayjs';
|
import { FC, useCallback, useEffect, useState } from "react";
|
||||||
import { FC, useEffect, useState, useCallback } from 'react';
|
import { useDispatch } from "react-redux";
|
||||||
import { useDispatch } from 'react-redux';
|
import dayjs from "dayjs";
|
||||||
import { removeMessage } from '../../app/slices/message';
|
|
||||||
import { removeChannelMsg } from '../../app/slices/message.channel';
|
import IconTimer from "@/assets/icons/timer.svg";
|
||||||
import { removeUserMsg } from '../../app/slices/message.user';
|
import { removeMessage } from "../../app/slices/message";
|
||||||
import IconTimer from '@/assets/icons/timer.svg';
|
import { removeChannelMsg } from "../../app/slices/message.channel";
|
||||||
import { ChatContext } from '../../types/common';
|
import { removeUserMsg } from "../../app/slices/message.user";
|
||||||
|
import { ChatContext } from "../../types/common";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: ChatContext,
|
context: ChatContext;
|
||||||
contextId: number,
|
contextId: number;
|
||||||
mid: number;
|
mid: number;
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
createAt: number;
|
createAt: number;
|
||||||
@@ -17,18 +19,14 @@ type Props = {
|
|||||||
const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }) => {
|
const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }) => {
|
||||||
const [countdown, setCountdown] = useState<number | undefined>();
|
const [countdown, setCountdown] = useState<number | undefined>();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const clearMsgFromClient = useCallback(
|
const clearMsgFromClient = useCallback(() => {
|
||||||
() => {
|
|
||||||
if (context == "dm") {
|
if (context == "dm") {
|
||||||
dispatch(removeUserMsg({ mid, id: contextId }));
|
dispatch(removeUserMsg({ mid, id: contextId }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(removeChannelMsg({ mid, id: contextId }));
|
dispatch(removeChannelMsg({ mid, id: contextId }));
|
||||||
|
|
||||||
}
|
}
|
||||||
dispatch(removeMessage(mid));
|
dispatch(removeMessage(mid));
|
||||||
},
|
}, [context, contextId, mid]);
|
||||||
[context, contextId, mid],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expiresIn > 0 && createAt > 0) {
|
if (expiresIn > 0 && createAt > 0) {
|
||||||
@@ -36,7 +34,6 @@ const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }
|
|||||||
if (dayjs().isAfter(new Date(expire_time))) {
|
if (dayjs().isAfter(new Date(expire_time))) {
|
||||||
// 已过期,立即删除
|
// 已过期,立即删除
|
||||||
clearMsgFromClient();
|
clearMsgFromClient();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 倒计时
|
// 倒计时
|
||||||
setCountdown(Math.floor((expire_time - new Date().getTime()) / 1000));
|
setCountdown(Math.floor((expire_time - new Date().getTime()) / 1000));
|
||||||
@@ -48,7 +45,7 @@ const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }
|
|||||||
if (typeof countdown !== "undefined") {
|
if (typeof countdown !== "undefined") {
|
||||||
if (countdown > 0) {
|
if (countdown > 0) {
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
setCountdown(prev => {
|
setCountdown((prev) => {
|
||||||
const _prev = prev ?? 0;
|
const _prev = prev ?? 0;
|
||||||
return _prev - 1;
|
return _prev - 1;
|
||||||
});
|
});
|
||||||
@@ -68,11 +65,15 @@ const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }
|
|||||||
if (!countdown) return null;
|
if (!countdown) return null;
|
||||||
const duration = dayjs.duration(countdown * 1000);
|
const duration = dayjs.duration(countdown * 1000);
|
||||||
const day = duration.days() !== 0 ? `${duration.days()} day` : "";
|
const day = duration.days() !== 0 ? `${duration.days()} day` : "";
|
||||||
const hours = duration.hours() !== 0 ? `${duration.hours().toString().padStart(2, '0')}:` : "";
|
const hours = duration.hours() !== 0 ? `${duration.hours().toString().padStart(2, "0")}:` : "";
|
||||||
const minutes = duration.minutes() !== 0 ? `${duration.minutes().toString().padStart(2, '0')}:` : "";
|
const minutes =
|
||||||
const formatted_countdown = `${day} ${hours}${minutes}${duration.seconds().toString().padStart(2, '0')}`;
|
duration.minutes() !== 0 ? `${duration.minutes().toString().padStart(2, "0")}:` : "";
|
||||||
|
const formatted_countdown = `${day} ${hours}${minutes}${duration
|
||||||
|
.seconds()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
return (
|
return (
|
||||||
<div className='absolute bottom-1 right-2 text-xs text-gray-400 flex items-center gap-1 font-mono'>
|
<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" />
|
<IconTimer className="w-4 h-4 stroke-slate-400" />
|
||||||
{formatted_countdown}
|
{formatted_countdown}
|
||||||
</div>
|
</div>
|
||||||
@@ -80,4 +81,3 @@ const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ExpireTimer;
|
export default ExpireTimer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FC, ReactElement, useEffect, useState } from "react";
|
import { FC, ReactElement, useEffect, useState } from "react";
|
||||||
import renderContent from "./renderContent";
|
|
||||||
import Avatar from "../Avatar";
|
|
||||||
import useFavMessage from "@/hooks/useFavMessage";
|
import useFavMessage from "@/hooks/useFavMessage";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import renderContent from "./renderContent";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -17,20 +18,34 @@ const FavoredMessage: FC<Props> = ({ id = "" }) => {
|
|||||||
const favorite_mids = messages.map(({ from_mid }) => +from_mid) || [];
|
const favorite_mids = messages.map(({ from_mid }) => +from_mid) || [];
|
||||||
|
|
||||||
setMsgs(
|
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">
|
<div className="list">
|
||||||
{messages.map((msg, idx) => {
|
{messages.map((msg, idx) => {
|
||||||
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
|
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
|
||||||
return (
|
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 && (
|
{user && (
|
||||||
<div className="shrink-0 w-10 h-10 flex">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="w-full flex flex-col gap-2 text-sm">
|
<div className="w-full flex flex-col gap-2 text-sm">
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<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>
|
||||||
<div className="select-text text-gray-800 break-all whitespace-pre-wrap dark:text-white">
|
<div className="select-text text-gray-800 break-all whitespace-pre-wrap dark:text-white">
|
||||||
{renderContent({
|
{renderContent({
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, FC, ReactElement } from "react";
|
import { FC, ReactElement, useEffect, useState } from "react";
|
||||||
import renderContent from "./renderContent";
|
|
||||||
import Avatar from "../Avatar";
|
|
||||||
import IconForward from "@/assets/icons/forward.svg";
|
|
||||||
import useNormalizeMessage from "@/hooks/useNormalizeMessage";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ChatContext } from "@/types/common";
|
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 = {
|
type Props = {
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
@@ -22,7 +23,10 @@ const ForwardedMessage: FC<Props> = ({ context, to, from_uid, id }) => {
|
|||||||
const forward_mids = messages.map(({ from_mid }) => from_mid) || [];
|
const forward_mids = messages.map(({ from_mid }) => from_mid) || [];
|
||||||
// console.log("fff", messages);
|
// console.log("fff", messages);
|
||||||
setForwards(
|
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">
|
<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" />
|
<IconForward className="w-4 h-4 fill-gray-500" />
|
||||||
{t("action.forward")}
|
{t("action.forward")}
|
||||||
@@ -31,7 +35,10 @@ const ForwardedMessage: FC<Props> = ({ context, to, from_uid, id }) => {
|
|||||||
{messages.map((msg, idx) => {
|
{messages.map((msg, idx) => {
|
||||||
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
|
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
|
||||||
return (
|
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 && (
|
{user && (
|
||||||
<div className="w-6 h-6 rounded-full flex shrink-0 overflow-hidden">
|
<div className="w-6 h-6 rounded-full flex shrink-0 overflow-hidden">
|
||||||
<Avatar width={24} height={24} src={user.avatar} name={user.name} />
|
<Avatar width={24} height={24} src={user.avatar} name={user.name} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// import { FC, ReactNode } from "react";
|
// import { FC, ReactNode } from "react";
|
||||||
import Tippy from "@tippyjs/react";
|
import Tippy from "@tippyjs/react";
|
||||||
import Profile from "../Profile";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import Profile from "../Profile";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uid: number;
|
uid: number;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { FC } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
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 { 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 {
|
interface Props {
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import renderContent from "./renderContent";
|
|
||||||
import Avatar from "../Avatar";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import renderContent from "./renderContent";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mid?: number;
|
mid?: number;
|
||||||
context?: "forward" | "pin"
|
context?: "forward" | "pin";
|
||||||
}
|
}
|
||||||
|
|
||||||
const PreviewMessage: FC<Props> = ({ mid = 0, context = "forward" }) => {
|
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 pinMsg = context == "pin";
|
||||||
const forwardMsg = context == "forward";
|
const forwardMsg = context == "forward";
|
||||||
return (
|
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">
|
<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>
|
||||||
<div className="w-full flex flex-col items-start">
|
<div className="w-full flex flex-col items-start">
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
<span className="text-gray-500 text-sm">{name}</span>
|
<span className="text-gray-500 text-sm">{name}</span>
|
||||||
</div>
|
</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({
|
{renderContent({
|
||||||
content_type,
|
content_type,
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import Tippy from "@tippyjs/react";
|
import Tippy from "@tippyjs/react";
|
||||||
import { hideAll } from "tippy.js";
|
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 = ({
|
const ReactionDetails = ({
|
||||||
uids = [],
|
uids = [],
|
||||||
@@ -28,7 +28,11 @@ const ReactionDetails = ({
|
|||||||
? `${names.join(", ")} and ${names.length - 3} others reacted with`
|
? `${names.join(", ")} and ${names.length - 3} others reacted with`
|
||||||
: `${names.join(", ")} reacted with`;
|
: `${names.join(", ")} reacted with`;
|
||||||
return (
|
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">
|
<div className="w-8 h-8">
|
||||||
<ReactionItem native={emoji} />
|
<ReactionItem native={emoji} />
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +69,9 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
|
|||||||
return uids.length > 0 ? (
|
return uids.length > 0 ? (
|
||||||
<span
|
<span
|
||||||
onClick={readOnly ? undefined : handleReact.bind(null, reaction)}
|
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}
|
key={reaction}
|
||||||
>
|
>
|
||||||
<Tippy
|
<Tippy
|
||||||
@@ -81,7 +87,9 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
|
|||||||
</i>
|
</i>
|
||||||
</Tippy>
|
</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>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
@@ -94,7 +102,7 @@ const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
|
|||||||
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
|
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">
|
<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>
|
</button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useReactMessageMutation } from "@/app/services/message";
|
|
||||||
import { Emojis } from "@/app/config";
|
import { Emojis } from "@/app/config";
|
||||||
import Emoji from "../ReactionItem";
|
import { useReactMessageMutation } from "@/app/services/message";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import Emoji from "../ReactionItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mid: number;
|
mid: number;
|
||||||
@@ -25,17 +26,22 @@ const ReactionPicker: FC<Props> = ({ mid, hidePicker }) => {
|
|||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoViewIfNeeded(false);
|
el.scrollIntoViewIfNeeded(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="z-[999]">
|
<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) => {
|
{Emojis.map((emoji) => {
|
||||||
let reacted =
|
let reacted =
|
||||||
reactionData[emoji] && reactionData[emoji].findIndex((id) => id == currUid) > -1;
|
reactionData[emoji] && reactionData[emoji].findIndex((id) => id == currUid) > -1;
|
||||||
return (
|
return (
|
||||||
<li
|
<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}
|
key={emoji}
|
||||||
onClick={handleReact.bind(null, emoji)}
|
onClick={handleReact.bind(null, emoji)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
import React, { MouseEvent, FC } from "react";
|
import React, { FC, MouseEvent } 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 { useTranslation } from "react-i18next";
|
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 renderContent = (data: MessagePayload) => {
|
||||||
|
|
||||||
const { content_type, content, thumbnail, properties } = data;
|
const { content_type, content, thumbnail, properties } = data;
|
||||||
let res = null;
|
let res = null;
|
||||||
switch (content_type) {
|
switch (content_type) {
|
||||||
case ContentTypes.text:
|
case ContentTypes.text:
|
||||||
res = (
|
res = (
|
||||||
<span className="max-w-lg md:truncate md:break-words md:break-all text-gray-800 dark:text-gray-100">
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContentTypes.audio:
|
case ContentTypes.audio:
|
||||||
res = (
|
res = <span className=" text-primary-400 text-sm">[Voice Message]</span>;
|
||||||
<span className=" text-primary-400 text-sm">[Voice Message]</span>
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case ContentTypes.markdown:
|
case ContentTypes.markdown:
|
||||||
res = (
|
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`;
|
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
|
if (!data)
|
||||||
key={mid}
|
return (
|
||||||
data-mid={mid}
|
<div key={mid} data-mid={mid} className={clsx(defaultClass, "italic")}>
|
||||||
className={clsx(defaultClass, 'italic')}
|
{t("reply_msg_del")}
|
||||||
>{t("reply_msg_del")}</div>;
|
</div>
|
||||||
|
);
|
||||||
const currUser = users[data.from_uid || 0];
|
const currUser = users[data.from_uid || 0];
|
||||||
if (!currUser) return null;
|
if (!currUser) return null;
|
||||||
|
|
||||||
@@ -95,9 +98,7 @@ const Reply: FC<ReplyProps> = ({ mid, interactive = true }) => {
|
|||||||
<div
|
<div
|
||||||
key={mid}
|
key={mid}
|
||||||
data-mid={mid}
|
data-mid={mid}
|
||||||
className={clsx(defaultClass,
|
className={clsx(defaultClass, interactive ? "cursor-pointer" : "!bg-transparent")}
|
||||||
interactive ? "cursor-pointer" : "!bg-transparent"
|
|
||||||
)}
|
|
||||||
onClick={interactive ? handleClick : undefined}
|
onClick={interactive ? handleClick : undefined}
|
||||||
>
|
>
|
||||||
<div className="md:flex shrink-0 w-6 h-6 hidden ">
|
<div className="md:flex shrink-0 w-6 h-6 hidden ">
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
import { useLazyGetOGInfoQuery } from "@/app/services/message";
|
import { useLazyGetOGInfoQuery } from "@/app/services/message";
|
||||||
import { upsertOG } from "@/app/slices/footprint";
|
import { upsertOG } from "@/app/slices/footprint";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|
||||||
|
|
||||||
export default function URLPreview({ url = "" }) {
|
export default function URLPreview({ url = "" }) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [favicon, setFavicon] = useState("");
|
const [favicon, setFavicon] = useState("");
|
||||||
const [getInfo, { isLoading }] = useLazyGetOGInfoQuery();
|
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>(
|
const [data, setData] = useState<{ title: string; description: string; ogImage: string } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -46,18 +46,26 @@ export default function URLPreview({ url = "" }) {
|
|||||||
|
|
||||||
return ogImage ? (
|
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>
|
<h3 className={`text-primary-500 w-full truncate`}>{title}</h3>
|
||||||
<p className={`text-xs text-gray-400 mb-2 w-full truncate`}>{description}</p>
|
<p className={`text-xs text-gray-400 mb-2 w-full truncate`}>{description}</p>
|
||||||
<div className="w-full h-[180px]">
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
// 带图详情
|
// 带图详情
|
||||||
<a
|
<a className={`${containerClass} gap-2 px-2 py-3`} href={url} target="_blank" rel="noreferrer">
|
||||||
className={`${containerClass} gap-2 px-2 py-3`}
|
|
||||||
href={url} target="_blank" rel="noreferrer">
|
|
||||||
{favicon && (
|
{favicon && (
|
||||||
<div className="flex rounded">
|
<div className="flex rounded">
|
||||||
<img className="object-contain w-12 h-12" src={favicon} alt="favicon" />
|
<img className="object-contain w-12 h-12" src={favicon} alt="favicon" />
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import React, { useRef, useState, useEffect, FC } from "react";
|
import React, { FC, useEffect, useRef, useState } from "react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
import Tippy from "@tippyjs/react";
|
import Tippy from "@tippyjs/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import useInView from "./useInView";
|
import { useAppSelector } from "@/app/store";
|
||||||
import Reaction from "./Reaction";
|
import { ChatContext } from "@/types/common";
|
||||||
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 useContextMenu from "@/hooks/useContextMenu";
|
import useContextMenu from "@/hooks/useContextMenu";
|
||||||
import usePinMessage from "@/hooks/usePinMessage";
|
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 ExpireTimer from "./ExpireTimer";
|
||||||
import IconInfo from '@/assets/icons/info.svg';
|
import Reaction from "./Reaction";
|
||||||
import { ChatContext } from "@/types/common";
|
import renderContent from "./renderContent";
|
||||||
|
import Reply from "./Reply";
|
||||||
|
import useInView from "./useInView";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -41,7 +41,8 @@ const Message: FC<IProps> = ({
|
|||||||
const [edit, setEdit] = useState(false);
|
const [edit, setEdit] = useState(false);
|
||||||
const avatarRef = useRef(null);
|
const avatarRef = useRef(null);
|
||||||
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
|
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
|
||||||
const { message, reactionMessageData, usersData, loginUid, enableRightLayout } = useAppSelector((store) => {
|
const { message, reactionMessageData, usersData, loginUid, enableRightLayout } = useAppSelector(
|
||||||
|
(store) => {
|
||||||
return {
|
return {
|
||||||
enableRightLayout: store.server.chat_layout_mode == "SelfRight",
|
enableRightLayout: store.server.chat_layout_mode == "SelfRight",
|
||||||
reactionMessageData: store.reactionMessage,
|
reactionMessageData: store.reactionMessage,
|
||||||
@@ -49,7 +50,8 @@ const Message: FC<IProps> = ({
|
|||||||
usersData: store.users.byId,
|
usersData: store.users.byId,
|
||||||
loginUid: store.authData.user?.uid
|
loginUid: store.authData.user?.uid
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const toggleEditMessage = () => {
|
const toggleEditMessage = () => {
|
||||||
setEdit((prev) => !prev);
|
setEdit((prev) => !prev);
|
||||||
@@ -81,10 +83,8 @@ const Message: FC<IProps> = ({
|
|||||||
properties,
|
properties,
|
||||||
expires_in = 0,
|
expires_in = 0,
|
||||||
failed = false
|
failed = false
|
||||||
|
|
||||||
} = message;
|
} = message;
|
||||||
|
|
||||||
|
|
||||||
const reactions = reactionMessageData[mid];
|
const reactions = reactionMessageData[mid];
|
||||||
const currUser = usersData[fromUid || 0];
|
const currUser = usersData[fromUid || 0];
|
||||||
// if (!message) return null;
|
// if (!message) return null;
|
||||||
@@ -98,17 +98,17 @@ const Message: FC<IProps> = ({
|
|||||||
const showExpire = (expires_in ?? 0) > 0;
|
const showExpire = (expires_in ?? 0) > 0;
|
||||||
const isSelf = fromUid == loginUid && enableRightLayout;
|
const isSelf = fromUid == loginUid && enableRightLayout;
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div
|
<div
|
||||||
key={_key}
|
key={_key}
|
||||||
onContextMenu={readOnly ? undefined : handleContextMenuEvent}
|
onContextMenu={readOnly ? undefined : handleContextMenuEvent}
|
||||||
data-msg-mid={mid}
|
data-msg-mid={mid}
|
||||||
ref={inViewRef}
|
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",
|
readOnly && "hover:bg-transparent",
|
||||||
showExpire && "bg-red-200 dark:bg-red-200/40",
|
showExpire && "bg-red-200 dark:bg-red-200/40",
|
||||||
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7",
|
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7",
|
||||||
isSelf && "flex-row-reverse",
|
isSelf && "flex-row-reverse"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tippy
|
<Tippy
|
||||||
@@ -121,7 +121,13 @@ const Message: FC<IProps> = ({
|
|||||||
content={<Profile uid={fromUid || 0} type="card" cid={context == "dm" ? 0 : contextId} />}
|
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}>
|
<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>
|
</div>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -133,14 +139,23 @@ const Message: FC<IProps> = ({
|
|||||||
hide={hideContextMenu}
|
hide={hideContextMenu}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative", isSelf && "items-end")}
|
className={clsx(
|
||||||
data-pin-tip={`pinned by ${pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
|
"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">
|
{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 : ""}`}
|
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
|
||||||
</span>}
|
</span>
|
||||||
<div className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}>
|
)}
|
||||||
|
<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>
|
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
delay={200}
|
delay={200}
|
||||||
@@ -154,13 +169,18 @@ const Message: FC<IProps> = ({
|
|||||||
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
|
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
|
||||||
</time>
|
</time>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{failed && <span className="text-red-500 text-xs flex items-center gap-1">
|
{failed && (
|
||||||
|
<span className="text-red-500 text-xs flex items-center gap-1">
|
||||||
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
|
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
|
||||||
</span>}
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
|
<div
|
||||||
sending && "opacity-90",
|
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} />}
|
{reply_mid && <Reply key={reply_mid} mid={reply_mid} />}
|
||||||
{edit ? (
|
{edit ? (
|
||||||
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
|
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// import React from "react";
|
// import React from "react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { ContentTypes } from "@/app/config";
|
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 { 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 = {
|
type Props = {
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
@@ -41,7 +42,10 @@ const renderContent = ({
|
|||||||
<>
|
<>
|
||||||
<LinkifyText text={content} cid={to} />
|
<LinkifyText text={content} cid={to} />
|
||||||
{edited && (
|
{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" })})
|
({i18n.t("edited", { ns: "chat" })})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
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 DeleteMessageConfirm from "../DeleteMessageConfirm";
|
||||||
import ForwardModal from "../ForwardModal";
|
import ForwardModal from "../ForwardModal";
|
||||||
import PinMessageModal from "./PinMessageModal";
|
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 {
|
interface Params {
|
||||||
mid: number;
|
mid: number;
|
||||||
@@ -74,7 +75,10 @@ export default function useMessageOperation({ mid, context, contextId }: Params)
|
|||||||
properties?.content_type.startsWith("image");
|
properties?.content_type.startsWith("image");
|
||||||
const enableEdit =
|
const enableEdit =
|
||||||
loginUser?.uid == from_uid && [ContentTypes.text, ContentTypes.markdown].includes(content_type);
|
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;
|
const canCopy = [ContentTypes.text, ContentTypes.markdown].includes(content_type) || isImage;
|
||||||
return {
|
return {
|
||||||
copyContent: isImage ? copyContent.bind(null, true) : copyContent.bind(null, false),
|
copyContent: isImage ? copyContent.bind(null, true) : copyContent.bind(null, false),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
import BASE_URL from "@/app/config";
|
import BASE_URL from "@/app/config";
|
||||||
import { useGetServerQuery } from "@/app/services/server";
|
import { useGetServerQuery } from "@/app/services/server";
|
||||||
|
|
||||||
type Props = {};
|
type Props = {};
|
||||||
const Meta: FC<Props> = () => {
|
const Meta: FC<Props> = () => {
|
||||||
const { data, isSuccess } = useGetServerQuery();
|
const { data, isSuccess } = useGetServerQuery();
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
import { useRef, useEffect, ClipboardEvent, FC } from "react";
|
import { ClipboardEvent, FC, useEffect, useRef } from "react";
|
||||||
import { useKey } from "rooks";
|
|
||||||
import { Editor, Transforms } from "slate";
|
|
||||||
import {
|
import {
|
||||||
createPlateUI,
|
|
||||||
Plate,
|
|
||||||
createExitBreakPlugin,
|
createExitBreakPlugin,
|
||||||
createTrailingBlockPlugin,
|
createMentionPlugin,
|
||||||
createNodeIdPlugin,
|
createNodeIdPlugin,
|
||||||
createParagraphPlugin,
|
createParagraphPlugin,
|
||||||
createSoftBreakPlugin,
|
createPlateUI,
|
||||||
createMentionPlugin,
|
|
||||||
findMentionInput,
|
|
||||||
createPlugins,
|
createPlugins,
|
||||||
|
createSoftBreakPlugin,
|
||||||
|
createTrailingBlockPlugin,
|
||||||
ELEMENT_PARAGRAPH,
|
ELEMENT_PARAGRAPH,
|
||||||
|
findMentionInput,
|
||||||
getPlateEditorRef,
|
getPlateEditorRef,
|
||||||
MentionCombobox
|
MentionCombobox,
|
||||||
|
Plate
|
||||||
} from "@udecode/plate";
|
} from "@udecode/plate";
|
||||||
import { createComboboxPlugin } from "@udecode/plate-combobox";
|
import { createComboboxPlugin } from "@udecode/plate-combobox";
|
||||||
|
import { useKey } from "rooks";
|
||||||
|
import { Editor, Transforms } from "slate";
|
||||||
import { ReactEditor } from "slate-react";
|
import { ReactEditor } from "slate-react";
|
||||||
import useUploadFile from "@/hooks/useUploadFile";
|
|
||||||
import { CONFIG } from "./config";
|
|
||||||
import User from "../User";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import { isMobile } from "@/utils";
|
|
||||||
import { ChatContext } from "@/types/common";
|
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";
|
export const TEXT_EDITOR_PREFIX = "_text_editor";
|
||||||
|
|
||||||
let components = createPlateUI({
|
let components = createPlateUI({
|
||||||
@@ -49,7 +51,6 @@ const Plugins: FC<Props> = ({
|
|||||||
updateMessages,
|
updateMessages,
|
||||||
members = []
|
members = []
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
// const { getMenuProps, getItemProps } = useComboboxControls();
|
// const { getMenuProps, getItemProps } = useComboboxControls();
|
||||||
const [context, to] = id.split("_") as [ChatContext, number];
|
const [context, to] = id.split("_") as [ChatContext, number];
|
||||||
const { addStageFile } = useUploadFile({ context, id: to });
|
const { addStageFile } = useUploadFile({ context, id: to });
|
||||||
@@ -99,7 +100,7 @@ const Plugins: FC<Props> = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
when: !isMobile(),
|
when: !isMobile(),
|
||||||
target: editableRef,
|
target: editableRef
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const pluginArr = [
|
const pluginArr = [
|
||||||
@@ -180,9 +181,11 @@ const Plugins: FC<Props> = ({
|
|||||||
updateMessages(msgs);
|
updateMessages(msgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
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
|
<Plate
|
||||||
id={`${TEXT_EDITOR_PREFIX}_${id}`}
|
id={`${TEXT_EDITOR_PREFIX}_${id}`}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FC, useState, useEffect } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { KEY_MOBILE_APP_TIP } from "@/app/config";
|
import { KEY_MOBILE_APP_TIP } from "@/app/config";
|
||||||
import Button from "./styled/Button";
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ const Container = (props: ContainerProps) => {
|
|||||||
localStorage.removeItem(KEY_MOBILE_APP_TIP);
|
localStorage.removeItem(KEY_MOBILE_APP_TIP);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
};
|
};
|
||||||
return <div className="flex flex-col md:flex-row items-center gap-2 whitespace-nowrap">
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-2 whitespace-nowrap">
|
||||||
<div>
|
<div>
|
||||||
<Trans i18nKey={"mobile_app"}>
|
<Trans i18nKey={"mobile_app"}>
|
||||||
<strong className="font-bold" />
|
<strong className="font-bold" />
|
||||||
@@ -33,9 +35,10 @@ const Container = (props: ContainerProps) => {
|
|||||||
{t("action.dismiss")}
|
{t("action.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
interface Props { }
|
interface Props {}
|
||||||
const Index: FC<Props> = () => {
|
const Index: FC<Props> = () => {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC, useEffect, useState, PropsWithChildren } from "react";
|
import { FC, PropsWithChildren, useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { KEY_MOBILE_APP_TIP } from "@/app/config";
|
import { KEY_MOBILE_APP_TIP } from "@/app/config";
|
||||||
import Button from "./styled/Button";
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, memo } from "react";
|
import { memo, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import useDeviceToken from "./useDeviceToken";
|
|
||||||
import { vapidKey } from "@/app/config";
|
import { vapidKey } from "@/app/config";
|
||||||
import { useUpdateDeviceTokenMutation } from "@/app/services/auth";
|
import { useUpdateDeviceTokenMutation } from "@/app/services/auth";
|
||||||
|
import useDeviceToken from "./useDeviceToken";
|
||||||
|
|
||||||
let updated = false;
|
let updated = false;
|
||||||
let updating = false;
|
let updating = false;
|
||||||
const Notification = () => {
|
const Notification = () => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import renderContent from "./Message/renderContent";
|
import { MessagePayload } from "@/app/slices/message";
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import { PinnedMessage } from "@/types/channel";
|
import { PinnedMessage } from "@/types/channel";
|
||||||
import { normalizeFileMessage } from "../utils";
|
import { normalizeFileMessage } from "../utils";
|
||||||
import { MessagePayload } from "@/app/slices/message";
|
import Avatar from "./Avatar";
|
||||||
|
import renderContent from "./Message/renderContent";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PinnedMessage
|
data: PinnedMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PinnedMessageView: FC<Props> = ({ data }) => {
|
const PinnedMessageView: FC<Props> = ({ data }) => {
|
||||||
@@ -32,8 +32,10 @@ const PinnedMessageView: FC<Props> = ({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col items-start gap-1 text-sm">
|
<div className="w-full flex flex-col items-start gap-1 text-sm">
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
<span className="text-gray-500 dark:text-gray-400">{name}</span>
|
<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>
|
<time className="text-xs text-gray-400">
|
||||||
|
{dayjs(created_at).format("YYYY-MM-DD h:mm:ss A")}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<div className={`select-text text-gray-600 break-all whitespace-pre-wrap dark:text-white`}>
|
<div className={`select-text text-gray-600 break-all whitespace-pre-wrap dark:text-white`}>
|
||||||
{renderContent({
|
{renderContent({
|
||||||
|
|||||||
@@ -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 { FC, memo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink } from "react-router-dom";
|
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";
|
import Avatar from "../Avatar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
+12
-11
@@ -1,19 +1,19 @@
|
|||||||
// import React from 'react';
|
import QR from "qrcode.react";
|
||||||
import QR from 'qrcode.react';
|
|
||||||
import { useAppSelector } from '@/app/store';
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: string,
|
link: string;
|
||||||
size?: number,
|
size?: number;
|
||||||
level?: "L" | "M" | "H" | "Q"
|
level?: "L" | "M" | "H" | "Q";
|
||||||
}
|
};
|
||||||
|
|
||||||
const QRCode = ({ link, size = 512, level = "L" }: Props) => {
|
const QRCode = ({ link, size = 512, level = "L" }: Props) => {
|
||||||
const logo = useAppSelector(store => store.server.logo);
|
const logo = useAppSelector((store) => store.server.logo);
|
||||||
return (
|
return (
|
||||||
<div className="p-2 bg-white dark:bg-slate-200 rounded">
|
<div className="p-2 bg-white dark:bg-slate-200 rounded">
|
||||||
<QR
|
<QR
|
||||||
renderAs='svg'
|
renderAs="svg"
|
||||||
value={link}
|
value={link}
|
||||||
className="rounded border border-solid border-gray-200 dark:border-none !w-full !h-full"
|
className="rounded border border-solid border-gray-200 dark:border-none !w-full !h-full"
|
||||||
size={size}
|
size={size}
|
||||||
@@ -27,8 +27,9 @@ const QRCode = ({ link, size = 512, level = "L" }: Props) => {
|
|||||||
y: undefined,
|
y: undefined,
|
||||||
height: size / 6,
|
height: size / 6,
|
||||||
width: size / 6,
|
width: size / 6,
|
||||||
excavate: true,
|
excavate: true
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
import { updateRoleChanged } from "@/app/slices/auth.data";
|
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 {
|
interface Props {
|
||||||
reasonType?: "role_changed",
|
reasonType?: "role_changed";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReLoginModal: FC<Props> = ({ reasonType = "role_changed" }) => {
|
const ReLoginModal: FC<Props> = ({ reasonType = "role_changed" }) => {
|
||||||
@@ -33,7 +34,9 @@ const ReLoginModal: FC<Props> = ({ reasonType = "role_changed" }) => {
|
|||||||
<StyledModal
|
<StyledModal
|
||||||
buttons={
|
buttons={
|
||||||
<>
|
<>
|
||||||
<Button className="cancel" onClick={handleReset}>{t("logout.later")}</Button>
|
<Button className="cancel" onClick={handleReset}>
|
||||||
|
{t("logout.later")}
|
||||||
|
</Button>
|
||||||
<Button onClick={handleLogout} className="danger">
|
<Button onClick={handleLogout} className="danger">
|
||||||
{exiting ? "Logging out" : ct("action.re_login")}
|
{exiting ? "Logging out" : ct("action.re_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { FC, ReactElement } from "react";
|
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 EmojiCelebrate from "@/assets/icons/emoji.celebrate.svg";
|
||||||
import EmojiUnhappy from "@/assets/icons/emoji.unhappy.svg";
|
|
||||||
import EmojiHeart from "@/assets/icons/emoji.heart.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 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 {
|
export interface Emojis {
|
||||||
"👍": ReactElement;
|
"👍": ReactElement;
|
||||||
@@ -26,7 +27,7 @@ export const ReactionMap = {
|
|||||||
"👎": ":thumb_down:",
|
"👎": ":thumb_down:",
|
||||||
"😄": ":smile:",
|
"😄": ":smile:",
|
||||||
"👀": ":eyes:",
|
"👀": ":eyes:",
|
||||||
"🚀": ":rocket:",
|
"🚀": ":rocket:"
|
||||||
};
|
};
|
||||||
const emojis: Emojis = {
|
const emojis: Emojis = {
|
||||||
"👍": <EmojiThumbUp className="emoji w-full h-full min-w-[16px] min-h-[16px]" />,
|
"👍": <EmojiThumbUp className="emoji w-full h-full min-w-[16px] min-h-[16px]" />,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FC, ReactElement } from "react";
|
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 { GuestRoutes, KEY_LOCAL_TRY_PATH } from "@/app/config";
|
||||||
import { useGetInitializedQuery } from "@/app/services/auth";
|
import { useGetInitializedQuery } from "@/app/services/auth";
|
||||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||||
@@ -34,7 +35,10 @@ const RequireAuth: FC<Props> = ({ children, redirectTo = "/login" }) => {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
// 记录下当前的路径,登录后跳转回来
|
// 记录下当前的路径,登录后跳转回来
|
||||||
const ignorePath = `/setting/my_account`;
|
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 />;
|
return <Navigate to={redirectTo} replace />;
|
||||||
}
|
}
|
||||||
const tryPath = localStorage.getItem(KEY_LOCAL_TRY_PATH);
|
const tryPath = localStorage.getItem(KEY_LOCAL_TRY_PATH);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useGetInitializedQuery } from "@/app/services/auth";
|
import { useGetInitializedQuery } from "@/app/services/auth";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement } from "react";
|
||||||
|
|
||||||
import useTabBroadcast from "@/hooks/useTabBroadcast";
|
import useTabBroadcast from "@/hooks/useTabBroadcast";
|
||||||
import InactiveScreen from "./InactiveScreen";
|
import InactiveScreen from "./InactiveScreen";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// import clsx from "clsx";
|
// import clsx from "clsx";
|
||||||
import { FC, MouseEvent } from "react";
|
import { FC, MouseEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Button from './styled/Button';
|
|
||||||
|
|
||||||
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
saveHandler: (e: MouseEvent) => void;
|
saveHandler: (e: MouseEvent) => void;
|
||||||
@@ -13,16 +13,21 @@ const SaveTip: FC<Props> = ({ saveHandler, resetHandler }) => {
|
|||||||
const { t } = useTranslation("setting");
|
const { t } = useTranslation("setting");
|
||||||
// const btnClass=clsx("")
|
// const btnClass=clsx("")
|
||||||
return (
|
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
|
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">
|
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>
|
>
|
||||||
|
<span className="p-2 text-sm dark:text-gray-200">{t("save_tip")}</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button className="small ghost border_less !text-gray-700 !shadow-none dark:!text-gray-100" onClick={resetHandler}>
|
<Button
|
||||||
{t('reset')}
|
className="small ghost border_less !text-gray-700 !shadow-none dark:!text-gray-100"
|
||||||
|
onClick={resetHandler}
|
||||||
|
>
|
||||||
|
{t("reset")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="small !rounded-full" onClick={saveHandler}>
|
<Button className="small !rounded-full" onClick={saveHandler}>
|
||||||
{t('save_change')}
|
{t("save_change")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// import Tippy from "@tippyjs/react";
|
// import Tippy from "@tippyjs/react";
|
||||||
import clsx from "clsx";
|
import { ChangeEvent, FC, FormEvent, useRef, useState } from "react";
|
||||||
import { FC, ChangeEvent, useState, useRef, FormEvent } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 IconClose from "@/assets/icons/close.svg";
|
||||||
import IconSearch from "@/assets/icons/search.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 Avatar from "./Avatar";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import StyledButton from "./styled/Button";
|
||||||
|
import Input from "./styled/Input";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
@@ -21,7 +21,7 @@ type Type = "id" | "email";
|
|||||||
|
|
||||||
const SearchUser: FC<Props> = ({ closeModal }) => {
|
const SearchUser: FC<Props> = ({ closeModal }) => {
|
||||||
const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation();
|
const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation();
|
||||||
const usersData = useAppSelector(store => store.users.byId);
|
const usersData = useAppSelector((store) => store.users.byId);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateTo = useNavigate();
|
const navigateTo = useNavigate();
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -35,11 +35,11 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
|
|||||||
const tmp = {
|
const tmp = {
|
||||||
[type]: evt.target.value
|
[type]: evt.target.value
|
||||||
};
|
};
|
||||||
setInput(prev => ({ ...prev, ...tmp }));
|
setInput((prev) => ({ ...prev, ...tmp }));
|
||||||
};
|
};
|
||||||
const resetInput = () => {
|
const resetInput = () => {
|
||||||
reset();
|
reset();
|
||||||
setInput(prev => ({ ...prev, [type]: "" }));
|
setInput((prev) => ({ ...prev, [type]: "" }));
|
||||||
};
|
};
|
||||||
const handleSubmit = (evt: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (evt: FormEvent<HTMLFormElement>) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -77,32 +77,77 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
|
|||||||
<Modal>
|
<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=" 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">
|
<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" })}
|
{t("search_by_id", { ns: "member" })}
|
||||||
</StyledButton>
|
</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" })}
|
{t("search_by_email", { ns: "member" })}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
<form className="w-full" ref={inputRef} action="/" onSubmit={handleSubmit}>
|
<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>
|
</form>
|
||||||
<div className="min-h-[280px] flex-center pb-10">
|
<div className="min-h-[280px] flex-center pb-10">
|
||||||
{isSuccess ? (data ? <div className="flex flex-col items-center pt-10">
|
{isSuccess ? (
|
||||||
<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} />
|
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>
|
<span className="my-2 dark:text-gray-100 text-gray-950">{data.name}</span>
|
||||||
<div className="flex gap-2 my-2">
|
<div className="flex gap-2 my-2">
|
||||||
<StyledButton className="mini ghost" onClick={resetInput}>{t("action.cancel")}</StyledButton>
|
<StyledButton className="mini ghost" onClick={resetInput}>
|
||||||
<StyledButton disabled={adding} onClick={handleChat.bind(null, inContact)} className="mini">{inContact ? t(`chat`) : `Add to Contact`}</StyledButton>
|
{t("action.cancel")}
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
disabled={adding}
|
||||||
|
onClick={handleChat.bind(null, inContact)}
|
||||||
|
className="mini"
|
||||||
|
>
|
||||||
|
{inContact ? t(`chat`) : `Add to Contact`}
|
||||||
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
</div> : <div className="w-full h-full text-center flex flex-col gap-3 items-center">
|
</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">
|
<span className="text-sm text-gray-800 dark:text-gray-200">
|
||||||
{t("search_not_found", { ns: "member" })}
|
{t("search_not_found", { ns: "member" })}
|
||||||
</span>
|
</span>
|
||||||
<StyledButton className="mini" onClick={resetInput}>Ok</StyledButton>
|
<StyledButton className="mini" onClick={resetInput}>
|
||||||
</div>) : null}
|
Ok
|
||||||
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
<IconClose role="button" className="absolute top-2 right-2 dark:fill-white w-5 h-5" onClick={closeModal} />
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<IconClose
|
||||||
|
role="button"
|
||||||
|
className="absolute top-2 right-2 dark:fill-white w-5 h-5"
|
||||||
|
onClick={closeModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useOutsideClick } from "rooks";
|
|
||||||
import clsx from "clsx";
|
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 SmileIcon from "@/assets/icons/emoji.smile.svg";
|
||||||
|
import Tooltip from "../Tooltip";
|
||||||
|
|
||||||
export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string) => void }) {
|
export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string) => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -18,9 +17,7 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
|
|||||||
ref.current.innerHTML = "";
|
ref.current.innerHTML = "";
|
||||||
new Picker({
|
new Picker({
|
||||||
data: async () => {
|
data: async () => {
|
||||||
const response = await fetch(
|
const response = await fetch("https://cdn.jsdelivr.net/npm/@emoji-mart/data");
|
||||||
'https://cdn.jsdelivr.net/npm/@emoji-mart/data',
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
@@ -29,7 +26,7 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
|
|||||||
console.log("eee333", item);
|
console.log("eee333", item);
|
||||||
|
|
||||||
selectEmoji(item.native);
|
selectEmoji(item.native);
|
||||||
},
|
}
|
||||||
// onClickOutside: () => {
|
// onClickOutside: () => {
|
||||||
// setVisible(false);
|
// setVisible(false);
|
||||||
// }
|
// }
|
||||||
@@ -44,7 +41,9 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
|
|||||||
if (!clickEle) return;
|
if (!clickEle) return;
|
||||||
const ignore =
|
const ignore =
|
||||||
(clickEle.nodeName == "svg" && clickEle.dataset.emoji == "toggler") ||
|
(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;
|
if (ignore) return;
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
},
|
},
|
||||||
@@ -54,10 +53,20 @@ export default function EmojiPicker({ selectEmoji }: { selectEmoji: (e: string)
|
|||||||
return (
|
return (
|
||||||
<Tooltip placement="top" tip="Emojis" disabled={visible}>
|
<Tooltip placement="top" tip="Emojis" disabled={visible}>
|
||||||
<div className="hidden md:flex relative w-fit items-center">
|
<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 */}
|
{/* emoji picker */}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { ContentTypes } from "@/app/config";
|
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 IconClose from "@/assets/icons/close.circle.svg";
|
||||||
import pictureIcon from "@/assets/icons/picture.svg?url";
|
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 LinkifyText from "../LinkifyText";
|
||||||
import { ChatContext } from "@/types/common";
|
import MarkdownRender from "../MarkdownRender";
|
||||||
|
|
||||||
const renderContent = (data: MessagePayload) => {
|
const renderContent = (data: MessagePayload) => {
|
||||||
const { content_type, content, thumbnail = "", properties } = data;
|
const { content_type, content, thumbnail = "", properties } = data;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ChangeEvent, useRef, FC } from "react";
|
import { ChangeEvent, FC, useRef } from "react";
|
||||||
import Tooltip from "../Tooltip";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ChatContext } from "@/types/common";
|
||||||
|
import useUploadFile from "@/hooks/useUploadFile";
|
||||||
import AddIcon from "@/assets/icons/add.solid.svg";
|
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 MarkdownIcon from "@/assets/icons/markdown.svg";
|
||||||
import SendIcon from "@/assets/icons/send.svg";
|
import SendIcon from "@/assets/icons/send.svg";
|
||||||
import FullscreenIcon from "@/assets/icons/fullscreen.svg";
|
import Tooltip from "../Tooltip";
|
||||||
import ExitFullscreenIcon from "@/assets/icons/fullscreen.exit.svg";
|
|
||||||
import useUploadFile from "@/hooks/useUploadFile";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChatContext } from "@/types/common";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sendMessages: () => void;
|
sendMessages: () => void;
|
||||||
@@ -17,7 +18,7 @@ type Props = {
|
|||||||
mode: "markdown" | "text";
|
mode: "markdown" | "text";
|
||||||
to: number;
|
to: number;
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
sendVisible: boolean
|
sendVisible: boolean;
|
||||||
};
|
};
|
||||||
const Toolbar: FC<Props> = ({
|
const Toolbar: FC<Props> = ({
|
||||||
sendMessages,
|
sendMessages,
|
||||||
@@ -49,17 +50,23 @@ const Toolbar: FC<Props> = ({
|
|||||||
// setFiles([...evt.target.files]);
|
// setFiles([...evt.target.files]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMarkdown = mode == 'markdown';
|
const isMarkdown = mode == "markdown";
|
||||||
return (
|
return (
|
||||||
<div className={`hidden md:flex flex-col md:flex-row items-center justify-end gap-2.5`}>
|
<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">
|
<div className="flex cursor-pointer gap-3.5">
|
||||||
<Tooltip placement="top" tip="Markdown">
|
<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>
|
</Tooltip>
|
||||||
{isMarkdown &&
|
{isMarkdown &&
|
||||||
(fullscreen ? (
|
(fullscreen ? (
|
||||||
<Tooltip placement="top" tip="Exit 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>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip placement="top" tip="Fullscreen">
|
<Tooltip placement="top" tip="Fullscreen">
|
||||||
@@ -67,10 +74,15 @@ const Toolbar: FC<Props> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!isMarkdown && <><Tooltip placement="top" tip={t("action.upload")}>
|
{!isMarkdown && (
|
||||||
|
<>
|
||||||
|
<Tooltip placement="top" tip={t("action.upload")}>
|
||||||
<div className="cursor-pointer relative w-6 h-6">
|
<div className="cursor-pointer relative w-6 h-6">
|
||||||
<AddIcon className="dark:fill-gray-300" />
|
<AddIcon className="dark:fill-gray-300" />
|
||||||
<label htmlFor="file" className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0">
|
<label
|
||||||
|
htmlFor="file"
|
||||||
|
className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
className="hidden"
|
className="hidden"
|
||||||
size={24}
|
size={24}
|
||||||
@@ -84,10 +96,16 @@ const Toolbar: FC<Props> = ({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{sendVisible && <Tooltip placement="top" tip="Send">
|
{sendVisible && (
|
||||||
<SendIcon className={"w-6 h-6 cursor-pointer animate-zoomIn dark:stroke-gray-300"} onClick={sendMessages} />
|
<Tooltip placement="top" tip="Send">
|
||||||
</Tooltip>}
|
<SendIcon
|
||||||
</>}
|
className={"w-6 h-6 cursor-pointer animate-zoomIn dark:stroke-gray-300"}
|
||||||
|
onClick={sendMessages}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useState } from "react";
|
||||||
|
|
||||||
|
import CloseIcon from "@/assets/icons/close.svg";
|
||||||
import Modal from "../../Modal";
|
import Modal from "../../Modal";
|
||||||
import Button from "../../styled/Button";
|
import Button from "../../styled/Button";
|
||||||
import Input from "../../styled/Input";
|
import Input from "../../styled/Input";
|
||||||
import CloseIcon from "@/assets/icons/close.svg";
|
|
||||||
|
|
||||||
|
|
||||||
export default function EditFileDetails({
|
export default function EditFileDetails({
|
||||||
name,
|
name,
|
||||||
@@ -30,7 +30,9 @@ export default function EditFileDetails({
|
|||||||
File Details <CloseIcon className="cursor-pointer dark:fill-white" onClick={closeModal} />
|
File Details <CloseIcon className="cursor-pointer dark:fill-white" onClick={closeModal} />
|
||||||
</h4>
|
</h4>
|
||||||
<div className="py-4 flex flex-col gap-2">
|
<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} />
|
<Input id="name" value={fileName} onChange={handleNameChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4 mt-8 w-full">
|
<div className="flex justify-end gap-4 mt-8 w-full">
|
||||||
|
|||||||
@@ -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 { useMixedEditor } from "../../MixedInput";
|
||||||
import EditFileDetailsModal from "./EditFileDetails";
|
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 = {
|
type EditProps = {
|
||||||
index: number;
|
index: number;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
export default function UploadFileList({
|
export default function UploadFileList({ context, id }: { context: ChatContext; id: number }) {
|
||||||
context,
|
|
||||||
id
|
|
||||||
}: {
|
|
||||||
context: ChatContext;
|
|
||||||
id: number;
|
|
||||||
}) {
|
|
||||||
const editor = useMixedEditor(`${context}_${id}`);
|
const editor = useMixedEditor(`${context}_${id}`);
|
||||||
const [editInfo, setEditInfo] = useState<EditProps | null>(null);
|
const [editInfo, setEditInfo] = useState<EditProps | null>(null);
|
||||||
const { stageFiles, updateStageFile, removeStageFile } = useUploadFile({
|
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">
|
<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) => {
|
{stageFiles.map(({ name, url, size, type }, idx: number) => {
|
||||||
return (
|
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">
|
<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>
|
</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>
|
<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">
|
<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 />
|
<EditIcon />
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { useEffect, useState, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSendMessage from "@/hooks/useSendMessage";
|
import clsx from "clsx";
|
||||||
import useAddLocalFileMessage from "@/hooks/useAddLocalFileMessage";
|
|
||||||
import { updateInputMode } from "@/app/slices/ui";
|
|
||||||
import { ContentTypes, ChatPrefixes } from "@/app/config";
|
|
||||||
|
|
||||||
// import StyledSend from "./styled";
|
import { ChatPrefixes, ContentTypes } from "@/app/config";
|
||||||
import UploadFileList from "./UploadFileList";
|
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 Replying from "./Replying";
|
||||||
import Toolbar from "./Toolbar";
|
import Toolbar from "./Toolbar";
|
||||||
import EmojiPicker from "./EmojiPicker";
|
// import StyledSend from "./styled";
|
||||||
|
import UploadFileList from "./UploadFileList";
|
||||||
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";
|
|
||||||
|
|
||||||
const Modes = {
|
const Modes = {
|
||||||
text: "text",
|
text: "text",
|
||||||
@@ -36,7 +35,10 @@ const Send: FC<IProps> = ({
|
|||||||
id
|
id
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation("chat");
|
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 { resetStageFiles } = useUploadFile({ context, id });
|
||||||
const { getDraft, getUpdateDraft } = useDraft({ context, id });
|
const { getDraft, getUpdateDraft } = useDraft({ context, id });
|
||||||
const editor = useMixedEditor(`${context}_${id}`);
|
const editor = useMixedEditor(`${context}_${id}`);
|
||||||
@@ -88,7 +90,7 @@ const Send: FC<IProps> = ({
|
|||||||
// send text msgs
|
// send text msgs
|
||||||
for await (const msg of msgs) {
|
for await (const msg of msgs) {
|
||||||
const { type: content_type, content, properties = {} } = msg;
|
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();
|
properties.local_id = properties.local_id ?? +new Date();
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
id,
|
id,
|
||||||
@@ -146,10 +148,14 @@ const Send: FC<IProps> = ({
|
|||||||
context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : [];
|
context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : [];
|
||||||
const isMarkdownMode = mode == Modes.markdown;
|
const isMarkdownMode = mode == Modes.markdown;
|
||||||
if (context == "dm" && blocked) {
|
if (context == "dm" && blocked) {
|
||||||
return <div className="p-5 bg-gray-200 rounded-lg w-full dark:bg-gray-600 text-red-300">
|
return (
|
||||||
|
<div className="p-5 bg-gray-200 rounded-lg w-full dark:bg-gray-600 text-red-300">
|
||||||
{t("contact_block_tip")}
|
{t("contact_block_tip")}
|
||||||
<StyledButton className="mini ml-4" onClick={unblockThisContact}>{t("unblock")}</StyledButton>
|
<StyledButton className="mini ml-4" onClick={unblockThisContact}>
|
||||||
</div>;
|
{t("unblock")}
|
||||||
|
</StyledButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -157,13 +163,22 @@ const Send: FC<IProps> = ({
|
|||||||
<TextInput sendMessage={handleSendMessage} placeholder={placeholder} />
|
<TextInput sendMessage={handleSendMessage} placeholder={placeholder} />
|
||||||
{/* PC input */}
|
{/* PC input */}
|
||||||
<div
|
<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" : ""
|
className={clsx(
|
||||||
} ${context}`, isMarkdownMode && markdownFullscreen && '-mt-9')}
|
`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} />}
|
{replying_mid && <Replying context={context} mid={replying_mid} id={id} />}
|
||||||
{mode == Modes.text && <UploadFileList context={context} 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} />
|
<EmojiPicker selectEmoji={insertEmoji} />
|
||||||
{mode == Modes.text && (
|
{mode == Modes.text && (
|
||||||
<MixedInput
|
<MixedInput
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import Tippy from "@tippyjs/react";
|
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 { 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 = {
|
type Props = {
|
||||||
readonly?: boolean;
|
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">
|
<NavLink to={"/"} className="relative flex items-center justify-between gap-2 px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8">
|
<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>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-sm text-gray-600 dark:text-gray-100" title={description}>
|
<h3 className="text-sm text-gray-600 dark:text-gray-100" title={description}>
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -42,13 +49,19 @@ export default function Server({ readonly = false }: Props) {
|
|||||||
<NavLink to={`/setting/overview?f=${pathname}`}>
|
<NavLink to={`/setting/overview?f=${pathname}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8">
|
<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>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-sm text-gray-600 font-bold dark:text-gray-100" title={description}>
|
<h3 className="text-sm text-gray-600 font-bold dark:text-gray-100" title={description}>
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -1,34 +1,44 @@
|
|||||||
import { ReactElement } from 'react';
|
import { ReactElement } from "react";
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { compareVersion } from '../utils';
|
|
||||||
import { useAppSelector } from '../app/store';
|
import { useAppSelector } from "../app/store";
|
||||||
|
import { compareVersion } from "../utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
empty?: boolean,
|
empty?: boolean;
|
||||||
version: string,
|
version: string;
|
||||||
children: ReactElement
|
children: ReactElement;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ServerVersionChecker = ({ empty = false, version, children }: Props) => {
|
const ServerVersionChecker = ({ empty = false, version, children }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentVersion = useAppSelector(store => store.server.version);
|
const currentVersion = useAppSelector((store) => store.server.version);
|
||||||
if (!currentVersion) return null;
|
if (!currentVersion) return null;
|
||||||
const res = compareVersion(currentVersion, version);
|
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'>
|
if (res < 0)
|
||||||
<span className='text-gray-400 text-sm'>
|
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"}>
|
<Trans i18nKey={"server_update.version_needed"}>
|
||||||
<strong className='font-bold'>{{ version }}</strong>
|
<strong className="font-bold">{{ version }}</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
<span className='text-gray-400 text-sm'>
|
<span className="text-gray-400 text-sm">
|
||||||
<Trans i18nKey={"server_update.current_version"}>
|
<Trans i18nKey={"server_update.current_version"}>
|
||||||
<strong className='font-bold'>{{ version: currentVersion }}</strong>
|
<strong className="font-bold">{{ version: currentVersion }}</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
<span className='text-gray-400 text-sm'>{t("server_update.update_tip")}</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">
|
<a
|
||||||
{t("server_update.howto")} 📖 </a>
|
className="text-blue-500 underline"
|
||||||
</div>;
|
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;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -1,9 +1,9 @@
|
|||||||
import clsx from 'clsx';
|
import React from "react";
|
||||||
import React from 'react';
|
import clsx from "clsx";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
strength?: number
|
strength?: number;
|
||||||
}
|
};
|
||||||
// 0:质量未知。
|
// 0:质量未知。
|
||||||
// 1:质量极好。
|
// 1:质量极好。
|
||||||
// 2:用户主观感觉和极好差不多,但码率可能略低于极好。
|
// 2:用户主观感觉和极好差不多,但码率可能略低于极好。
|
||||||
@@ -14,16 +14,17 @@ type Props = {
|
|||||||
|
|
||||||
const Signal = ({ strength = 0 }: Props) => {
|
const Signal = ({ strength = 0 }: Props) => {
|
||||||
const finalStrength = 6 - strength;
|
const finalStrength = 6 - strength;
|
||||||
const color = finalStrength <= 2 ? `bg-red-700` : (finalStrength == 3 ? `bg-yellow-700` : `bg-green-700`);
|
const color =
|
||||||
|
finalStrength <= 2 ? `bg-red-700` : finalStrength == 3 ? `bg-yellow-700` : `bg-green-700`;
|
||||||
let bgColor = ` bg-gray-500`;
|
let bgColor = ` bg-gray-500`;
|
||||||
return (
|
return (
|
||||||
<div className='w-6 h-6 flex-center'>
|
<div className="w-6 h-6 flex-center">
|
||||||
<div className="w-[18px] h-[15px] flex items-end gap-[2px]">
|
<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-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-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-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-4/5 w-[2px]", finalStrength <= 3 ? bgColor : color)}></span>
|
||||||
<span className={clsx('h-full w-[2px]', finalStrength <= 4 ? bgColor : color)}></span>
|
<span className={clsx("h-full w-[2px]", finalStrength <= 4 ? bgColor : color)}></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { FC, PropsWithChildren, ReactNode } from "react";
|
import { FC, PropsWithChildren, ReactNode } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
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 clsx from "clsx";
|
||||||
import GoBackNav from "./GoBackNav";
|
|
||||||
|
import IconBack from "@/assets/icons/arrow.left.svg";
|
||||||
import MobileNavs from "../routes/home/MobileNavs";
|
import MobileNavs from "../routes/home/MobileNavs";
|
||||||
|
import { Nav } from "../routes/settingChannel/navs";
|
||||||
|
import GoBackNav from "./GoBackNav";
|
||||||
|
|
||||||
// import ErrorCatcher from "./ErrorCatcher";
|
// import ErrorCatcher from "./ErrorCatcher";
|
||||||
export interface Danger {
|
export interface Danger {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -12,7 +14,7 @@ export interface Danger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pathPrefix?: string,
|
pathPrefix?: string;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
navs: Nav[];
|
navs: Nav[];
|
||||||
@@ -29,24 +31,59 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
|
|||||||
nav,
|
nav,
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-screen h-screen flex">
|
<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")}>
|
<div
|
||||||
<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">
|
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}
|
<IconBack className="dark:fill-gray-400" /> {title}
|
||||||
</h2>
|
</h2>
|
||||||
{navs.map(({ title, items }) => {
|
{navs.map(({ title, items }) => {
|
||||||
return (
|
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 }) => {
|
{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")}>
|
if (link)
|
||||||
<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 (
|
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")}>
|
<li
|
||||||
<NavLink to={`${pathPrefix}/${name}`} className="block md:px-3 py-1">{title}</NavLink>
|
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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -59,7 +96,11 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
|
|||||||
if (typeof d === "boolean" || !d) return null;
|
if (typeof d === "boolean" || !d) return null;
|
||||||
const { title, handler } = d;
|
const { title, handler } = d;
|
||||||
return (
|
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}
|
{title}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -67,9 +108,18 @@ const StyledSettingContainer: FC<PropsWithChildren<Props>> = ({
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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" />
|
<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> */}
|
{/* <ErrorCatcher> */}
|
||||||
{children}
|
{children}
|
||||||
{/* </ErrorCatcher> */}
|
{/* </ErrorCatcher> */}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import TextareaAutoSize from "react-textarea-autosize";
|
import TextareaAutoSize from "react-textarea-autosize";
|
||||||
import { useKey } from 'rooks';
|
import { useKey } from "rooks";
|
||||||
import { isMobile } from '../../utils';
|
|
||||||
|
|
||||||
|
import { isMobile } from "../../utils";
|
||||||
import Button from "../styled/Button";
|
import Button from "../styled/Button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
placeholder: string,
|
placeholder: string;
|
||||||
sendMessage: any
|
sendMessage: any;
|
||||||
}
|
};
|
||||||
const TextInput = ({ sendMessage, placeholder }: Props) => {
|
const TextInput = ({ sendMessage, placeholder }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -34,11 +34,11 @@ const TextInput = ({ sendMessage, placeholder }: Props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
when: !isMobile(),
|
when: !isMobile(),
|
||||||
target: inputRef,
|
target: inputRef
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return (
|
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
|
<TextareaAutoSize
|
||||||
// autoFocus
|
// autoFocus
|
||||||
onFocus={(e) =>
|
onFocus={(e) =>
|
||||||
@@ -56,7 +56,9 @@ const TextInput = ({ sendMessage, placeholder }: Props) => {
|
|||||||
value={currMsg}
|
value={currMsg}
|
||||||
placeholder={placeholder}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { isMobile } from "../utils";
|
|
||||||
|
|
||||||
|
import { isMobile } from "../utils";
|
||||||
|
|
||||||
const Triangle: FC<Pick<TippyProps, "placement">> = ({ placement }) => {
|
const Triangle: FC<Pick<TippyProps, "placement">> = ({ placement }) => {
|
||||||
if (placement == "left") return null;
|
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 == "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 == "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>;
|
return <i className={cls}></i>;
|
||||||
};
|
};
|
||||||
@@ -29,10 +29,12 @@ const Tooltip: FC<Props> = ({ tip = "", placement = "right", delay = null, child
|
|||||||
duration={delay ? defaultDuration : 0}
|
duration={delay ? defaultDuration : 0}
|
||||||
delay={delay ?? [150, 0]}
|
delay={delay ?? [150, 0]}
|
||||||
placement={placement}
|
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">
|
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} />
|
<Triangle placement={placement} />
|
||||||
{tip}
|
{tip}
|
||||||
</div>}
|
</div>
|
||||||
|
}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useAppSelector } from '../app/store';
|
|
||||||
import getUnreadCount from '../routes/chat/utils';
|
import { useAppSelector } from "../app/store";
|
||||||
|
import getUnreadCount from "../routes/chat/utils";
|
||||||
|
|
||||||
// type Props = {}
|
// type Props = {}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let title = "";
|
let title = "";
|
||||||
const UnreadTabTip = () => {
|
const UnreadTabTip = () => {
|
||||||
const { userData, dmMids, channelMids, messageData, readChannels, readUsers, loginUid } = useAppSelector(store => {
|
const { userData, dmMids, channelMids, messageData, readChannels, readUsers, loginUid } =
|
||||||
|
useAppSelector((store) => {
|
||||||
return {
|
return {
|
||||||
userData: store.users.byId,
|
userData: store.users.byId,
|
||||||
dmMids: store.userMessage.byId,
|
dmMids: store.userMessage.byId,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import Tippy from "@tippyjs/react";
|
import Tippy from "@tippyjs/react";
|
||||||
|
|
||||||
import useUserOperation from "@/hooks/useUserOperation";
|
import useUserOperation from "@/hooks/useUserOperation";
|
||||||
import ContextMenu, { Item } from "../ContextMenu";
|
import ContextMenu, { Item } from "../ContextMenu";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
@@ -75,7 +76,6 @@ const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, c
|
|||||||
title: t("remove"),
|
title: t("remove"),
|
||||||
handler: removeUser
|
handler: removeUser
|
||||||
}
|
}
|
||||||
|
|
||||||
].filter(Boolean) as Item[]
|
].filter(Boolean) as Item[]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { FC, memo } from "react";
|
import { FC, memo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import Tippy from "@tippyjs/react";
|
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 IconBot from "@/assets/icons/bot.svg";
|
||||||
|
import IconOwner from "@/assets/icons/owner.svg";
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../Avatar";
|
||||||
import Profile from "../Profile";
|
import Profile from "../Profile";
|
||||||
import ContextMenu from "./ContextMenu";
|
import ContextMenu from "./ContextMenu";
|
||||||
import useContextMenu from "@/hooks/useContextMenu";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uid: number;
|
uid: number;
|
||||||
@@ -36,18 +37,30 @@ const User: FC<Props> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
|
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
|
||||||
const { curr, loginUid, showStatus } = useAppSelector((store) => {
|
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 = () => {
|
const handleDoubleClick = () => {
|
||||||
navigate(`/chat/dm/${uid}`);
|
navigate(`/chat/dm/${uid}`);
|
||||||
};
|
};
|
||||||
if (!curr) return null;
|
if (!curr) return null;
|
||||||
const online = curr.online || curr.uid == loginUid;
|
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 containerClass = clsx(
|
||||||
const nameClass = clsx(`text-sm text-gray-500 max-w-[190px] truncate font-semibold dark:text-white`);
|
`relative flex items-center justify-start gap-2 rounded-lg select-none`,
|
||||||
const statusClass = clsx(`absolute -bottom-[2.5px] -right-[2.5px] border-content rounded-full border-[1px] border-white dark:border-gray-300`,
|
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",
|
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;
|
const statusElement = showStatus ? <div className={statusClass}></div> : null;
|
||||||
if (!popover)
|
if (!popover)
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +76,10 @@ const User: FC<Props> = ({
|
|||||||
onDoubleClick={dm ? handleDoubleClick : undefined}
|
onDoubleClick={dm ? handleDoubleClick : undefined}
|
||||||
onContextMenu={enableContextMenu ? handleContextMenuEvent : 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
|
<Avatar
|
||||||
className="w-full h-full rounded-full object-cover"
|
className="w-full h-full rounded-full object-cover"
|
||||||
width={avatarSize}
|
width={avatarSize}
|
||||||
@@ -72,7 +88,13 @@ const User: FC<Props> = ({
|
|||||||
name={curr.name}
|
name={curr.name}
|
||||||
alt="avatar"
|
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>
|
</div>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<span className={nameClass} title={curr?.name}>
|
<span className={nameClass} title={curr?.name}>
|
||||||
@@ -103,7 +125,10 @@ const User: FC<Props> = ({
|
|||||||
onDoubleClick={dm ? handleDoubleClick : undefined}
|
onDoubleClick={dm ? handleDoubleClick : undefined}
|
||||||
onContextMenu={enableContextMenu ? handleContextMenuEvent : 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
|
<Avatar
|
||||||
className="w-full h-full rounded-full object-cover"
|
className="w-full h-full rounded-full object-cover"
|
||||||
width={avatarSize}
|
width={avatarSize}
|
||||||
|
|||||||
@@ -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 { NavLink } from "react-router-dom";
|
||||||
import { useOutsideClick } from "rooks";
|
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 {
|
interface Props {
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
@@ -21,12 +21,19 @@ const UsersModal: FC<Props> = ({ closeModal }) => {
|
|||||||
updateInput(evt.target.value);
|
updateInput(evt.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal>
|
<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">
|
<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>
|
</div>
|
||||||
{users && (
|
{users && (
|
||||||
<ul className="flex flex-col overflow-y-scroll h-[260px] py-4">
|
<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 || {};
|
const { uid = 0 } = u || {};
|
||||||
return (
|
return (
|
||||||
<li key={uid} className="cursor-pointer px-2 md:hover:bg-gray-600/10">
|
<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} />
|
<User uid={uid} interactive={false} />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Ring } from "@uiball/loaders";
|
import { Ring } from "@uiball/loaders";
|
||||||
import Button from "./styled/Button";
|
import dayjs from "dayjs";
|
||||||
import { unregister } from '../serviceWorkerRegistration';
|
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
|
import { unregister } from "../serviceWorkerRegistration";
|
||||||
|
import Button from "./styled/Button";
|
||||||
|
|
||||||
type Props = {};
|
type Props = {};
|
||||||
const Version: FC<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 [syncing, setSyncing] = useState(false);
|
||||||
const { t } = useTranslation("setting", { keyPrefix: "version" });
|
const { t } = useTranslation("setting", { keyPrefix: "version" });
|
||||||
const ts = (process.env.REACT_APP_BUILD_TIME ?? 0) as number;
|
const ts = (process.env.REACT_APP_BUILD_TIME ?? 0) as number;
|
||||||
@@ -20,11 +22,21 @@ const Version: FC<Props> = () => {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-2 dark:text-white">
|
<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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
// import React from 'react';
|
// import React from 'react';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
import clsx from 'clsx';
|
import { useDispatch } from "react-redux";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
// import { useTranslation } from 'react-i18next';
|
// import { useTranslation } from 'react-i18next';
|
||||||
import { Waveform } from '@uiball/loaders';
|
import { Waveform } from "@uiball/loaders";
|
||||||
import { useDispatch } from 'react-redux';
|
import clsx from "clsx";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { useAppSelector } from '../../app/store';
|
|
||||||
|
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 Tooltip from "../Tooltip";
|
||||||
import IconCallOff from '@/assets/icons/call.off.svg';
|
import useVoice from "./useVoice";
|
||||||
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';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
from: number
|
from: number;
|
||||||
to?: number
|
to?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const DMCalling = ({ from, to = 0 }: Props) => {
|
const DMCalling = ({ from, to = 0 }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,7 +27,7 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" });
|
const { leave, joinVoice, joining } = useVoice({ id: to, context: "dm" });
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const { calling, voicingMembers, fromUser, toUser, loginUser } = useAppSelector(store => {
|
const { calling, voicingMembers, fromUser, toUser, loginUser } = useAppSelector((store) => {
|
||||||
return {
|
return {
|
||||||
calling: store.voice.calling,
|
calling: store.voice.calling,
|
||||||
// voicingInfo: store.voice.voicing ?? {},
|
// voicingInfo: store.voice.voicing ?? {},
|
||||||
@@ -40,12 +41,12 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = voicingMembers.ids;
|
const ids = voicingMembers.ids;
|
||||||
ids.forEach(id => {
|
ids.forEach((id) => {
|
||||||
playAgoraVideo(id);
|
playAgoraVideo(id);
|
||||||
});
|
});
|
||||||
}, [voicingMembers.ids]);
|
}, [voicingMembers.ids]);
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
console.log('cancel');
|
console.log("cancel");
|
||||||
if (sendByMe || voicingMembers.ids.length == 2) {
|
if (sendByMe || voicingMembers.ids.length == 2) {
|
||||||
leave();
|
leave();
|
||||||
}
|
}
|
||||||
@@ -63,46 +64,63 @@ const DMCalling = ({ from, to = 0 }: Props) => {
|
|||||||
if (!fromUser || !toUser || connected || atChatPath || !calling) return null;
|
if (!fromUser || !toUser || connected || atChatPath || !calling) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div
|
||||||
<motion.aside drag dragConstraints={containerRef} dragMomentum={false} whileDrag={{ scale: 1.1 }} className={clsx(`pointer-events-auto
|
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
|
rounded bg-gray-800 relative
|
||||||
shadow-lg shadow-slate-200 dark:shadow-slate-800
|
shadow-lg shadow-slate-200 dark:shadow-slate-800
|
||||||
cursor-move overflow-hidden
|
cursor-move overflow-hidden
|
||||||
w-64 h-80
|
w-64 h-80
|
||||||
`)}>
|
`)}
|
||||||
|
>
|
||||||
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
|
<div className="absolute right-0-0 top-0 w-40 h-32" id={`CAMERA_${to}`}>
|
||||||
{/* to camera video */}
|
{/* to camera video */}
|
||||||
</div>
|
</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={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="flex flex-col gap-2 items-center">
|
||||||
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
|
<div className="rounded-full overflow-hidden w-20 h-20 shrink-0">
|
||||||
<Avatar name={name} src={avatar} width={80} height={80} className='h-20' />
|
<Avatar name={name} src={avatar} width={80} height={80} className="h-20" />
|
||||||
</div>
|
</div>
|
||||||
<span className='text-white mb-2'>{name}</span>
|
<span className="text-white mb-2">{name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-1 items-center my-4'>
|
<div className="flex flex-col gap-1 items-center my-4">
|
||||||
<Waveform
|
<Waveform size={18} lineWeight={3} speed={1} color="#aaa" />
|
||||||
size={18}
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
lineWeight={3}
|
{sendByMe ? `Calling` : `Incoming call`}
|
||||||
speed={1}
|
</span>
|
||||||
color='#aaa'
|
|
||||||
/>
|
|
||||||
<span className='text-xs text-gray-600 dark:text-gray-400'>{sendByMe ? `Calling` : `Incoming call`}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||||
<Tooltip tip={"Disconnect"} placement="top">
|
<Tooltip tip={"Disconnect"} placement="top">
|
||||||
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded-lg'>
|
<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" />
|
<IconCallOff className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!sendByMe &&
|
{!sendByMe && (
|
||||||
<Tooltip tip={"Answer"} placement="top">
|
<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'>
|
<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" />
|
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|||||||
@@ -1,45 +1,49 @@
|
|||||||
import { MouseEvent, SetStateAction, useState, Dispatch } from 'react';
|
import { Dispatch, MouseEvent, SetStateAction, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from 'clsx';
|
import { useDispatch } from "react-redux";
|
||||||
import { useDispatch } from 'react-redux';
|
import Tippy from "@tippyjs/react";
|
||||||
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 { updateChannelVisibleAside, updateDMVisibleAside } from '@/app/slices/footprint';
|
||||||
import { DeviceInfo, MediaDeviceKind, updateSelectDeviceId } from '@/app/slices/voice';
|
import { DeviceInfo, MediaDeviceKind, updateSelectDeviceId } from "@/app/slices/voice";
|
||||||
import { useAppSelector } from '@/app/store';
|
import { useAppSelector } from "@/app/store";
|
||||||
import { ICameraVideoTrack, IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng';
|
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";
|
type DeviceType = "audio" | "video";
|
||||||
// https://docportal.shengwang.cn/cn/video-call-4.x/test_switch_device_web_ng?platform=Web
|
// https://docportal.shengwang.cn/cn/video-call-4.x/test_switch_device_web_ng?platform=Web
|
||||||
const DeviceList = ({ type, visible, setVisible, devices }: {
|
const DeviceList = ({
|
||||||
type: DeviceType,
|
type,
|
||||||
visible: VisibleType,
|
visible,
|
||||||
setVisible: Dispatch<SetStateAction<VisibleType>>,
|
setVisible,
|
||||||
|
devices
|
||||||
|
}: {
|
||||||
|
type: DeviceType;
|
||||||
|
visible: VisibleType;
|
||||||
|
setVisible: Dispatch<SetStateAction<VisibleType>>;
|
||||||
devices: {
|
devices: {
|
||||||
title: string,
|
title: string;
|
||||||
list: DeviceInfo[],
|
list: DeviceInfo[];
|
||||||
selected: string
|
selected: string;
|
||||||
}[],
|
}[];
|
||||||
}) => {
|
}) => {
|
||||||
const loginUid = useAppSelector(store => store.authData.user?.uid ?? 0);
|
const loginUid = useAppSelector((store) => store.authData.user?.uid ?? 0);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
// const { t } = useTranslation("chat");
|
// const { t } = useTranslation("chat");
|
||||||
const toggleVisible = (evt: MouseEvent<HTMLDivElement>) => {
|
const toggleVisible = (evt: MouseEvent<HTMLDivElement>) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
setVisible((prev: VisibleType) => prev == type ? "" : type);
|
setVisible((prev: VisibleType) => (prev == type ? "" : type));
|
||||||
};
|
};
|
||||||
const handleSelect = (evt: MouseEvent<HTMLLIElement>) => {
|
const handleSelect = (evt: MouseEvent<HTMLLIElement>) => {
|
||||||
// evt.stopPropagation();
|
// evt.stopPropagation();
|
||||||
@@ -47,28 +51,34 @@ const DeviceList = ({ type, visible, setVisible, devices }: {
|
|||||||
if (selected == deviceId || !kind) return;
|
if (selected == deviceId || !kind) return;
|
||||||
switch (kind as MediaDeviceKind) {
|
switch (kind as MediaDeviceKind) {
|
||||||
case "audiooutput":
|
case "audiooutput":
|
||||||
case "audioinput": {
|
case "audioinput":
|
||||||
|
{
|
||||||
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
|
const localAudioTrack = window.VOICE_TRACK_MAP[loginUid] as IMicrophoneAudioTrack;
|
||||||
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
||||||
if (localAudioTrack) {
|
if (localAudioTrack) {
|
||||||
localAudioTrack.setDevice(deviceId).then(() => {
|
localAudioTrack
|
||||||
|
.setDevice(deviceId)
|
||||||
|
.then(() => {
|
||||||
console.log("audioinput setDevice", deviceId);
|
console.log("audioinput setDevice", deviceId);
|
||||||
}
|
})
|
||||||
).catch((err) => {
|
.catch((err) => {
|
||||||
console.log("audioinput setDevice error", err);
|
console.log("audioinput setDevice error", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "videoinput": {
|
case "videoinput":
|
||||||
|
{
|
||||||
const localCameraTrack = window.VIDEO_TRACK_MAP[loginUid] as ICameraVideoTrack;
|
const localCameraTrack = window.VIDEO_TRACK_MAP[loginUid] as ICameraVideoTrack;
|
||||||
if (localCameraTrack) {
|
if (localCameraTrack) {
|
||||||
localCameraTrack.setDevice(deviceId).then(() => {
|
localCameraTrack
|
||||||
|
.setDevice(deviceId)
|
||||||
|
.then(() => {
|
||||||
console.log("videoinput setDevice", deviceId);
|
console.log("videoinput setDevice", deviceId);
|
||||||
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
dispatch(updateSelectDeviceId({ kind: kind as MediaDeviceKind, value: deviceId }));
|
||||||
}
|
})
|
||||||
).catch((err) => {
|
.catch((err) => {
|
||||||
console.log("videoinput setDevice error", err);
|
console.log("videoinput setDevice error", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,74 +89,117 @@ const DeviceList = ({ type, visible, setVisible, devices }: {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
const handleBlockClick = (evt: MouseEvent<HTMLDivElement>) => {
|
const handleBlockClick = (evt: MouseEvent<HTMLDivElement>) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
return <Tippy
|
return (
|
||||||
|
<Tippy
|
||||||
onClickOutside={() => setVisible("")}
|
onClickOutside={() => setVisible("")}
|
||||||
interactive
|
interactive
|
||||||
popperOptions={{ strategy: "fixed" }}
|
popperOptions={{ strategy: "fixed" }}
|
||||||
visible={visible == type}
|
visible={visible == type}
|
||||||
placement="top-start"
|
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">
|
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 }) => {
|
{devices.map(({ title, list, selected }) => {
|
||||||
console.log("device selected", title, selected);
|
console.log("device selected", title, selected);
|
||||||
if (list.length == 0) return null;
|
if (list.length == 0) return null;
|
||||||
return <div key={title} className="w-full flex flex-col items-start gap-2 pt-3">
|
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>
|
<p className="text-gray-500 text-xs">{title}</p>
|
||||||
<ul className="w-full flex flex-col gap-4">
|
<ul className="w-full flex flex-col gap-4">
|
||||||
{list.map(({ deviceId, kind, label }) => {
|
{list.map(({ deviceId, kind, label }) => {
|
||||||
return (
|
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"
|
<li
|
||||||
onClick={handleSelect}>
|
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}
|
{label}
|
||||||
<i className='w-4 h-3'>
|
<i className="w-4 h-3">
|
||||||
{selected == deviceId &&
|
{selected == deviceId && <IconCheck className={clsx("shrink-0")} />}
|
||||||
<IconCheck className={clsx("shrink-0")} />
|
|
||||||
}
|
|
||||||
</i>
|
</i>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>;
|
|
||||||
})}
|
|
||||||
|
|
||||||
</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")} />
|
|
||||||
</div>
|
</div>
|
||||||
</Tippy>;
|
);
|
||||||
|
})}
|
||||||
|
</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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tippy>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
type VisibleType = "audio" | "video" | "";
|
type VisibleType = "audio" | "video" | "";
|
||||||
type Props = {
|
type Props = {
|
||||||
mode?: "channel" | "dm" | "fullscreen",
|
mode?: "channel" | "dm" | "fullscreen";
|
||||||
id: number,
|
id: number;
|
||||||
context: ChatContext
|
context: ChatContext;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Operations = ({ id, context, mode = "channel" }: Props) => {
|
const Operations = ({ id, context, mode = "channel" }: Props) => {
|
||||||
const [panelVisible, setPanelVisible] = useState<VisibleType>("");
|
const [panelVisible, setPanelVisible] = useState<VisibleType>("");
|
||||||
const { enterFullscreen, voicingInfo,
|
const {
|
||||||
leave, setMute, closeCamera, openCamera, startShareScreen, stopShareScreen,
|
enterFullscreen,
|
||||||
audioInputDevices, audioOutputDevices, videoInputDevices, videoInputDeviceId, audioInputDeviceId, audioOutputDeviceId
|
voicingInfo,
|
||||||
|
leave,
|
||||||
|
setMute,
|
||||||
|
closeCamera,
|
||||||
|
openCamera,
|
||||||
|
startShareScreen,
|
||||||
|
stopShareScreen,
|
||||||
|
audioInputDevices,
|
||||||
|
audioOutputDevices,
|
||||||
|
videoInputDevices,
|
||||||
|
videoInputDeviceId,
|
||||||
|
audioInputDeviceId,
|
||||||
|
audioOutputDeviceId
|
||||||
} = useVoice({ id, context });
|
} = useVoice({ id, context });
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
if (!voicingInfo) return null;
|
if (!voicingInfo) return null;
|
||||||
const { muted, video, shareScreen } = voicingInfo;
|
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 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");
|
const baseIconClass = clsx("w-[25px] h-6 m-auto fill-gray-700 dark:fill-gray-300");
|
||||||
return <>
|
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}>
|
<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} />}
|
{muted ? <IconMicOff className={baseIconClass} /> : <IconMic className={baseIconClass} />}
|
||||||
<DeviceList
|
<DeviceList
|
||||||
visible={panelVisible}
|
visible={panelVisible}
|
||||||
setVisible={setPanelVisible}
|
setVisible={setPanelVisible}
|
||||||
type='audio'
|
type="audio"
|
||||||
devices={[
|
devices={[
|
||||||
{
|
{
|
||||||
title: "Input Device",
|
title: "Input Device",
|
||||||
@@ -162,13 +215,25 @@ const Operations = ({ id, context, mode = "channel" }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip disabled={panelVisible == "video"} tip={video ? t("camera_off") : t("camera_on")} placement="top">
|
<Tooltip
|
||||||
<button disabled={videoInputDevices.length == 0} onClick={video ? closeCamera : openCamera} className={baseButtonClass}>
|
disabled={panelVisible == "video"}
|
||||||
{video ? <IconCamera className={baseIconClass} /> : <IconCameraOff className={baseIconClass} />}
|
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
|
<DeviceList
|
||||||
visible={panelVisible}
|
visible={panelVisible}
|
||||||
setVisible={setPanelVisible}
|
setVisible={setPanelVisible}
|
||||||
type='video'
|
type="video"
|
||||||
devices={[
|
devices={[
|
||||||
{
|
{
|
||||||
title: "Camera Device",
|
title: "Camera Device",
|
||||||
@@ -180,21 +245,43 @@ const Operations = ({ id, context, mode = "channel" }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip tip={"Share Screen"} placement="top">
|
<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")}>
|
<button
|
||||||
<IconScreen className={clsx("w-6 h-6 dark:fill-gray-300", shareScreen ? "fill-gray-200" : "fill-gray-800")} />
|
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>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{mode !== "fullscreen" && <Tooltip tip={"Fullscreen"} placement="top">
|
{mode !== "fullscreen" && (
|
||||||
|
<Tooltip tip={"Fullscreen"} placement="top">
|
||||||
<button onClick={enterFullscreen.bind(null, undefined)} className={baseButtonClass}>
|
<button onClick={enterFullscreen.bind(null, undefined)} className={baseButtonClass}>
|
||||||
<IconFullscreen className={baseIconClass} />
|
<IconFullscreen className={baseIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>}
|
</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")}>
|
{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" />
|
<IconCallOff className="m-auto w-[25px] h-6" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>}
|
</Tooltip>
|
||||||
</>;
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Operations;
|
export default Operations;
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import AgoraRTC from 'agora-rtc-sdk-ng';
|
import { memo, useEffect } from "react";
|
||||||
import { memo, useEffect } from 'react';
|
import { useDispatch } from "react-redux";
|
||||||
import { useDispatch } from 'react-redux';
|
import AgoraRTC from "agora-rtc-sdk-ng";
|
||||||
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';
|
|
||||||
|
|
||||||
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.VOICE_TRACK_MAP = window.VOICE_TRACK_MAP ?? {};
|
||||||
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
|
window.VIDEO_TRACK_MAP = window.VIDEO_TRACK_MAP ?? {};
|
||||||
// let tmpUids: number[] = [];
|
// let tmpUids: number[] = [];
|
||||||
const Voice = () => {
|
const Voice = () => {
|
||||||
const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector(store => {
|
const { from, to, voiceList, loginUid, voicingInfo } = useAppSelector((store) => {
|
||||||
return {
|
return {
|
||||||
voicingInfo: store.voice.voicing,
|
voicingInfo: store.voice.voicing,
|
||||||
voiceList: store.voice.list,
|
voiceList: store.voice.list,
|
||||||
@@ -24,10 +37,13 @@ const Voice = () => {
|
|||||||
});
|
});
|
||||||
const { data: enabled } = useGetAgoraStatusQuery();
|
const { data: enabled } = useGetAgoraStatusQuery();
|
||||||
const [getUsersByChannel] = useLazyGetAgoraUsersByChannelQuery();
|
const [getUsersByChannel] = useLazyGetAgoraUsersByChannelQuery();
|
||||||
useGetAgoraChannelsQuery({ page_no: 0, page_size: 100 }, {
|
useGetAgoraChannelsQuery(
|
||||||
|
{ page_no: 0, page_size: 100 },
|
||||||
|
{
|
||||||
skip: !enabled || !navigator.onLine,
|
skip: !enabled || !navigator.onLine,
|
||||||
pollingInterval: 5000
|
pollingInterval: 5000
|
||||||
});
|
}
|
||||||
|
);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeAgoraClient = async () => {
|
const initializeAgoraClient = async () => {
|
||||||
@@ -67,7 +83,9 @@ const Voice = () => {
|
|||||||
}
|
}
|
||||||
if (!user.hasVideo) {
|
if (!user.hasVideo) {
|
||||||
// 远端用户取消了视频
|
// 远端用户取消了视频
|
||||||
dispatch(updateVoicingMember({ uid: +user.uid, info: { video: false, shareScreen: false } }));
|
dispatch(
|
||||||
|
updateVoicingMember({ uid: +user.uid, info: { video: false, shareScreen: false } })
|
||||||
|
);
|
||||||
// 注销本地视频变量
|
// 注销本地视频变量
|
||||||
window.VIDEO_TRACK_MAP[+user.uid] = null;
|
window.VIDEO_TRACK_MAP[+user.uid] = null;
|
||||||
// 关闭视频后,需要把视频的高度设置回去
|
// 关闭视频后,需要把视频的高度设置回去
|
||||||
@@ -80,7 +98,8 @@ const Voice = () => {
|
|||||||
console.log(user, "has left the channel", reason);
|
console.log(user, "has left the channel", reason);
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "Quit":
|
case "Quit":
|
||||||
case "ServerTimeOut": {
|
case "ServerTimeOut":
|
||||||
|
{
|
||||||
dispatch(removeVoiceMember(+user.uid as number));
|
dispatch(removeVoiceMember(+user.uid as number));
|
||||||
// clear tracks
|
// clear tracks
|
||||||
window.VOICE_TRACK_MAP[+user.uid] = null;
|
window.VOICE_TRACK_MAP[+user.uid] = null;
|
||||||
@@ -146,13 +165,19 @@ const Voice = () => {
|
|||||||
AgoraRTC.onMicrophoneChanged = (info) => {
|
AgoraRTC.onMicrophoneChanged = (info) => {
|
||||||
console.log("onMicrophoneChanged", info);
|
console.log("onMicrophoneChanged", info);
|
||||||
};
|
};
|
||||||
AgoraRTC.getDevices().then((devices) => {
|
AgoraRTC.getDevices()
|
||||||
|
.then((devices) => {
|
||||||
console.log("devices", devices);
|
console.log("devices", devices);
|
||||||
dispatch(updateDevices(devices.map((d) => {
|
dispatch(
|
||||||
|
updateDevices(
|
||||||
|
devices.map((d) => {
|
||||||
const { deviceId, groupId, label, kind } = d;
|
const { deviceId, groupId, label, kind } = d;
|
||||||
return { deviceId, groupId, label, kind };
|
return { deviceId, groupId, label, kind };
|
||||||
})));
|
})
|
||||||
}).catch((err) => {
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.log("err", err);
|
console.log("err", err);
|
||||||
});
|
});
|
||||||
window.VOICE_CLIENT = agoraEngine;
|
window.VOICE_CLIENT = agoraEngine;
|
||||||
@@ -171,19 +196,18 @@ const Voice = () => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
|
window.removeEventListener("beforeunload", handlePageUnload, { capture: true });
|
||||||
|
|
||||||
};
|
};
|
||||||
}, [voicingInfo]);
|
}, [voicingInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 有人呼叫我
|
// 有人呼叫我
|
||||||
const callMeList = voiceList.filter(item => item.context == "dm" && item.id == loginUid);
|
const callMeList = voiceList.filter((item) => item.context == "dm" && item.id == loginUid);
|
||||||
if (callMeList.length) {
|
if (callMeList.length) {
|
||||||
const [firstCall] = callMeList;
|
const [firstCall] = callMeList;
|
||||||
const { memberCount, channelName, id } = firstCall;
|
const { memberCount, channelName, id } = firstCall;
|
||||||
if (memberCount == 1) {
|
if (memberCount == 1) {
|
||||||
// 呼叫的人在频道里
|
// 呼叫的人在频道里
|
||||||
getUsersByChannel(channelName).then(resp => {
|
getUsersByChannel(channelName).then((resp) => {
|
||||||
const [uid] = resp.data ?? [];
|
const [uid] = resp.data ?? [];
|
||||||
if (uid && uid != loginUid) {
|
if (uid && uid != loginUid) {
|
||||||
dispatch(updateCallInfo({ from: uid, to: id }));
|
dispatch(updateCallInfo({ from: uid, to: id }));
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import dayjs from 'dayjs';
|
import clsx from "clsx";
|
||||||
import clsx from 'clsx';
|
import dayjs from "dayjs";
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import IconPause from '@/assets/icons/pause.svg';
|
|
||||||
import IconRefresh from '@/assets/icons/refresh.svg';
|
import IconPause from "@/assets/icons/pause.svg";
|
||||||
import IconPlay from '@/assets/icons/play.circle.svg';
|
import IconPlay from "@/assets/icons/play.circle.svg";
|
||||||
import BASE_URL from '../app/config';
|
import IconRefresh from "@/assets/icons/refresh.svg";
|
||||||
|
import BASE_URL from "../app/config";
|
||||||
|
|
||||||
export type VoiceMessageProps = {
|
export type VoiceMessageProps = {
|
||||||
type: string,
|
type: string;
|
||||||
url: string,
|
url: string;
|
||||||
secure_url: string,
|
secure_url: string;
|
||||||
}
|
};
|
||||||
// 全局存储Voice信息
|
// 全局存储Voice信息
|
||||||
const VoiceMap: { [key: string]: WaveSurfer | null } = {};
|
const VoiceMap: { [key: string]: WaveSurfer | null } = {};
|
||||||
const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
||||||
@@ -25,15 +26,13 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
// )}`));
|
// )}`));
|
||||||
const initWave = (file_path: string) => {
|
const initWave = (file_path: string) => {
|
||||||
let wave: WaveSurfer | null = null;
|
let wave: WaveSurfer | null = null;
|
||||||
const audioSrc = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(
|
const audioSrc = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(file_path)}`;
|
||||||
file_path
|
|
||||||
)}`;
|
|
||||||
wave = WaveSurfer.create({
|
wave = WaveSurfer.create({
|
||||||
container: containerRef.current ?? "",
|
container: containerRef.current ?? "",
|
||||||
// maxCanvasWidth: 200,
|
// maxCanvasWidth: 200,
|
||||||
height: 32,
|
height: 32,
|
||||||
waveColor: '#0BA5EC',
|
waveColor: "#0BA5EC",
|
||||||
cursorColor: '#0BA5AA',
|
cursorColor: "#0BA5AA",
|
||||||
progressColor: "#0BA5AA",
|
progressColor: "#0BA5AA",
|
||||||
hideScrollbar: true,
|
hideScrollbar: true,
|
||||||
// mediaControls: true,
|
// mediaControls: true,
|
||||||
@@ -42,7 +41,7 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
});
|
});
|
||||||
wave.load(audioSrc);
|
wave.load(audioSrc);
|
||||||
|
|
||||||
wave.on('error', function (err) {
|
wave.on("error", function (err) {
|
||||||
console.log("voice message error", err);
|
console.log("voice message error", err);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
const current = VoiceMap[file_path];
|
const current = VoiceMap[file_path];
|
||||||
@@ -50,19 +49,19 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
current.destroy();
|
current.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
wave.on('play', function () {
|
wave.on("play", function () {
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
});
|
});
|
||||||
wave.on('pause', function () {
|
wave.on("pause", function () {
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
});
|
});
|
||||||
wave.on('ready', function () {
|
wave.on("ready", function () {
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
const current = VoiceMap[file_path];
|
const current = VoiceMap[file_path];
|
||||||
if (current) {
|
if (current) {
|
||||||
const dur = current.getDuration();
|
const dur = current.getDuration();
|
||||||
const num = Math.ceil(dur);
|
const num = Math.ceil(dur);
|
||||||
const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss');
|
const durDisplay = dayjs.duration(num, "seconds").format("mm:ss");
|
||||||
setDuration(durDisplay);
|
setDuration(durDisplay);
|
||||||
}
|
}
|
||||||
// tricky
|
// tricky
|
||||||
@@ -99,8 +98,7 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
current.playPause();
|
current.playPause();
|
||||||
}
|
}
|
||||||
@@ -110,16 +108,35 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
|
|||||||
};
|
};
|
||||||
const notReady = status !== "ready";
|
const notReady = status !== "ready";
|
||||||
return (
|
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")}>
|
<div
|
||||||
<button className='disabled:opacity-60' onClick={handleClick} disabled={notReady}>
|
className={clsx(
|
||||||
{playing ? <IconPause className="stroke-primary-500" /> : <IconPlay className="stroke-primary-500" />}
|
"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>
|
</button>
|
||||||
<div ref={containerRef} className={clsx('flex-1 h-8 min-w-[100px]')} >
|
<div ref={containerRef} className={clsx("flex-1 h-8 min-w-[100px]")}>
|
||||||
{status == "loading" && <span className='text-xs'>Loading voice message...</span>}
|
{status == "loading" && <span className="text-xs">Loading voice message...</span>}
|
||||||
{status == "error" && <span className='text-xs text-red-800'>Load voice message error</span>}
|
{status == "error" && (
|
||||||
|
<span className="text-xs text-red-800">Load voice message error</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status !== "error" && <time className='text-primary-500 text-xs whitespace-nowrap text-left'>{duration}</time>}
|
{status !== "error" && (
|
||||||
{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} />}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user