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