feat: lots of updates
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
const BASE_URL = `http://rustchat.com:3000/api`;
|
||||
|
||||
export const tokenHeader = "X-API-Key";
|
||||
|
||||
export default BASE_URL;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import BASE_URL from "../config";
|
||||
|
||||
export const authApi = createApi({
|
||||
reducerPath: "auth",
|
||||
baseQuery: fetchBaseQuery({ baseUrl: BASE_URL }),
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation({
|
||||
query: (credentials) => ({
|
||||
url: "token/login",
|
||||
method: "POST",
|
||||
body: credentials,
|
||||
}),
|
||||
// transformResponse: (resp) => {
|
||||
// console.log("resp", resp);
|
||||
// if (resp.status == 401) {
|
||||
// resp.msg = "username or password incorrect";
|
||||
// }
|
||||
// return resp;
|
||||
// },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useLoginMutation } = authApi;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fetchBaseQuery } from "@reduxjs/toolkit/query";
|
||||
import BASE_URL, { tokenHeader } from "../config";
|
||||
// const whiteList = [
|
||||
// "/resource/avatar",
|
||||
// "/resource/company/logo",
|
||||
// "/resource/thumbnail",
|
||||
// "/resource/image",
|
||||
// "/token/login",
|
||||
// "/token/renew",
|
||||
// "/user",
|
||||
// "/admin/system/company",
|
||||
// ];
|
||||
const baseQuery = fetchBaseQuery({
|
||||
baseUrl: BASE_URL,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
const { token } = getState().authData;
|
||||
if (token) {
|
||||
headers.set(tokenHeader, token);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
});
|
||||
const baseQueryWithReauth = async (args, api, extraOptions) => {
|
||||
let result = await baseQuery(args, api, extraOptions);
|
||||
if (result.error && result.error.status === 401) {
|
||||
// try to get a new token with refreshToken
|
||||
// const refreshResult = await baseQuery('/refreshToken', api, extraOptions);
|
||||
// if (refreshResult.data) {
|
||||
// // store the new token
|
||||
// api.dispatch(tokenReceived(refreshResult.data));
|
||||
// // retry the initial query
|
||||
// result = await baseQuery(args, api, extraOptions);
|
||||
// } else {
|
||||
// api.dispatch(loggedOut());
|
||||
// }
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default baseQueryWithReauth;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||
import baseQuery from "./base.query";
|
||||
import { REHYDRATE } from "redux-persist";
|
||||
export const channelApi = createApi({
|
||||
reducerPath: "groups",
|
||||
baseQuery,
|
||||
extractRehydrationInfo(action, { reducerPath }) {
|
||||
if (action.type === REHYDRATE) {
|
||||
return action.payload ? action.payload[reducerPath] : undefined;
|
||||
}
|
||||
},
|
||||
refetchOnFocus: true,
|
||||
endpoints: (builder) => ({
|
||||
getChannels: builder.query({
|
||||
query: () => ({ url: `group` }),
|
||||
}),
|
||||
createChannel: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: "group",
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
sendChannelMsg: builder.mutation({
|
||||
query: ({ gid, message }) => ({
|
||||
url: `group/${gid}/send`,
|
||||
method: "POST",
|
||||
body: message,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetChannelsQuery,
|
||||
useCreateChannelMutation,
|
||||
useSendChannelMsgMutation,
|
||||
} = channelApi;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||
import baseQuery from "./base.query";
|
||||
import BASE_URL from "../config";
|
||||
import { REHYDRATE } from "redux-persist";
|
||||
export const contactApi = createApi({
|
||||
reducerPath: "contacts",
|
||||
baseQuery,
|
||||
extractRehydrationInfo(action, { reducerPath }) {
|
||||
if (action.type === REHYDRATE) {
|
||||
return action.payload ? action.payload[reducerPath] : undefined;
|
||||
}
|
||||
},
|
||||
endpoints: (builder) => ({
|
||||
getContacts: builder.query({
|
||||
query: () => ({ url: `user` }),
|
||||
transformResponse: (data) => {
|
||||
return data.map((user) => {
|
||||
const avatar = `${BASE_URL}/resource/avatar?uid=${user.uid}`;
|
||||
user.avatar = avatar;
|
||||
return user;
|
||||
});
|
||||
},
|
||||
}),
|
||||
sendMsg: builder.mutation({
|
||||
query: ({ uid, message }) => ({
|
||||
url: `user/${uid}/send`,
|
||||
method: "POST",
|
||||
body: message,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetContactsQuery, useSendMsgMutation } = contactApi;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||
import BASE_URL from "../config";
|
||||
// import { REHYDRATE } from 'redux-persist';
|
||||
|
||||
import baseQuery from "./base.query";
|
||||
|
||||
export const serverApi = createApi({
|
||||
reducerPath: "server",
|
||||
baseQuery,
|
||||
// extractRehydrationInfo(action, { reducerPath }) {
|
||||
// if (action.type === REHYDRATE) {
|
||||
// return action.payload ? action.payload[reducerPath] : undefined;
|
||||
// }
|
||||
// },
|
||||
endpoints: (builder) => ({
|
||||
getServer: builder.query({
|
||||
query: () => ({ url: `admin/system/company` }),
|
||||
transformResponse: (data) => {
|
||||
data.logo = `${BASE_URL}/resource/company/logo`;
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetServerQuery } = serverApi;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
};
|
||||
const authDataSlice = createSlice({
|
||||
name: "authData",
|
||||
initialState,
|
||||
reducers: {
|
||||
setAuthData(state, action) {
|
||||
const { user, token, refresh_token } = action.payload;
|
||||
state.user = user;
|
||||
state.token = token;
|
||||
state.refreshToken = refresh_token;
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { setAuthData } = authDataSlice.actions;
|
||||
export default authDataSlice.reducer;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { isObjectEqual } from "../../common/utils";
|
||||
|
||||
const initialState = {};
|
||||
const channelMsgSlice = createSlice({
|
||||
name: "channelMessage",
|
||||
initialState,
|
||||
reducers: {
|
||||
addChannelMsg(state, action) {
|
||||
const { id, content, created_at, mid, from_uid } = action.payload;
|
||||
const newMsg = { content, created_at, from_uid };
|
||||
if (state[id]) {
|
||||
let replaceMsg = state[id][mid];
|
||||
// 如果存在,并且新消息和缓存消息不一样,则替换掉
|
||||
if (replaceMsg) {
|
||||
const copyMsg = { ...replaceMsg };
|
||||
if (!isObjectEqual(copyMsg, newMsg)) {
|
||||
state[id][mid] = newMsg;
|
||||
}
|
||||
} else {
|
||||
state[id][mid] = newMsg;
|
||||
}
|
||||
// let replaceIdx = state[id].findIndex((m) => m.mid == mid);
|
||||
// if (replaceIdx > -1) {
|
||||
// const copyMsg = { ...state[id][replaceIdx] };
|
||||
// console.log("current channel msg", copyMsg, newMsg);
|
||||
// if (!isObjectEqual(copyMsg, newMsg)) {
|
||||
// state[id][replaceIdx] = newMsg;
|
||||
// }
|
||||
// } else {
|
||||
// state[id] = [...state[id], newMsg];
|
||||
// }
|
||||
} else {
|
||||
state[id] = { [mid]: newMsg };
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { addChannelMsg } = channelMsgSlice.actions;
|
||||
export default channelMsgSlice.reducer;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { isObjectEqual } from "../../common/utils";
|
||||
|
||||
const initialState = {};
|
||||
const userMsgSlice = createSlice({
|
||||
name: "userMessage",
|
||||
initialState,
|
||||
reducers: {
|
||||
addUserMsg(state, action) {
|
||||
const { id, content, created_at, mid, from_uid } = action.payload;
|
||||
const newMsg = { content, created_at, from_uid };
|
||||
if (state[id]) {
|
||||
let replaceMsg = state[id][mid];
|
||||
// 如果存在,并且新消息和缓存消息不一样,则替换掉
|
||||
if (replaceMsg) {
|
||||
const copyMsg = { ...replaceMsg };
|
||||
if (!isObjectEqual(copyMsg, newMsg)) {
|
||||
state[id][mid] = newMsg;
|
||||
}
|
||||
} else {
|
||||
state[id][mid] = newMsg;
|
||||
}
|
||||
// let replaceIdx = state[id].findIndex((m) => m.mid == mid);
|
||||
// if (replaceIdx > -1) {
|
||||
// const copyMsg = { ...state[id][replaceIdx] };
|
||||
// console.log("current user msg", copyMsg, newMsg);
|
||||
// if (!isObjectEqual(copyMsg, newMsg)) {
|
||||
// state[id][replaceIdx] = newMsg;
|
||||
// }
|
||||
// } else {
|
||||
// state[id] = [...state[id], newMsg];
|
||||
// }
|
||||
} else {
|
||||
state[id] = { [mid]: newMsg };
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { addUserMsg } = userMsgSlice.actions;
|
||||
export default userMsgSlice.reducer;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { configureStore, combineReducers } from "@reduxjs/toolkit";
|
||||
import { setupListeners } from "@reduxjs/toolkit/query";
|
||||
import storage from "redux-persist/lib/storage";
|
||||
import {
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from "redux-persist";
|
||||
import authDataReducer from "./slices/auth.data";
|
||||
import channelMsgReducer from "./slices/message.channel";
|
||||
import userMsgReducer from "./slices/message.user";
|
||||
import { authApi } from "./services/auth";
|
||||
import { contactApi } from "./services/contact";
|
||||
import { channelApi } from "./services/channel";
|
||||
import { serverApi } from "./services/server";
|
||||
const persistConfig = {
|
||||
key: "root",
|
||||
version: 1,
|
||||
storage,
|
||||
};
|
||||
const persistedReducer = persistReducer(
|
||||
persistConfig,
|
||||
combineReducers({
|
||||
userMsg: userMsgReducer,
|
||||
channelMsg: channelMsgReducer,
|
||||
authData: authDataReducer,
|
||||
[authApi.reducerPath]: authApi.reducer,
|
||||
[contactApi.reducerPath]: contactApi.reducer,
|
||||
[channelApi.reducerPath]: channelApi.reducer,
|
||||
[serverApi.reducerPath]: serverApi.reducer,
|
||||
})
|
||||
);
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
}).concat(
|
||||
authApi.middleware,
|
||||
contactApi.middleware,
|
||||
channelApi.middleware,
|
||||
serverApi.middleware
|
||||
),
|
||||
});
|
||||
setupListeners(store.dispatch);
|
||||
export default store;
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.7207 25.7835C13.4371 27.17 15.6215 28.0002 17.9998 28.0002C19.5087 28.0002 20.9418 27.6654 22.2269 27.0653L26.8037 27.9809C27.1315 28.0464 27.4705 27.9438 27.7069 27.7074C27.9433 27.471 28.046 27.132 27.9804 26.8042L27.065 22.227C27.665 20.942 27.9998 19.509 27.9998 18.0002C27.9998 15.6224 27.1699 13.4385 25.7839 11.7222C25.9257 12.4597 25.9999 13.2213 25.9999 14.0002C25.9999 14.7535 25.9305 15.4906 25.7977 16.2055C25.93 16.7824 25.9998 17.3832 25.9998 18.0002C25.9998 19.3117 25.685 20.5466 25.1278 21.6365C25.0252 21.8373 24.9934 22.0668 25.0376 22.2879L25.7251 25.7255L22.2878 25.0379C22.0667 24.9936 21.8371 25.0254 21.6364 25.1281C20.5464 25.6854 19.3113 26.0002 17.9998 26.0002C17.3827 26.0002 16.7819 25.9304 16.2049 25.7981C15.4901 25.9308 14.7531 26.0002 13.9999 26.0002C13.2215 26.0002 12.4594 25.9258 11.7207 25.7835ZM14 4C8.47717 4 4.00002 8.47715 4.00002 14C4.00002 15.5088 4.3348 16.9418 4.93483 18.2267L4.01943 22.8039C3.95386 23.1318 4.05649 23.4707 4.29293 23.7072C4.52936 23.9436 4.86831 24.0462 5.19617 23.9806L9.77298 23.0651C11.058 23.6652 12.4911 24 14 24C19.5229 24 24 19.5228 24 14C24 8.47715 19.5229 4 14 4Z" fill="#4B5563"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.99995 9.10619C4.6356 9.10619 4.29404 9.20378 4 9.37426C3.40416 9.71971 3.00342 10.3644 3.00342 11.1027C3.00342 11.841 3.40416 12.4857 4 12.8312C4.29404 13.0017 4.6356 13.0993 4.99995 13.0993C6.10261 13.0993 6.99649 12.2054 6.99649 11.1027C6.99649 10.0001 6.10261 9.10619 4.99995 9.10619ZM4.99995 7.10619C4.77861 7.10619 4.56147 7.12418 4.34992 7.15878C5.08315 5.30851 6.88877 4 9 4H23C25.7614 4 28 6.23858 28 9V23C28 25.7614 25.7614 28 23 28H9C6.23858 28 4 25.7614 4 23V14.9731C4.31959 15.0555 4.65466 15.0993 4.99995 15.0993C7.20718 15.0993 8.99649 13.3099 8.99649 11.1027C8.99649 8.8955 7.20718 7.10619 4.99995 7.10619ZM11 13C11 13.5523 11.4477 14 12 14H20C20.5523 14 21 13.5523 21 13C21 12.4477 20.5523 12 20 12H12C11.4477 12 11 12.4477 11 13ZM11 19C11 19.5523 11.4477 20 12 20H17.2278C17.7801 20 18.2278 19.5523 18.2278 19C18.2278 18.4477 17.7801 18 17.2278 18H12C11.4477 18 11 18.4477 11 19Z" fill="#4B5563"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// import React from 'react';
|
||||
import ReactDOM from "react-dom";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Route, Routes, HashRouter } from "react-router-dom";
|
||||
import { Reset } from "styled-reset";
|
||||
import { persistStore } from "redux-persist";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
import store from "./app/store";
|
||||
// import Welcome from './routes/Welcome'
|
||||
import NotFoundPage from "./routes/404";
|
||||
import LoginPage from "./routes/login";
|
||||
import HomePage from "./routes/home";
|
||||
import ChatPage from "./routes/chat";
|
||||
import ContactsPage from "./routes/contacts";
|
||||
|
||||
let persistor = persistStore(store);
|
||||
ReactDOM.render(
|
||||
<>
|
||||
<Toaster />
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
<Route path="chat">
|
||||
<Route index element={<ChatPage />} />
|
||||
<Route path="channel/:channel_id" element={<ChatPage />} />
|
||||
<Route path="dm/:user_id" element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="contacts">
|
||||
<Route index element={<ContactsPage />} />
|
||||
<Route path=":user_id" element={<ContactsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
<Reset />
|
||||
</>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
// import React from 'react';
|
||||
import StyledWrapper from "./styled";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <StyledWrapper>404 page</StyledWrapper>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import Message from "../../common/component/Message";
|
||||
import ChannelIcon from "../../common/component/ChannelIcon";
|
||||
import Send from "../../common/component/Send";
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
// import msgs from "./channel.msg.mock.json";
|
||||
import Contact from "../../common/component/Contact";
|
||||
|
||||
const StyledWrapper = styled.article`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
.head {
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
|
||||
.txt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
.title {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #1c1c1e;
|
||||
}
|
||||
.desc {
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #616161;
|
||||
}
|
||||
}
|
||||
}
|
||||
.main {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
padding: 0 0 20px 16px;
|
||||
.notification {
|
||||
padding: 3px 8px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
width: 900px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #3c8ce7 0%, #00eaff 100%);
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.clear {
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
.channel {
|
||||
width: 684px;
|
||||
.info {
|
||||
padding-top: 114px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 36px;
|
||||
line-height: 44px;
|
||||
}
|
||||
.desc {
|
||||
color: #78787c;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.edit {
|
||||
color: #3c8ce7;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
.chat {
|
||||
padding: 18px 0;
|
||||
}
|
||||
}
|
||||
.contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
/* todo */
|
||||
width: 226px;
|
||||
height: calc(100vh - 56px);
|
||||
overflow-y: scroll;
|
||||
background: #f5f6f7;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function ChannelChat({ cid = "", data = {} }) {
|
||||
const msgs = useSelector((store) => {
|
||||
return store.channelMsg[cid] || {};
|
||||
});
|
||||
const { data: users } = useGetContactsQuery();
|
||||
useEffect(() => {
|
||||
console.log({ cid });
|
||||
}, [cid]);
|
||||
const { name, description, is_public, members = [] } = data;
|
||||
const filteredUsers =
|
||||
members.length == 0
|
||||
? users
|
||||
: users.filter((u) => {
|
||||
return members.includes(u.uid);
|
||||
});
|
||||
console.log("channel message list", msgs);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<header className="head">
|
||||
<div className="txt">
|
||||
<ChannelIcon personal={!is_public} />
|
||||
<span className="title">{name}</span>
|
||||
|
||||
<span className="desc">{description}</span>
|
||||
</div>
|
||||
<ul className="members">members</ul>
|
||||
</header>
|
||||
<main className="main">
|
||||
<div className="notification">
|
||||
<div className="content">25+ new messages since 3:24 PM</div>
|
||||
<button className="clear">Mark As Read</button>
|
||||
</div>
|
||||
<div className="channel">
|
||||
<div className="info">
|
||||
<h2 className="title">Welcome to #{name} !</h2>
|
||||
<p className="desc">This is the start of the #{name} channel. </p>
|
||||
<button className="edit">Edit Channel</button>
|
||||
</div>
|
||||
<div className="chat">
|
||||
{Object.entries(msgs).map(([mid, msg]) => {
|
||||
if (!msg) return null;
|
||||
const { from_uid, content, created_at } = msg;
|
||||
return (
|
||||
<Message
|
||||
key={mid}
|
||||
time={created_at}
|
||||
uid={from_uid}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="contacts">
|
||||
{filteredUsers.map(({ name, status, uid }) => {
|
||||
return <Contact key={name} uid={uid} status={status} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
<Send id={cid} type="channel" name={name} />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useSelector } from "react-redux";
|
||||
import Message from "../../common/component/Message";
|
||||
import Send from "../../common/component/Send";
|
||||
// import msgs from "./channel.msg.mock.json";
|
||||
import Contact from "../../common/component/Contact";
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
|
||||
const StyledWrapper = styled.article`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
.head {
|
||||
height: 56px;
|
||||
padding: 0 20px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
|
||||
.txt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
.title {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #1c1c1e;
|
||||
}
|
||||
.desc {
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #616161;
|
||||
}
|
||||
}
|
||||
}
|
||||
.main {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
padding: 0 0 20px 16px;
|
||||
.chat {
|
||||
width: 890px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function DMChat({ uid = "" }) {
|
||||
const msgs = useSelector((store) => {
|
||||
return store.userMsg[uid] || {};
|
||||
});
|
||||
const { data: contacts } = useGetContactsQuery();
|
||||
const [currUser, setCurrUser] = useState(null);
|
||||
useEffect(() => {
|
||||
console.log({ uid });
|
||||
if (uid && contacts) {
|
||||
setCurrUser(contacts.find((c) => c.uid == uid));
|
||||
}
|
||||
}, [uid, contacts]);
|
||||
if (!currUser) return null;
|
||||
console.log("user msgs", msgs);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<header className="head">
|
||||
<Contact interactive={false} uid={currUser.uid} />
|
||||
{/* <ul className="members">members</ul> */}
|
||||
</header>
|
||||
<main className="main">
|
||||
<div className="chat">
|
||||
{Object.entries(msgs).map(([mid, msg]) => {
|
||||
if (!msg) return null;
|
||||
const { from_uid, content, created_at } = msg;
|
||||
return (
|
||||
<Message
|
||||
key={mid}
|
||||
time={created_at}
|
||||
uid={from_uid}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
<Send type="user" name={currUser?.name} id={currUser?.uid} />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// import React from 'react';
|
||||
import { useState } from "react";
|
||||
import { NavLink, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import { AiOutlineCaretDown } from "react-icons/ai";
|
||||
import { useGetChannelsQuery } from "../../app/services/channel";
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
import StyledWrapper from "./styled";
|
||||
import Search from "../../common/component/Search";
|
||||
import Avatar from "../../common/component/Avatar";
|
||||
import ChannelIcon from "../../common/component/ChannelIcon";
|
||||
import ChannelChat from "./ChannelChat";
|
||||
import DMChat from "./DMChat";
|
||||
import ContactsModal from "../../common/component/ContactsModal";
|
||||
import ChannelModal from "../../common/component/ChannelModal";
|
||||
|
||||
export default function ChatPage() {
|
||||
const UserMsgData = useSelector((store) => {
|
||||
return store.userMsg;
|
||||
});
|
||||
const [channelModalVisible, setChannelModalVisible] = useState(false);
|
||||
const [contactsModalVisible, setContactsModalVisible] = useState(false);
|
||||
const { channel_id, user_id } = useParams();
|
||||
const { data: channels } = useGetChannelsQuery();
|
||||
const { data: contacts } = useGetContactsQuery();
|
||||
const toggleContactsModalVisible = () => {
|
||||
setContactsModalVisible((prev) => !prev);
|
||||
};
|
||||
const toggleChannelModalVisible = () => {
|
||||
setChannelModalVisible((prev) => !prev);
|
||||
};
|
||||
console.log("channels", channels);
|
||||
if (!channels || !contacts) return null;
|
||||
const Sessions = Object.keys(UserMsgData);
|
||||
const tmpSessionUser = contacts.find((c) => c.uid == user_id);
|
||||
return (
|
||||
<>
|
||||
{channelModalVisible && (
|
||||
<ChannelModal closeModal={toggleChannelModalVisible} personal={true} />
|
||||
)}
|
||||
{contactsModalVisible && (
|
||||
<ContactsModal closeModal={toggleContactsModalVisible} />
|
||||
)}
|
||||
<StyledWrapper>
|
||||
<div className="left">
|
||||
<Search />
|
||||
<div className="list channels">
|
||||
<h3 className="title">
|
||||
<span className="txt">
|
||||
<AiOutlineCaretDown size={18} color="#78787C" />
|
||||
CHANNELS
|
||||
</span>
|
||||
<MdAdd
|
||||
onClick={toggleChannelModalVisible}
|
||||
size={18}
|
||||
color="#78787C"
|
||||
/>
|
||||
</h3>
|
||||
<nav className="nav">
|
||||
{channels.map(({ gid, is_public, name, description }) => {
|
||||
return (
|
||||
<NavLink
|
||||
title={description}
|
||||
key={gid}
|
||||
className="link"
|
||||
to={`/chat/channel/${gid}`}
|
||||
>
|
||||
<span className="txt">
|
||||
<ChannelIcon personal={!is_public} />
|
||||
{name}
|
||||
</span>
|
||||
<i className="badge">12</i>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="list dms">
|
||||
<h3 className="title">
|
||||
<span className="txt">
|
||||
<AiOutlineCaretDown size={18} color="#78787C" />
|
||||
DIRECT MESSAGE
|
||||
</span>
|
||||
<MdAdd
|
||||
size={18}
|
||||
color="#78787C"
|
||||
onClick={toggleContactsModalVisible}
|
||||
/>
|
||||
</h3>
|
||||
<nav className="nav">
|
||||
{Sessions.map((uid) => {
|
||||
let currUser = contacts.find((c) => c.uid == uid);
|
||||
let latestMid = Object.keys(UserMsgData[uid]).sort().pop();
|
||||
let latestMsg = UserMsgData[uid][latestMid];
|
||||
return (
|
||||
<NavLink key={uid} className="session" to={`/chat/dm/${uid}`}>
|
||||
<Avatar className="avatar" url={currUser.avatar} id={uid} />
|
||||
<div className="details">
|
||||
<div className="up">
|
||||
<span className="name">{currUser.name}</span>
|
||||
<time>
|
||||
{dayjs(latestMsg.created_at).format("YYYY-MM-DD")}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="down">
|
||||
<div className="msg">{latestMsg.content}</div>
|
||||
<i className="badge">3</i>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{user_id && !Sessions.includes(user_id) && (
|
||||
<NavLink className="session" to={`/chat/dm/${user_id}`}>
|
||||
<Avatar
|
||||
className="avatar"
|
||||
url={tmpSessionUser.avatar}
|
||||
id={user_id}
|
||||
/>
|
||||
<div className="details">
|
||||
<div className="up">
|
||||
<span className="name">{tmpSessionUser.name}</span>
|
||||
<time></time>
|
||||
</div>
|
||||
|
||||
<div className="down">
|
||||
<div className="msg"></div>
|
||||
<i className="badge">3</i>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
{channel_id && (
|
||||
<ChannelChat
|
||||
cid={channel_id}
|
||||
data={channels.find(({ gid }) => gid == channel_id)}
|
||||
/>
|
||||
)}
|
||||
{user_id && <DMChat uid={user_id} />}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
> .left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 260px;
|
||||
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
|
||||
.list {
|
||||
margin: 12px 8px;
|
||||
.title {
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
> .txt {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #78787c;
|
||||
}
|
||||
}
|
||||
> .nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
&:hover,
|
||||
&.active {
|
||||
background: rgba(116, 127, 141, 0.1);
|
||||
}
|
||||
> .txt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #1c1c1e;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
> .badge {
|
||||
padding: 3px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #bfbfbf;
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
&.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
&:hover,
|
||||
&.active {
|
||||
background: rgba(116, 127, 141, 0.1);
|
||||
}
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
/* img{
|
||||
width: 100%;
|
||||
} */
|
||||
}
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
.up {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #52525b;
|
||||
}
|
||||
time {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #78787c;
|
||||
}
|
||||
}
|
||||
.down {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.msg {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #78787c;
|
||||
}
|
||||
> .badge {
|
||||
letter-spacing: -1px;
|
||||
padding: 2px;
|
||||
color: #fff;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: #1fe1f9;
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
&.mute {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
> .right {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,92 @@
|
||||
// import React from "react";
|
||||
// import toast from "react-hot-toast";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { BsChatText } from "react-icons/bs";
|
||||
import { RiUserAddLine } from "react-icons/ri";
|
||||
import { IoShareOutline } from "react-icons/io5";
|
||||
import styled from "styled-components";
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
|
||||
import Avatar from "../../common/component/Avatar";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
margin-top: 80px;
|
||||
width: 432px;
|
||||
gap: 4px;
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
line-height: 100%;
|
||||
color: #1c1c1e;
|
||||
}
|
||||
.email {
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #78787c;
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 26px 0;
|
||||
gap: 28px;
|
||||
.icon {
|
||||
}
|
||||
}
|
||||
.line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
export default function Profile({ uid = null }) {
|
||||
const { data: contacts } = useGetContactsQuery();
|
||||
const [profile, setProfile] = useState(null);
|
||||
useEffect(() => {
|
||||
if (contacts && contacts) {
|
||||
setProfile(contacts.find((c) => c.uid == uid));
|
||||
} else {
|
||||
setProfile(null);
|
||||
}
|
||||
}, [uid, contacts]);
|
||||
if (!profile) return null;
|
||||
console.log({ profile });
|
||||
const { name, email, avatar } = profile;
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Avatar className="avatar" url={avatar} id={uid} name={name} />
|
||||
<h2 className="name">{name}</h2>
|
||||
<span className="email">{email}</span>
|
||||
<ul className="icons">
|
||||
<li className="icon chat">
|
||||
<NavLink to={`/chat/dm/${uid}`}>
|
||||
<BsChatText size={20} color="#616161" />
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="icon add">
|
||||
{/* <NavLink to={`/chat/dm/${uid}`}> */}
|
||||
<RiUserAddLine size={20} color="#616161" />
|
||||
{/* </NavLink> */}
|
||||
</li>
|
||||
<li className="icon share">
|
||||
{/* <NavLink to={`/chat/dm/${uid}`}> */}
|
||||
<IoShareOutline size={20} color="#616161" />
|
||||
{/* </NavLink> */}
|
||||
</li>
|
||||
</ul>
|
||||
<hr className="line" />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// import { useState, useEffect } from "react";
|
||||
import { NavLink, useParams } from "react-router-dom";
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
import Search from "../../common/component/Search";
|
||||
import Contact from "../../common/component/Contact";
|
||||
import Profile from "./Profile";
|
||||
|
||||
import StyledWrapper from "./styled";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const { user_id } = useParams();
|
||||
const { data: contacts } = useGetContactsQuery();
|
||||
|
||||
console.log({ contacts, user_id });
|
||||
if (!contacts) return null;
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="left">
|
||||
<Search />
|
||||
<div className="list">
|
||||
<nav className="nav">
|
||||
{contacts.map(({ uid, status }) => {
|
||||
return (
|
||||
<NavLink key={uid} className="session" to={`/contacts/${uid}`}>
|
||||
<Contact uid={uid} status={status} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{user_id && (
|
||||
<div className="right">
|
||||
<Profile uid={user_id} />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import styled from "styled-components";
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
> .left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 260px;
|
||||
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
|
||||
.list {
|
||||
margin: 12px 8px;
|
||||
> .nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.session {
|
||||
&:hover,
|
||||
&.active {
|
||||
background: rgba(116, 127, 141, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.right {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,63 @@
|
||||
// import React from 'react';
|
||||
import styled from "styled-components";
|
||||
import { AiOutlineCaretDown, AiOutlineSound } from "react-icons/ai";
|
||||
import { MdOutlineKeyboardVoice } from "react-icons/md";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 16px 12px;
|
||||
width: -webkit-fill-available;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.profile {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.toggle {
|
||||
}
|
||||
}
|
||||
.settings {
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function CurrentUser({ collaspe = false }) {
|
||||
const { user } = useSelector((store) => store.authData);
|
||||
if (!user) return null;
|
||||
const { name } = user;
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="profile" title={name}>
|
||||
<img
|
||||
title={name}
|
||||
src={`https://avatars.dicebear.com/api/adventurer-neutral/${name}.svg`}
|
||||
alt="user avatar"
|
||||
className="avatar"
|
||||
/>
|
||||
<AiOutlineCaretDown className="toggle" width={20} color="#C4C4C4" />
|
||||
</div>
|
||||
{!collaspe && (
|
||||
<div className="settings">
|
||||
<AiOutlineSound className="icon" size={15} color="#1C1C1E" />
|
||||
<MdOutlineKeyboardVoice className="icon" size={15} color="#1C1C1E" />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// import React from 'react';
|
||||
import styled from "styled-components";
|
||||
import { HiChevronDoubleLeft } from "react-icons/hi";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
|
||||
&.collaspe {
|
||||
padding-right: 5px;
|
||||
}
|
||||
.server {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
.arrow {
|
||||
cursor: pointer;
|
||||
transform-origin: center;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
display: flex;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
.icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&.collaspe {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function ServerDropList({ data, toggle, collaspe = false }) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<StyledWrapper className={collaspe ? "collaspe" : ""}>
|
||||
<div className="server">
|
||||
<div className="logo">
|
||||
<img src={data.logo} alt="logo" />
|
||||
</div>
|
||||
{!collaspe && <h2 className="title">{data.name}</h2>}
|
||||
</div>
|
||||
<div onClick={toggle} className={`arrow ${collaspe ? "collaspe" : ""}`}>
|
||||
<HiChevronDoubleLeft className="icon" color="#BFBFBF" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// import React from 'react';
|
||||
import { RiAddFill } from "react-icons/ri";
|
||||
|
||||
import styled from "styled-components";
|
||||
import { IoLogoGithub } from "react-icons/io5";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
> hr {
|
||||
border: none;
|
||||
width: 40%;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.tools {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.tool,
|
||||
.add {
|
||||
cursor: pointer;
|
||||
gap: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #4b5563;
|
||||
}
|
||||
.tool {
|
||||
.logo {
|
||||
border-radius: 5.5px;
|
||||
background: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.icon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
&.add .logo {
|
||||
background: none;
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default function Tools({ collaspe = false }) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<hr />
|
||||
<ul className="tools">
|
||||
<li className="tool">
|
||||
<div className="logo">
|
||||
<img
|
||||
className="icon"
|
||||
src="https://static.nicegoodthings.com/project/ext/webrowse.logo.png"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
{!collaspe && <h2 className="title">Webrowse</h2>}
|
||||
</li>
|
||||
<li className="tool">
|
||||
<div className="logo">
|
||||
<IoLogoGithub size={40} className="icon" />
|
||||
</div>
|
||||
{!collaspe && <h2 className="title">Github</h2>}
|
||||
</li>
|
||||
<li className="tool add">
|
||||
<div className="logo">
|
||||
<RiAddFill className="icon" size={40} color="#4B5563" />
|
||||
</div>
|
||||
{!collaspe && <h2 className="title">Add new app</h2>}
|
||||
</li>
|
||||
</ul>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// import React from 'react';
|
||||
import { useState } from "react";
|
||||
import StyledWrapper from "./styled";
|
||||
import { Outlet, NavLink } from "react-router-dom";
|
||||
import ServerDropList from "./ServerDropList";
|
||||
import Tools from "./Tools";
|
||||
import usePreload from "./usePreload";
|
||||
|
||||
import CurrentUser from "./CurrentUser";
|
||||
|
||||
import ChatIcon from "../../assets/icons/chat.svg";
|
||||
import ContactIcon from "../../assets/icons/contact.svg";
|
||||
import NotificationHub from "../../common/component/NotificationHub";
|
||||
|
||||
export default function HomePage() {
|
||||
const { data, error, success } = usePreload();
|
||||
const [collaspe, setCollaspe] = useState(false);
|
||||
const toggleCollaspe = () => {
|
||||
setCollaspe((prev) => !prev);
|
||||
};
|
||||
console.log({ data, error, success });
|
||||
return (
|
||||
<>
|
||||
<NotificationHub />
|
||||
<StyledWrapper>
|
||||
<div className={`col left ${collaspe ? "collaspe" : ""}`}>
|
||||
<ServerDropList
|
||||
data={data?.server}
|
||||
collaspe={collaspe}
|
||||
toggle={toggleCollaspe}
|
||||
/>
|
||||
<nav className="nav">
|
||||
<NavLink className="link" to={"/chat"}>
|
||||
<img src={ChatIcon} alt="chat icon" /> {!collaspe && `Chat`}
|
||||
</NavLink>
|
||||
<NavLink className="link" to={"/contacts"}>
|
||||
<img src={ContactIcon} alt="contact icon" />{" "}
|
||||
{!collaspe && `Contacts`}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Tools collaspe={collaspe} />
|
||||
<CurrentUser collaspe={collaspe} />
|
||||
</div>
|
||||
<div className="col right">
|
||||
<Outlet />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import styled from "styled-components";
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #f5f6f7;
|
||||
> .col {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&.left {
|
||||
position: relative;
|
||||
/* background: #0891B2; */
|
||||
width: 180px;
|
||||
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.5s ease-in;
|
||||
&.collaspe {
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
&.right {
|
||||
width: 100%;
|
||||
}
|
||||
> .nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 24px 8px 10px 8px;
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
padding: 10px 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #4b5563;
|
||||
border-radius: 8px;
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,39 @@
|
||||
// import React from 'react';
|
||||
import { useGetContactsQuery } from "../../app/services/contact";
|
||||
import { useGetChannelsQuery } from "../../app/services/channel";
|
||||
import { useGetServerQuery } from "../../app/services/server";
|
||||
// pollingInterval: 0,
|
||||
const querySetting = {
|
||||
refetchOnMountOrArgChange: true,
|
||||
};
|
||||
export default function usePreload() {
|
||||
const {
|
||||
isLoading: contactsLoading,
|
||||
isSuccess: contactsSuccess,
|
||||
isError: contactsError,
|
||||
data: contacts,
|
||||
} = useGetContactsQuery(undefined, querySetting);
|
||||
const {
|
||||
isLoading: serverLoading,
|
||||
isSuccess: serverSuccess,
|
||||
isError: serverError,
|
||||
data: server,
|
||||
} = useGetServerQuery(undefined, querySetting);
|
||||
const {
|
||||
isLoading: groupsLoading,
|
||||
isSuccess: groupsSuccess,
|
||||
isError: groupsError,
|
||||
data: groups,
|
||||
} = useGetChannelsQuery(undefined, querySetting);
|
||||
|
||||
return {
|
||||
loading: contactsLoading && groupsLoading && serverLoading,
|
||||
error: contactsError && groupsError && serverError,
|
||||
success: contactsSuccess && groupsSuccess && serverSuccess,
|
||||
data: {
|
||||
contacts,
|
||||
server,
|
||||
groups,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import StyledWrapper from "./styled";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLoginMutation } from "../../app/services/auth";
|
||||
import { setAuthData } from "../../app/slices/auth.data";
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigateTo = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [login] = useLoginMutation();
|
||||
const [input, setInput] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const handleLogin = async (evt) => {
|
||||
evt.preventDefault();
|
||||
console.log("wtf", input);
|
||||
const { data, error } = await login({
|
||||
...input,
|
||||
device: "web",
|
||||
device_token: "test",
|
||||
});
|
||||
if (error) {
|
||||
console.log(error);
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
toast.error("username or password incorrect");
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
dispatch(setAuthData(data));
|
||||
toast.success("login success");
|
||||
navigateTo("/");
|
||||
}
|
||||
};
|
||||
const handleInput = (evt) => {
|
||||
const { type } = evt.target.dataset;
|
||||
const { value } = evt.target;
|
||||
console.log(type, value);
|
||||
setInput((prev) => {
|
||||
prev[type] = value;
|
||||
return { ...prev };
|
||||
});
|
||||
};
|
||||
const { email, password } = input;
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="form">
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
name="email"
|
||||
value={email}
|
||||
required
|
||||
placeholder="email"
|
||||
data-type="email"
|
||||
onChange={handleInput}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
name="password"
|
||||
required
|
||||
data-type="password"
|
||||
onChange={handleInput}
|
||||
placeholder="password"
|
||||
/>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
.form{
|
||||
padding: 30px 15px;
|
||||
border: 1px solid #eee;
|
||||
form{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
input{
|
||||
padding:4px 6px
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
Reference in New Issue
Block a user