feat: unify session UX

This commit is contained in:
zerosoul
2022-06-13 21:22:11 +08:00
parent 5d7ea864a5
commit b3f63dc8e2
8 changed files with 404 additions and 45 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg width="5" height="8" viewBox="0 0 5 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.1875 3.16699V2.33366C4.1875 1.40033 3.375 0.666992 2.5 0.666992C1.625 0.666992 0.833333 1.40033 0.833333 2.33366V3.16699C0.373083 3.16699 0 3.54009 0 4.00032V6.50032C0 6.96056 0.373083 7.33366 0.833333 7.33366H2.5H4.16667C4.62692 7.33366 5 6.96056 5 6.50032V3.97949C5 3.53076 4.63625 3.16699 4.1875 3.16699ZM3.33333 3.16699H1.66667V2.33366C1.66667 1.85747 2.05558 1.50033 2.5 1.50033C2.94442 1.50033 3.33333 1.85747 3.33333 2.33366V3.16699Z" fill="#616161"/>
</svg>

After

Width:  |  Height:  |  Size: 570 B

+103
View File
@@ -0,0 +1,103 @@
// import React from 'react'
import Tippy from "@tippyjs/react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useLocation, useMatch } from "react-router-dom";
import { useUpdateMuteSettingMutation } from "../../../app/services/contact";
import { useReadMessageMutation } from "../../../app/services/message";
import { removeUserSession } from "../../../app/slices/message.user";
import ContextMenu from "../../../common/component/ContextMenu";
export default function SessionContextMenu({
context,
id,
visible,
mid,
hide,
deleteChannel,
setInviteChannelId,
children
}) {
const [muteChannel] = useUpdateMuteSettingMutation();
const [updateReadIndex] = useReadMessageMutation();
const pathMatched = useMatch(`/chat/dm/${id}`);
const dispatch = useDispatch();
const navigateTo = useNavigate();
const { pathname } = useLocation();
const { channelMuted } = useSelector((store) => {
return {
channelMuted: context == "channel" ? store.footprint.muteChannels[id] : false
};
});
const handleChannelSetting = () => {
navigateTo(`/setting/channel/${id}?f=${pathname}`);
};
const handleReadAll = () => {
// console.log("last mid", mids, lastMid);
if (mid) {
const param =
context == "dm" ? { users: [{ uid: +id, mid }] } : { groups: [{ gid: +id, mid }] };
updateReadIndex(param);
}
};
const handleRemoveSession = () => {
dispatch(removeUserSession(id));
if (pathMatched) {
navigateTo("/chat");
}
};
const handleChannelMute = () => {
const data = channelMuted ? { remove_groups: [id] } : { add_groups: [{ gid: id }] };
muteChannel(data);
};
const items =
context == "dm"
? [
{
title: "Mark As Read",
handler: handleReadAll
},
{
title: "Hide Session",
danger: true,
handler: handleRemoveSession
}
]
: [
{
title: "Settings",
underline: true,
handler: handleChannelSetting
},
{
title: "Mark As Read",
// underline: true
handler: handleReadAll
},
{
title: channelMuted ? "Unmute" : "Mute",
handler: handleChannelMute
},
{
title: "Invite People",
handler: setInviteChannelId.bind(null, id)
},
{
title: "Delete Channel",
danger: true,
handler: deleteChannel.bind(null, id)
}
];
return (
<Tippy
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
followCursor={"initial"}
visible={visible}
onClickOutside={hide}
content={<ContextMenu hideMenu={hide} items={items} />}
>
{children}
</Tippy>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import dayjs from "dayjs";
import { useDrop } from "react-dnd";
import { NativeTypes } from "react-dnd-html5-backend";
import relativeTime from "dayjs/plugin/relativeTime";
import ContextMenu from "./ContextMenu";
import { renderPreviewMessage } from "../utils";
import Contact from "../../../common/component/Contact";
import iconChannel from "../../../assets/icons/channel.svg?url";
import IconLock from "../../../assets/icons/lock.svg";
import useContextMenu from "../../../common/hook/useContextMenu";
import { useNavigate, NavLink } from "react-router-dom";
dayjs.extend(relativeTime);
export default function Session({
type = "dm",
id,
mid,
setFiles,
setDeleteChannelId,
setInviteChannelId
}) {
const navigate = useNavigate();
const [{ isActive }, drop] = useDrop(() => ({
accept: [NativeTypes.FILE],
drop({ dataTransfer }) {
if (dataTransfer.files.length) {
// console.log(files, rest);
setFiles([...dataTransfer.files]);
navigate(type == "dm" ? `/chat/dm/${id}` : `/chat/channel/${id}`);
// 重置
setTimeout(() => {
setFiles([]);
}, 300);
}
},
collect: (monitor) => ({
isActive: monitor.canDrop() && monitor.isOver()
})
}));
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
const [data, setData] = useState(null);
const { messageData, contactData, channelData } = useSelector((store) => {
return {
messageData: store.message,
contactData: store.contacts.byId,
channelData: store.channels.byId
};
});
const handleImageError = (evt) => {
evt.target.classList.add("channel_default");
evt.target.src = iconChannel;
};
useEffect(() => {
const tmp = type == "dm" ? contactData[id] : channelData[id];
if (!tmp) return;
const { name, icon, avatar, is_public = true } = tmp;
const session =
type == "dm" ? { name, icon: avatar, mid, is_public } : { name, icon, mid, is_public };
setData(session);
}, [id, mid, type, contactData, channelData]);
if (!data) return null;
const previewMsg = messageData[mid] || {};
const { name, icon, is_public } = data;
return (
<li className="session">
<ContextMenu
visible={contextMenuVisible}
hide={hideContextMenu}
context={type}
id={id}
mid={mid}
setInviteChannelId={setInviteChannelId}
deleteChannel={setDeleteChannelId}
>
<NavLink
ref={drop}
className={`nav ${isActive ? "drop_over" : ""}`}
to={type == "dm" ? `/chat/dm/${id}` : `/chat/channel/${id}`}
onContextMenu={handleContextMenuEvent}
>
<div className="icon">
{type == "dm" ? (
<Contact avatarSize={40} compact interactive={false} className="avatar" uid={id} />
) : (
<img
className={`${icon ? "" : "channel_default"}`}
onError={handleImageError}
src={icon || iconChannel}
/>
)}
</div>
<div className="details">
<div className="up">
<span className="name">
{name} {!is_public && <IconLock />}
</span>
<span className="time">{dayjs(previewMsg.created_at).fromNow()}</span>
</div>
<div className="down">
<span className="msg">{renderPreviewMessage(previewMsg)}</span>
</div>
</div>
</NavLink>
</ContextMenu>
</li>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import Styled from "./styled";
import Session from "./Session";
import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
import InviteModal from "../../../common/component/InviteModal";
export default function SessionList() {
const [deleteId, setDeleteId] = useState(null);
const [inviteChannelId, setInviteChannelId] = useState(null);
const [sessions, setSessions] = useState([]);
const { channelIDs, DMs, readChannels, readUsers, channelMessage, userMessage, loginUid } =
useSelector((store) => {
return {
loginUid: store.authData.uid,
channelIDs: store.channels.ids,
DMs: store.userMessage.ids,
userMessage: store.userMessage.byId,
channelMessage: store.channelMessage,
readChannels: store.footprint.readChannels,
readUsers: store.footprint.readUsers
};
});
useEffect(() => {
const cSessions = channelIDs.map((id) => {
const mids = channelMessage[id];
if (!mids || mids.length == 0) {
return { mid: null, unreads: 0, id, type: "channel" };
}
const mid = [...mids].pop();
return { key: `channel_${id}`, id, mid, type: "channel" };
});
const uSessions = DMs.map((id) => {
const mids = userMessage[id];
if (!mids || mids.length == 0) {
return { mid: null, unreads: 0, id, type: "dm" };
}
const mid = [...mids].pop();
return { key: `dm_${id}`, type: "dm", id, mid };
});
setSessions(
[...cSessions, ...uSessions].sort((a, b) => {
return b.mid - a.mid;
})
);
}, [channelIDs, DMs, channelMessage, readChannels, readUsers, loginUid, userMessage]);
return (
<>
<Styled>
{sessions.map((s) => {
const { key, type, id, mid } = s;
return (
<Session
key={key}
type={type}
id={id}
mid={mid}
setInviteChannelId={setInviteChannelId}
setDeleteChannelId={setDeleteId}
className="session"
/>
);
})}
</Styled>
{deleteId && (
<DeleteChannelConfirmModal
id={deleteId}
closeModal={() => {
setDeleteId(null);
}}
/>
)}
{inviteChannelId && (
<InviteModal
type="channel"
cid={inviteChannelId}
closeModal={() => {
setInviteChannelId(null);
}}
/>
)}
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
import styled from "styled-components";
const Styled = styled.ul`
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
height: calc(100vh - 56px - 56px - 16px);
overflow: auto;
> .session {
> a {
display: flex;
gap: 8px;
border-radius: 8px;
padding: 8px;
width: 100%;
&.active,
&:hover {
background: rgba(116, 127, 141, 0.2);
}
.icon {
flex: 1;
background-color: #eee;
border-radius: 50%;
img {
width: 40px;
height: 40px;
&.channel_default {
padding: 5px;
/* height: 35px; */
}
}
}
.details {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
.up {
display: flex;
align-items: center;
justify-content: space-between;
.name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #52525b;
max-width: 112px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.time {
font-weight: 500;
font-size: 12px;
line-height: 18px;
color: #78787c;
}
}
.down {
display: flex;
align-items: center;
.msg {
font-weight: 400;
font-size: 12px;
line-height: 18px;
color: #78787c;
white-space: nowrap;
overflow: hidden;
width: 140px;
text-overflow: ellipsis;
}
}
}
}
}
`;
export default Styled;
+3 -43
View File
@@ -3,22 +3,17 @@ import { useState } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { AiOutlineCaretDown } from "react-icons/ai";
import StyledWrapper from "./styled";
import AddIcon from "../../assets/icons/add.svg";
import BlankPlaceholder from "../../common/component/BlankPlaceholder";
import Server from "../../common/component/Server";
import Tooltip from "../../common/component/Tooltip";
// import Tooltip from "../../common/component/Tooltip";
// import Contact from "../../common/component/Contact";
import CurrentUser from "../../common/component/CurrentUser";
import ChannelChat from "./ChannelChat";
import DMChat from "./DMChat";
import ChannelList from "./ChannelList";
import ContactsModal from "../../common/component/ContactsModal";
import ChannelModal from "../../common/component/ChannelModal";
import DMList from "./DMList";
import SessionList from "./SessionList";
export default function ChatPage() {
const [channelDropFiles, setChannelDropFiles] = useState([]);
const [userDropFiles, setUserDropFiles] = useState([]);
@@ -36,11 +31,6 @@ export default function ChatPage() {
const toggleChannelModalVisible = () => {
setChannelModalVisible((prev) => !prev);
};
const handleToggleExpand = (evt) => {
const { currentTarget } = evt;
const listEle = currentTarget.parentElement.parentElement;
listEle.classList.toggle("collapse");
};
const tmpUid = sessionUids.findIndex((i) => i == user_id) > -1 ? null : user_id;
// console.log("temp uid", tmpUid);
const placeholderVisible = !channel_id && !user_id;
@@ -53,37 +43,7 @@ export default function ChatPage() {
<StyledWrapper>
<div className="left">
<Server />
<div className="list channels">
<h3 className="title">
<span className="txt" onClick={handleToggleExpand}>
<AiOutlineCaretDown className="icon" size={18} color="#78787C" />
CHANNELS
</span>
<Tooltip tip="New Channel" placement="bottom">
<AddIcon className="add_icon" onClick={toggleChannelModalVisible} />
</Tooltip>
</h3>
<nav className="nav">
<ChannelList setDropFiles={setChannelDropFiles} />
</nav>
</div>
<div className="list dms">
<h3 className="title">
<span className="txt" onClick={handleToggleExpand}>
<AiOutlineCaretDown className="icon" size={18} color="#78787C" />
DIRECT MESSAGE
</span>
<Tooltip tip="New DM" placement="bottom">
<AddIcon className="add_icon" onClick={toggleContactsModalVisible} />
</Tooltip>
</h3>
<nav className="nav">
<DMList
uids={tmpUid ? [...sessionUids, tmpUid] : sessionUids}
setDropFiles={setUserDropFiles}
/>
</nav>
</div>
<SessionList />
<CurrentUser />
</div>
<div className={`right ${placeholderVisible ? "placeholder" : ""}`}>
-2
View File
@@ -107,8 +107,6 @@ const StyledWrapper = styled.div`
text-overflow: ellipsis;
}
> .badge {
/* letter-spacing: -1px; */
/* padding: 2px; */
color: #fff;
height: 20px;
min-width: 20px;
+25
View File
@@ -0,0 +1,25 @@
import { useState } from "react";
import { useSelector } from "react-redux";
// import { useUpdateChannelMutation } from "../../app/services/channel";
import { useUpdateMuteSettingMutation } from "../../app/services/contact";
import { useReadMessageMutation } from "../../app/services/message";
export default function useSession({ type, id }) {
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [muteChannel] = useUpdateMuteSettingMutation();
const [updateReadIndex] = useReadMessageMutation();
const { messageData, contactData, channelData } = useSelector((store) => {
return {
messageData: store.message,
contactData: store.contacts.byId,
channelData: store.channels.byId
};
});
const toggleInviteModal = () => {
setInviteModalVisible((prev) => !prev);
};
return {
inviteModalVisible,
toggleInviteModal
};
}