feat: lots of updates

This commit is contained in:
zerosoul
2022-01-27 22:06:44 +08:00
commit e18c7323a6
72 changed files with 12432 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
export default function Avatar({ url, id = 0, ...rest }) {
const [src, setSrc] = useState(url);
const [loaded, setLoaded] = useState(false);
const handleLoaded = () => {
setLoaded(true);
};
const handleError = () => {
if (src.indexOf("avatars.dicebear.com") > -1) return;
setSrc(`https://avatars.dicebear.com/api/adventurer-neutral/${id}.svg`);
};
useEffect(() => {
setLoaded(false);
setSrc(url);
}, [url]);
useEffect(() => {
const inter = setTimeout(() => {
if (!loaded) {
handleError();
}
}, 500);
return () => {
clearTimeout(inter);
};
}, [loaded]);
return (
<img onLoad={handleLoaded} src={src} onError={handleError} {...rest} />
);
}
+49
View File
@@ -0,0 +1,49 @@
// import React from 'react';
const HashIcon = ({ size = 20, color = "#616161", ...rest }) => {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
{...rest}
>
<path
d="M6.48667 11.6667L6.83667 8.33333H3.325V6.66667H7L7.43333 2.5H9.10833L8.66667 6.66667H11.9833L12.4167 2.5H14.0917L13.65 6.66667H16.625V8.33333H13.4667L13.1167 11.6667H16.6167V13.3333H12.9333L12.4917 17.5H10.8083L11.2417 13.3333H7.91667L7.475 17.5H5.8L6.23333 13.3333H3.25V11.6667H6.4H6.48667ZM8.1625 11.6667H11.4875L11.8375 8.33333H8.5125L8.1625 11.6667Z"
fill={color}
/>
</svg>
);
};
const PrivateHashIcon = ({ size = 20, color = "#616161", ...rest }) => {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
{...rest}
>
<path
d="M6.83667 8.33333L6.48667 11.6667H6.4H3.25V13.3333H6.23333L5.8 17.5H7.475L7.91667 13.3333H11.2417L10.8083 17.5H12.4917L12.9333 13.3333H16.6167V11.6667H13.1167L13.2917 10H11.6625L11.4875 11.6667H8.1625L8.5125 8.33333H10.8334V6.66667H8.66667L9.10833 2.5H7.43333L7 6.66667H3.325V8.33333H6.83667Z"
fill={color}
/>
<path
d="M16.6875 4.16663V3.33329C16.6875 2.39996 15.875 1.66663 15 1.66663C14.125 1.66663 13.3333 2.39996 13.3333 3.33329V4.16663C12.8731 4.16663 12.5 4.53973 12.5 4.99996V7.49996C12.5 7.96019 12.8731 8.33329 13.3333 8.33329H15H16.6667C17.1269 8.33329 17.5 7.96019 17.5 7.49996V4.97913C17.5 4.53039 17.1362 4.16663 16.6875 4.16663ZM15.8333 4.16663H14.1667V3.33329C14.1667 2.8571 14.5556 2.49996 15 2.49996C15.4444 2.49996 15.8333 2.8571 15.8333 3.33329V4.16663Z"
fill={color}
/>
</svg>
);
};
export default function ChannelIcon({
personal = false,
size = 20,
color = "#616161",
...rest
}) {
return personal ? (
<PrivateHashIcon size={size} color={color} {...rest} />
) : (
<HashIcon size={size} color={color} {...rest} />
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import Modal from "../Modal";
import ChannelIcon from "../ChannelIcon";
import Contact from "../Contact";
import StyledWrapper from "./styled";
import useFilteredUsers from "../../hook/useFilteredUsers";
import { useGetChannelsQuery } from "../../../app/services/channel";
import { useCreateChannelMutation } from "../../../app/services/channel";
export default function ChannelModal({ personal = false, closeModal }) {
const navigateTo = useNavigate();
const [data, setData] = useState({
name: "",
dsecription: "",
members: [],
is_public: !personal,
});
const { contacts, input, updateInput } = useFilteredUsers();
const { refetch: refetchChannels } = useGetChannelsQuery();
const [
createChannel,
{ isSuccess, isError, isLoading, data: newChannel },
] = useCreateChannelMutation();
const currentUser = useSelector((state) => {
return state.authData.user;
});
const handleToggle = () => {
const { is_public } = data;
setData((prev) => {
return { ...prev, is_public: !is_public };
});
};
const handleCreate = () => {
if (!data.name) {
toast("please input channel name");
return;
}
createChannel(data);
};
useEffect(() => {
if (isError) {
toast.error("create new channel failed");
}
}, [isError]);
useEffect(() => {
if (isSuccess) {
toast.success("create new channel success");
closeModal();
refetchChannels();
navigateTo(`/chat/channel/${newChannel.gid}`);
}
}, [isSuccess, newChannel]);
const handleNameInput = (evt) => {
setData((prev) => {
return { ...prev, name: evt.target.value };
});
};
const handleInputChange = (evt) => {
updateInput(evt.target.value);
};
const toggleCheckMember = ({ currentTarget }) => {
const { members } = data;
const { uid } = currentTarget.dataset;
let tmp = members.includes(+uid)
? members.filter((m) => m != uid)
: [...members, +uid];
console.log(uid, currentTarget);
setData((prev) => {
return { ...prev, members: tmp };
});
console.log({ data });
};
console.log("contacts", contacts);
if (!currentUser) return null;
const { name, members, is_public } = data;
return (
<Modal>
<StyledWrapper>
{!is_public && (
<div className="left">
<div className="search">
<input
value={input}
onChange={handleInputChange}
placeholder="Type username to search"
/>
</div>
{contacts && (
<ul className="users">
{contacts.map((u) => {
const { uid } = u;
const checked = members.includes(uid);
console.log({ checked });
return (
<li
key={uid}
data-uid={uid}
className="user"
onClick={toggleCheckMember}
>
<input
readOnly
checked={checked}
type="checkbox"
name="cb"
id="cb"
/>
<Contact uid={uid} interactive={false} />
</li>
);
})}
</ul>
)}
</div>
)}
<div className={`right ${!is_public ? "private" : ""}`}>
<h3 className="title">Create New Channel</h3>
<p className="desc normal">
{!is_public
? "This is a private channel, only select members will bee able to join."
: "This is a public channel, everyone in this team can see it."}
</p>
<div className="name">
<span className="label normal">Channel Name</span>
<div className="input">
<input
onChange={handleNameInput}
value={name}
placeholder="new channel"
/>
<ChannelIcon personal={!is_public} className="icon" />
</div>
</div>
{/* admin or */}
{
<div className="private">
<span className="txt normal">Private Channel</span>
<input
disabled={!currentUser?.is_admin}
onChange={handleToggle}
checked={!is_public}
type="checkbox"
name="private"
id="private"
/>
</div>
}
<div className="btns">
<button onClick={closeModal} className="btn normal cancel">
Cancel
</button>
<button
disabled={isLoading}
onClick={handleCreate}
className="btn normal create"
>
Create
</button>
</div>
</div>
</StyledWrapper>
</Modal>
);
}
+144
View File
@@ -0,0 +1,144 @@
import styled from "styled-components";
const StyledWrapper = styled.div`
display: flex;
/* max-width: 604px; */
max-height: 402px;
background: #fff;
box-shadow: 0px 25px 50px rgba(31, 41, 55, 0.25);
border-radius: 8px;
transition: all 0.5s ease;
.left {
width: 260px;
height: 100%;
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
overflow-y: scroll;
.search {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
padding: 8px;
width: -webkit-fill-available;
input {
outline: none;
width: -webkit-fill-available;
padding: 8px;
font-size: 14px;
line-height: 20px;
border: none;
}
}
.users {
display: flex;
flex-direction: column;
height: 260px;
padding-bottom: 20px;
overflow-y: scroll;
.user {
cursor: pointer;
display: flex;
align-items: center;
padding: 0 16px;
width: -webkit-fill-available;
> div {
width: 100%;
}
}
}
}
.right {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
box-sizing: border-box;
&.private {
width: 344px;
.desc {
text-align: center;
/* padding: 0 20px; */
}
}
> .title {
font-weight: 600;
font-size: 20px;
line-height: 30px;
color: #374151;
margin-bottom: 16px;
}
.desc {
font-weight: normal;
color: #6b7280;
margin-bottom: 48px;
}
.name {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
margin-bottom: 34px;
.label {
font-weight: 600;
color: #6b7280;
}
.input {
position: relative;
input {
width: -webkit-fill-available;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
padding: 8px;
color: #78787c;
padding-left: 36px;
}
.icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
}
}
}
.private {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 50px;
.txt {
font-weight: 600;
color: #6b7280;
}
input {
cursor: pointer;
}
}
.btns {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
.btn {
cursor: pointer;
padding: 8px 16px;
background: none;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
font-weight: 500;
color: #374151;
&.create {
border: none;
background: #1fe1f9;
color: #fff;
}
}
}
.normal {
font-size: 14px;
line-height: 20px;
}
}
`;
export default StyledWrapper;
+69
View File
@@ -0,0 +1,69 @@
// import React from 'react';
import styled from "styled-components";
import Avatar from "./Avatar";
import { useGetContactsQuery } from "../../app/services/contact";
const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 8px;
border-radius: 4px;
user-select: none;
&.interactive {
&:hover,
&.active {
background: rgba(116, 127, 141, 0.1);
}
}
.avatar {
width: 32px;
height: 32px;
position: relative;
img {
border-radius: 50%;
width: 100%;
height: 100%;
}
.status {
position: absolute;
bottom: 0;
right: -2px;
width: 10px;
height: 10px;
border-radius: 50%;
outline: 2px solid #fff;
background-color: #22c55e;
&.offline {
background-color: #a1a1aa;
}
&.alert {
background-color: #dc2626;
}
}
}
.name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #52525b;
}
`;
export default function Contact({ interactive = true, status = "", uid = "" }) {
const { data: contacts } = useGetContactsQuery();
if (!contacts) return null;
const currUser = contacts.find((c) => c.uid == uid);
return (
<StyledWrapper
className={`${interactive ? "interactive" : ""}`}
title={currUser?.email}
>
<div className="avatar">
<Avatar url={currUser?.avatar} id={uid} alt="avatar" />
<div className={`status ${status}`}></div>
</div>
<span className="name">{currUser?.name}</span>
</StyledWrapper>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useRef } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import { NavLink } from "react-router-dom";
import { useOutsideClick } from "rooks";
import useFilteredUsers from "../hook/useFilteredUsers";
import Contact from "./Contact";
import Modal from "./Modal";
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 440px;
max-height: 402px;
background: #fff;
box-shadow: 0px 25px 50px rgba(31, 41, 55, 0.25);
border-radius: 8px;
transition: all 0.5s ease;
overflow-y: scroll;
.search {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
padding: 8px;
width: -webkit-fill-available;
input {
outline: none;
width: -webkit-fill-available;
padding: 8px;
font-size: 14px;
line-height: 20px;
border: none;
}
}
.users {
display: flex;
flex-direction: column;
height: 260px;
padding: 16px 0;
overflow-y: scroll;
.user {
cursor: pointer;
display: flex;
align-items: center;
padding: 0 8px;
width: -webkit-fill-available;
&:hover {
background: rgba(116, 127, 141, 0.1);
}
> a {
width: 100%;
}
}
}
`;
export default function ContactsModal({ closeModal }) {
const wrapperRef = useRef();
const { contacts, updateInput, input } = useFilteredUsers();
const currentUser = useSelector((state) => {
return state.authData.user;
});
useOutsideClick(wrapperRef, closeModal);
const handleSearch = (evt) => {
updateInput(evt.target.value);
};
if (!currentUser) return null;
return (
<Modal>
<StyledWrapper ref={wrapperRef}>
<div className="search">
<input
value={input}
onChange={handleSearch}
placeholder="Type username to search"
/>
</div>
{contacts && (
<ul className="users">
{contacts.map((u) => {
const { uid } = u;
return (
<li key={uid} className="user">
<NavLink onClick={closeModal} to={`/chat/dm/${uid}`}>
<Contact uid={uid} interactive={false} />
</NavLink>
</li>
);
})}
</ul>
)}
</StyledWrapper>
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
// import React from 'react';
import styled from "styled-components";
import dayjs from "dayjs";
import Avatar from "./Avatar";
import { useGetContactsQuery } from "../../app/services/contact";
const StyledMsg = styled.div`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 12px 0;
.avatar {
img {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.details {
display: flex;
flex-direction: column;
gap: 8px;
.up {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
.name {
color: #06b6d4;
font-style: normal;
font-size: 14px;
line-height: 20px;
}
.time {
color: #bfbfbf;
font-size: 12px;
line-height: 18px;
}
}
.down {
color: #374151;
font-weight: normal;
font-size: 14px;
line-height: 20px;
word-break: break-all;
}
}
`;
export default function Message({ uid, time, content }) {
const { data: contacts } = useGetContactsQuery();
if (!contacts) return null;
const currUser = contacts.find((c) => c.uid == uid);
return (
<StyledMsg>
<div className="avatar" data-uid={uid}>
<Avatar url={currUser.avatar} id={uid} name={currUser.name} />
</div>
<div className="details">
<div className="up">
<span className="name">{currUser.name}</span>
<i className="time">{dayjs(time).format("YYYY-MM-DD h:mm:ss A")}</i>
</div>
<div className="down">{content}</div>
</div>
</StyledMsg>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
const modalRoot = document.getElementById('root-modal');
const wrapper = document.createElement('div');
wrapper.classList.add('wrapper');
export default function Modal({ children }) {
// let eleRef = useRef(null);
useEffect(() => {
// const wrapper = document.createElement('div');
// eleRef.current = wrapper;
modalRoot.appendChild(wrapper);
return () => {
modalRoot.removeChild(wrapper);
};
}, []);
if (!wrapper) return null;
console.log("create portal");
return createPortal(children, wrapper);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import BASE_URL from "../../app/config";
import { addChannelMsg } from "../../app/slices/message.channel";
import { addUserMsg } from "../../app/slices/message.user";
const NotificationHub = () => {
const dispatch = useDispatch();
const { token } = useSelector((store) => store.authData);
useEffect(() => {
let sse = null;
if (token) {
sse = new EventSource(`${BASE_URL}/user/events?api-key=${token}`);
sse.onopen = () => {
console.info("sse opened");
};
sse.onerror = (err) => {
console.error("sse error", err);
};
sse.onmessage = (evt) => {
handleSSEMessage(JSON.parse(evt.data));
};
}
return () => {
if (sse) {
sse.close();
}
};
}, [token]);
const handleSSEMessage = (data) => {
const { type } = data;
switch (type) {
case "heartbeat":
console.log("heartbeat");
break;
case "chat":
console.log("chat data", data);
if (data.gid) {
const { gid, ...rest } = data;
dispatch(addChannelMsg({ id: gid, ...rest }));
} else {
dispatch(addUserMsg({ id: data.from_uid, ...data }));
}
break;
default:
break;
}
};
return null;
};
export default NotificationHub;
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import styled from "styled-components";
import { MdSearch, MdAdd, MdMail } from "react-icons/md";
import { useSelector } from "react-redux";
import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal";
import ContactsModal from "./ContactsModal";
const StyledWrapper = styled.div`
position: relative;
height: 56px;
padding: 0 10px 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
.search {
display: flex;
align-items: center;
gap: 5px;
.input {
border: none;
outline: none;
background: none;
font-weight: normal;
font-size: 14px;
line-height: 20px;
}
}
.add {
cursor: pointer;
}
.popup {
user-select: none;
box-shadow: 0px 20px 25px rgba(31, 41, 55, 0.1),
0px 10px 10px rgba(31, 41, 55, 0.04);
border-radius: 8px;
color: #616161;
background: #fff;
position: absolute;
top: 50px;
right: 8px;
display: flex;
flex-direction: column;
.item {
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
font-size: 14px;
line-height: 20px;
cursor: pointer;
padding: 12px;
}
}
`;
export default function Search() {
const currentUser = useSelector((store) => store.authData.user);
const [popupVisible, setPopupVisible] = useState(false);
const [channelModalVisible, setChannelModalVisible] = useState(false);
const [contactsModalVisible, setContactsModalVisible] = useState(false);
const [isPrivate, setIsPrivate] = useState(false);
const toggleContactsModalVisible = () => {
if (!contactsModalVisible) {
togglePopupVisible();
}
setContactsModalVisible((prev) => !prev);
};
const togglePopupVisible = () => {
setPopupVisible((prev) => !prev);
};
const handleOpenChannelModal = (isPrivate) => {
setIsPrivate(isPrivate);
setChannelModalVisible(true);
togglePopupVisible();
};
const handleCloseModal = () => {
setChannelModalVisible(false);
};
return (
<StyledWrapper>
{channelModalVisible && (
<ChannelModal personal={isPrivate} closeModal={handleCloseModal} />
)}
{contactsModalVisible && (
<ContactsModal closeModal={toggleContactsModalVisible} />
)}
<div className="search">
<MdSearch size={20} color="#A1A1AA" />
<input placeholder="Search..." className="input" />
</div>
<MdAdd
className="add"
onClick={togglePopupVisible}
size={24}
color="#A1A1AA"
/>
{popupVisible && (
<ul className="popup">
{currentUser?.is_admin && (
<li
className="item"
onClick={handleOpenChannelModal.bind(null, false)}
>
<ChannelIcon />
New Channel
</li>
)}
<li
className="item"
onClick={handleOpenChannelModal.bind(null, true)}
>
<ChannelIcon personal={true} />
New Private Channel
</li>
<li className="item" onClick={toggleContactsModalVisible}>
<MdMail size={20} color="#616161" />
New Message
</li>
</ul>
)}
</StyledWrapper>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { MdAdd } from "react-icons/md";
import TextareaAutosize from "react-textarea-autosize";
import { useDispatch } from "react-redux";
import { useSendChannelMsgMutation } from "../../app/services/channel";
import { useSendMsgMutation } from "../../app/services/contact";
import { addChannelMsg } from "../../app/slices/message.channel";
import { addUserMsg } from "../../app/slices/message.user";
const StyledSend = styled.div`
position: sticky;
top: calc(100vh - 90px);
left: 16px;
background: #f5f6f7;
border-radius: 8px;
width: 884px;
min-height: 54px;
display: flex;
align-items: center;
gap: 18px;
padding: 4px 18px;
.addon {
cursor: pointer;
}
.input {
width: 100%;
position: relative;
.content {
padding: 4px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: #616161;
width: 100%;
border: none;
background: none;
}
.btn {
cursor: pointer;
border: none;
border-radius: 4px;
padding: 2px 6px;
background: green;
color: #fff;
font-size: 16px;
font-weight: bold;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
}
`;
const Types = {
channel: "#",
user: "@",
};
export default function Send({ name, type = "channel", id = "" }) {
const dispatch = useDispatch();
const [
sendMsg,
{ isLoading: sending, isSuccess: sendSuccess, data: sendData },
] = useSendMsgMutation();
const [
sendChannelMsg,
{
isLoading: channelSending,
isSuccess: sendChannelSuccess,
data: sendChannelData,
},
] = useSendChannelMsgMutation();
const [msg, setMsg] = useState("");
const handleMsgChange = (evt) => {
setMsg(evt.target.value);
};
useEffect(() => {
if (sendSuccess) {
dispatch(addUserMsg({ id, ...sendData }));
setMsg("");
}
}, [sendSuccess, sendData]);
useEffect(() => {
if (sendChannelSuccess) {
const { gid, ...rest } = sendChannelData;
dispatch(addChannelMsg({ id: gid, ...rest }));
setMsg("");
}
}, [sendChannelSuccess, sendChannelData]);
const handleSendMessage = () => {
if (!msg || !type || !id) return;
switch (type) {
case "channel":
sendChannelMsg({ gid: id, message: msg });
break;
case "user":
sendMsg({ uid: id, message: msg });
break;
default:
break;
}
};
return (
<StyledSend className="send">
<MdAdd className="addon" size={20} color="#78787C" />
<div className="input">
<TextareaAutosize
className="content"
maxRows={8}
minRows={1}
onChange={handleMsgChange}
value={msg}
placeholder={`${Types[type]}${name} 发消息`}
/>
<button
disabled={sending || channelSending}
className="btn"
onClick={handleSendMessage}
>
{/* {sending ? `Sending` : `Send`} */}
Send
</button>
</div>
<div className="emoji">emoji</div>
</StyledSend>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from "react";
import { useGetContactsQuery } from "../../app/services/contact";
export default function useFilteredUsers() {
const [input, setInput] = useState("");
const { data: contacts } = useGetContactsQuery();
const [filteredUsers, setFilteredUsers] = useState(contacts);
useEffect(() => {
if (!input) {
setFilteredUsers(contacts);
} else {
let str = ["", ...input.toLowerCase(), ""].join(".*");
let reg = new RegExp(str);
setFilteredUsers(
contacts.filter((c) => {
return reg.test(c.name.toLowerCase());
})
);
}
}, [input, contacts]);
const updateInput = (val) => {
setInput(val);
};
return {
input,
contacts: filteredUsers,
updateInput,
};
}
+5
View File
@@ -0,0 +1,5 @@
export const isObjectEqual = (obj1, obj2) => {
let o1 = Object.entries(obj1).sort().toString();
let o2 = Object.entries(obj2).sort().toString();
return o1 === o2;
};