chore: merge from haodong code

This commit is contained in:
Tristan Yang
2022-06-24 10:32:04 +08:00
42 changed files with 714 additions and 382 deletions
+13 -9
View File
@@ -3,7 +3,7 @@ import { nanoid } from "@reduxjs/toolkit";
import baseQuery from "./base.query"; import baseQuery from "./base.query";
import { setAuthData, updateToken, resetAuthData, updateInitialized } from "../slices/auth.data"; import { setAuthData, updateToken, resetAuthData, updateInitialized } from "../slices/auth.data";
import BASE_URL, { KEY_DEVICE_KEY } from "../config"; import BASE_URL, { KEY_DEVICE_KEY } from "../config";
import { AuthData } from '../../types/auth'; import { AuthData } from "../../types/auth";
const getDeviceId = () => { const getDeviceId = () => {
let d = localStorage.getItem(KEY_DEVICE_KEY); let d = localStorage.getItem(KEY_DEVICE_KEY);
@@ -18,7 +18,7 @@ export const authApi = createApi({
reducerPath: "authApi", reducerPath: "authApi",
baseQuery, baseQuery,
endpoints: (builder) => ({ endpoints: (builder) => ({
login: builder.mutation({ login: builder.mutation<AuthData, string>({
query: (credentials) => ({ query: (credentials) => ({
url: "token/login", url: "token/login",
method: "POST", method: "POST",
@@ -30,17 +30,21 @@ export const authApi = createApi({
}), }),
transformResponse: (data: AuthData) => { transformResponse: (data: AuthData) => {
const { avatar_updated_at } = data.user; const { avatar_updated_at } = data.user;
data.user.avatar = return {
avatar_updated_at == 0 ...data,
? "" user: {
: `${BASE_URL}/resource/avatar?uid=${data.user.uid}&t=${avatar_updated_at}`; ...data.user,
return data; avatar:
avatar_updated_at == 0
? ""
: `${BASE_URL}/resource/avatar?uid=${data.user.uid}&t=${avatar_updated_at}`
}
};
}, },
async onQueryStarted(params, { dispatch, queryFulfilled }) { async onQueryStarted(params, { dispatch, queryFulfilled }) {
try { try {
const { data } = await queryFulfilled; const { data } = await queryFulfilled;
if (data) { if (data) {
console.log("login resp", data);
dispatch(setAuthData(data)); dispatch(setAuthData(data));
} }
} catch { } catch {
@@ -119,7 +123,7 @@ export const authApi = createApi({
}, },
url: `user/send_login_magic_link?email=${encodeURIComponent(email)}`, url: `user/send_login_magic_link?email=${encodeURIComponent(email)}`,
method: "POST", method: "POST",
responseHandler: (response) => response.text() responseHandler: (response: Response) => response.text()
// body: { email } // body: { email }
}) })
}), }),
+9 -7
View File
@@ -1,26 +1,29 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { KEY_EXPIRE, KEY_PWA_INSTALLED, KEY_REFRESH_TOKEN, KEY_TOKEN, KEY_UID } from "../config"; import { KEY_EXPIRE, KEY_PWA_INSTALLED, KEY_REFRESH_TOKEN, KEY_TOKEN, KEY_UID } from "../config";
import { AuthData, AuthToken } from "../../types/auth"; import { AuthData, AuthToken, User } from "../../types/auth";
interface State { interface State {
initialized: boolean; initialized: boolean;
uid: string | null; uid: string | null;
user: User | null;
token: string | null; token: string | null;
expireTime: string | number; // todo expireTime: number;
refreshToken: string | null; refreshToken: string | null;
} }
const initialState: State = { const initialState: State = {
initialized: true, initialized: true,
uid: null, uid: null,
user: null,
token: localStorage.getItem(KEY_TOKEN), token: localStorage.getItem(KEY_TOKEN),
expireTime: localStorage.getItem(KEY_EXPIRE) || +new Date(), expireTime: Number(localStorage.getItem(KEY_EXPIRE) || +new Date()),
refreshToken: localStorage.getItem(KEY_REFRESH_TOKEN) refreshToken: localStorage.getItem(KEY_REFRESH_TOKEN)
}; };
const emptyState: State = { const emptyState: State = {
initialized: true, initialized: true,
uid: null, uid: null,
user: null,
token: null, token: null,
expireTime: +new Date(), expireTime: +new Date(),
refreshToken: null refreshToken: null
@@ -30,20 +33,20 @@ const authDataSlice = createSlice({
name: "authData", name: "authData",
initialState, initialState,
reducers: { reducers: {
setAuthData(state, action: PayloadAction<AuthData>) { setAuthData(state, { payload }: PayloadAction<AuthData>) {
const { const {
initialized = true, initialized = true,
user: { uid }, user: { uid },
token, token,
refresh_token, refresh_token,
expired_in = 0 expired_in = 0
} = action.payload; } = payload;
state.initialized = initialized; state.initialized = initialized;
state.uid = `${uid}`; state.uid = `${uid}`;
state.user = payload.user;
state.token = token; state.token = token;
state.refreshToken = refresh_token; state.refreshToken = refresh_token;
// 当前时间往后推expire时长 // 当前时间往后推expire时长
console.log("expire", expired_in);
const expireTime = +new Date() + Number(expired_in) * 1000; const expireTime = +new Date() + Number(expired_in) * 1000;
state.expireTime = expireTime; state.expireTime = expireTime;
// set local data // set local data
@@ -53,7 +56,6 @@ const authDataSlice = createSlice({
localStorage.setItem(KEY_UID, `${uid}`); localStorage.setItem(KEY_UID, `${uid}`);
}, },
resetAuthData() { resetAuthData() {
console.log("clear auth data");
// remove local data // remove local data
localStorage.removeItem(KEY_EXPIRE); localStorage.removeItem(KEY_EXPIRE);
localStorage.removeItem(KEY_TOKEN); localStorage.removeItem(KEY_TOKEN);
+23 -23
View File
@@ -1,11 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import BASE_URL from '../config'; import BASE_URL from "../config";
import { getNonNullValues } from '../../common/utils'; import { getNonNullValues } from "../../common/utils";
import { Channel, UpdateChannelDTO, UpdatePinnedMessageDTO } from '../../types/channel'; import { Channel, UpdateChannelDTO, UpdatePinnedMessageDTO } from "../../types/channel";
interface StoredChannel extends Channel {
icon: string;
}
interface State { interface State {
ids: number[]; ids: number[];
byId: { [id: number]: Channel | undefined }; byId: { [id: number]: StoredChannel | undefined };
} }
const initialState: State = { const initialState: State = {
@@ -23,16 +27,15 @@ const channelsSlice = createSlice({
fullfillChannels(state, action: PayloadAction<Channel[]>) { fullfillChannels(state, action: PayloadAction<Channel[]>) {
const channels = action.payload || []; const channels = action.payload || [];
state.ids = channels.map(({ gid }) => gid); state.ids = channels.map(({ gid }) => gid);
state.byId = Object.fromEntries( channels.forEach((c) => {
channels.map((c) => { state.byId[c.gid] = {
const { gid, avatar_updated_at } = c; ...c,
c.icon = icon:
avatar_updated_at == 0 c.avatar_updated_at == 0
? '' ? ""
: `${BASE_URL}/resource/group_avatar?gid=${gid}&t=${avatar_updated_at}`; : `${BASE_URL}/resource/group_avatar?gid=${c.gid}&t=${c.avatar_updated_at}`
return [gid, c]; };
}) });
);
}, },
addChannel(state, action: PayloadAction<Channel>) { addChannel(state, action: PayloadAction<Channel>) {
const ch = action.payload; const ch = action.payload;
@@ -44,26 +47,23 @@ const channelsSlice = createSlice({
...ch, ...ch,
icon: icon:
avatar_updated_at == 0 avatar_updated_at == 0
? '' ? ""
: `${BASE_URL}/resource/group_avatar?gid=${gid}&t=${avatar_updated_at}` : `${BASE_URL}/resource/group_avatar?gid=${gid}&t=${avatar_updated_at}`
}; };
}, },
updateChannel(state, action: PayloadAction<UpdateChannelDTO>) { updateChannel(state, action: PayloadAction<UpdateChannelDTO>) {
const ignoreInPublic = ['add_member', 'remove_member']; const ignoreInPublic = ["add_member", "remove_member"];
const { gid, operation, members = [], ...rest } = action.payload; const { gid, operation, members = [], ...rest } = action.payload;
const currChannel = state.byId[gid]; const currChannel = state.byId[gid];
if ( if (!currChannel || (currChannel.is_public && ignoreInPublic.includes(operation))) return;
!currChannel ||
(currChannel.is_public && ignoreInPublic.includes(operation))
) return;
switch (operation) { switch (operation) {
case 'remove_member': { case "remove_member": {
state.byId[gid]!.members = state.byId[gid]!.members.filter( state.byId[gid]!.members = state.byId[gid]!.members.filter(
(id) => members.findIndex((uid) => uid == id) == -1 (id) => members.findIndex((uid) => uid == id) == -1
); );
break; break;
} }
case 'add_member': { case "add_member": {
const currs = state.byId[gid]!.members; const currs = state.byId[gid]!.members;
const _set = new Set([...currs, ...members]); const _set = new Set([...currs, ...members]);
state.byId[gid]!.members = Array.from(_set); state.byId[gid]!.members = Array.from(_set);
+40 -36
View File
@@ -1,12 +1,13 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { MuteChannel, MuteUser } from "../../types/sse";
export interface State { export interface State {
usersVersion: number; usersVersion: number;
afterMid: number; afterMid: number;
readUsers: {}; readUsers: { [uid: number]: number };
readChannels: {}; readChannels: { [gid: number]: number };
muteUsers: {}; muteUsers: { [uid: number]: { expired_at: number } | undefined };
muteChannels: {}; muteChannels: { [gid: number]: { expired_at: number } | undefined };
} }
const initialState: State = { const initialState: State = {
@@ -18,6 +19,13 @@ const initialState: State = {
muteChannels: {} muteChannels: {}
}; };
interface UpdateMutePayload {
remove_users: number[];
remove_groups: number[];
add_users: MuteUser[];
add_groups: MuteChannel[];
}
const footprintSlice = createSlice({ const footprintSlice = createSlice({
name: "footprint", name: "footprint",
initialState, initialState,
@@ -49,55 +57,51 @@ const footprintSlice = createSlice({
updateAfterMid(state, action: PayloadAction<number>) { updateAfterMid(state, action: PayloadAction<number>) {
state.afterMid = action.payload; state.afterMid = action.payload;
}, },
updateMute(state, action) { updateMute(state, action: PayloadAction<UpdateMutePayload>) {
const payload = action.payload || {}; const payload = action.payload || {};
Object.keys(payload).forEach((key) => { Object.keys(payload).forEach((key) => {
switch (key) { switch (key) {
case "remove_users": case "remove_users": {
{ const uids = payload.remove_users;
const uids = payload.remove_users; uids.forEach((id) => {
uids.forEach((id) => { delete state.muteUsers[id];
delete state.muteUsers[id]; });
});
}
break; break;
case "remove_groups": }
{ case "remove_groups": {
const gids = payload.remove_groups; const gids = payload.remove_groups;
gids.forEach((id) => { gids.forEach((id) => {
delete state.muteChannels[id]; delete state.muteChannels[id];
}); });
}
break; break;
case "add_users": }
{ case "add_users": {
const mutes = payload.add_users; const mutes = payload.add_users;
mutes.forEach(({ uid, expired_in = null }) => { mutes.forEach(({ uid, expired_at }) => {
state.muteUsers[uid] = { expired_in }; state.muteUsers[uid] = { expired_at };
}); });
}
break; break;
case "add_groups": }
{ case "add_groups": {
const mutes = payload.add_groups; const mutes = payload.add_groups;
mutes.forEach(({ gid, expired_in = null }) => { mutes.forEach(({ gid, expired_at }) => {
state.muteChannels[gid] = { expired_in }; state.muteChannels[gid] = { expired_at };
}); });
}
break; break;
}
default: default:
break; break;
} }
}); });
}, },
updateReadUsers(state, action) { updateReadUsers(state, action: PayloadAction<{ uid: number; mid: number }[]>) {
const reads = action.payload || []; const reads = action.payload || [];
if (reads.length == 0) return; if (reads.length == 0) return;
reads.forEach(({ uid, mid }) => { reads.forEach(({ uid, mid }) => {
state.readUsers[uid] = mid; state.readUsers[uid] = mid;
}); });
}, },
updateReadChannels(state, action) { updateReadChannels(state, action: PayloadAction<{ gid: number; mid: number }[]>) {
const reads = action.payload || []; const reads = action.payload || [];
if (reads.length == 0) return; if (reads.length == 0) return;
reads.forEach(({ gid, mid }) => { reads.forEach(({ gid, mid }) => {
+24 -8
View File
@@ -1,5 +1,16 @@
import { createSlice } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
const initialState = {
interface State {
name: string;
description: string;
logo: string;
inviteLink: {
link: string;
expire: number;
};
}
const initialState: State = {
name: "", name: "",
description: "", description: "",
logo: "", logo: "",
@@ -8,6 +19,7 @@ const initialState = {
expire: 0 expire: 0
} }
}; };
const serverSlice = createSlice({ const serverSlice = createSlice({
name: "server", name: "server",
initialState, initialState,
@@ -15,24 +27,28 @@ const serverSlice = createSlice({
resetServer() { resetServer() {
return initialState; return initialState;
}, },
fullfillServer(state, action) { fullfillServer(state, action: PayloadAction<State>) {
const { const {
inviteLink = { inviteLink = {
link: "", link: "",
expire: 0 expire: 0
}, },
logo = "", // todo: check missed logo property
name = "", name = "",
description = "" description = ""
} = action.payload || {}; } = action.payload || {};
return { name, description, inviteLink }; return { name, logo, description, inviteLink };
}, },
updateInfo(state, action) { updateInfo(state, action: PayloadAction<Partial<State>>) {
const values = action.payload || {}; const values = action.payload || {};
Object.keys(values).forEach((_key) => { // todo: check and remove old logic
state[_key] = values[_key]; // Object.keys(values).forEach((_key) => {
}); // state[_key] = values[_key];
// });
state = { ...state, ...values };
} }
} }
}); });
export const { updateInfo, resetServer, fullfillServer } = serverSlice.actions; export const { updateInfo, resetServer, fullfillServer } = serverSlice.actions;
export default serverSlice.reducer; export default serverSlice.reducer;
+12 -3
View File
@@ -1,10 +1,17 @@
import { useState, useEffect, memo } from "react"; import { useState, useEffect, memo, SyntheticEvent, FC } from "react";
import { getInitials, getInitialsAvatar } from "../utils"; import { getInitials, getInitialsAvatar } from "../utils";
const Avatar = ({ url = "", name = "unkonw name", type = "user", ...rest }) => { interface Props {
url?: string;
name?: string;
type?: "user" | "channel";
}
const Avatar: FC<Props> = ({ url = "", name = "unknown name", type = "user", ...rest }) => {
// console.log("avatar url", url); // console.log("avatar url", url);
const [src, setSrc] = useState(""); const [src, setSrc] = useState("");
const handleError = (err) => {
const handleError = (err: SyntheticEvent<HTMLImageElement>) => {
console.log("load avatar error", err); console.log("load avatar error", err);
const tmp = getInitialsAvatar({ const tmp = getInitialsAvatar({
initials: getInitials(name), initials: getInitials(name),
@@ -13,6 +20,7 @@ const Avatar = ({ url = "", name = "unkonw name", type = "user", ...rest }) => {
}); });
setSrc(tmp); setSrc(tmp);
}; };
useEffect(() => { useEffect(() => {
if (!url) { if (!url) {
const tmp = getInitialsAvatar({ const tmp = getInitialsAvatar({
@@ -26,6 +34,7 @@ const Avatar = ({ url = "", name = "unkonw name", type = "user", ...rest }) => {
} }
}, [url, name]); }, [url, name]);
if (!src) return null; if (!src) return null;
return <img src={src} onError={handleError} {...rest} />; return <img src={src} onError={handleError} {...rest} />;
}; };
+19 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { ChangeEvent, FC, useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import uploadIcon from "../../assets/icons/upload.image.svg?url"; import uploadIcon from "../../assets/icons/upload.image.svg?url";
@@ -61,21 +61,31 @@ const StyledWrapper = styled.div`
} }
`; `;
export default function AvatarUploader({ interface Props {
url?: string;
name?: string;
type?: "user";
disabled?: boolean;
uploadImage: (file: File) => Promise<any>;
}
const AvatarUploader: FC<Props> = ({
url = "", url = "",
name = "", name = "",
type = "user", type = "user",
uploadImage, uploadImage,
disabled = false disabled = false
}) { }) => {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const handleUpload = async (evt) => { const handleUpload = async (evt: ChangeEvent<HTMLInputElement>) => {
if (uploading) return; if (uploading) return;
const [file] = evt.target.files; if (!evt.target.files) return;
const [file] = Array.from(evt.target.files);
setUploading(true); setUploading(true);
await uploadImage(file); await uploadImage(file);
setUploading(false); setUploading(false);
}; };
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="avatar"> <div className="avatar">
@@ -87,6 +97,7 @@ export default function AvatarUploader({
multiple={false} multiple={false}
onChange={handleUpload} onChange={handleUpload}
type="file" type="file"
// todo: xss
accept="image/*" accept="image/*"
name="avatar" name="avatar"
id="avatar" id="avatar"
@@ -97,4 +108,6 @@ export default function AvatarUploader({
{!disabled && <img src={uploadIcon} alt="icon" className="icon" />} {!disabled && <img src={uploadIcon} alt="icon" className="icon" />}
</StyledWrapper> </StyledWrapper>
); );
} };
export default AvatarUploader;
+13 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { FC, useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import ChannelModal from "./ChannelModal"; import ChannelModal from "./ChannelModal";
@@ -67,7 +67,11 @@ const Styled = styled.div`
} }
`; `;
export default function BlankPlaceholder({ type = "chat" }) { interface Props {
type?: "chat";
}
const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
const server = useAppSelector((store) => store.server); const server = useAppSelector((store) => store.server);
const [inviteModalVisible, setInviteModalVisible] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [createChannelVisible, setCreateChannelVisible] = useState(false); const [createChannelVisible, setCreateChannelVisible] = useState(false);
@@ -83,7 +87,8 @@ export default function BlankPlaceholder({ type = "chat" }) {
}; };
const chatTip = const chatTip =
type == "chat" ? "Create a Channel to Start a Conversation" : "Send a Direct Message"; type == "chat" ? "Create a Channel to Start a Conversation" : "Send a Direct Message";
const chatHanlder = type == "chat" ? toggleChannelModalVisible : toggleContactListVisible; const chatHandler = type == "chat" ? toggleChannelModalVisible : toggleContactListVisible;
return ( return (
<> <>
<Styled> <Styled>
@@ -99,7 +104,7 @@ export default function BlankPlaceholder({ type = "chat" }) {
<IconInvite className="icon" /> <IconInvite className="icon" />
<div className="txt">Invite your friends or teammates</div> <div className="txt">Invite your friends or teammates</div>
</div> </div>
<div className="box" onClick={chatHanlder}> <div className="box" onClick={chatHandler}>
<IconChat className="icon" /> <IconChat className="icon" />
<div className="txt">{chatTip}</div> <div className="txt">{chatTip}</div>
</div> </div>
@@ -109,7 +114,7 @@ export default function BlankPlaceholder({ type = "chat" }) {
</a> </a>
<NavLink to={"#"} className="box"> <NavLink to={"#"} className="box">
<IconAsk className="icon" /> <IconAsk className="icon" />
<div className="txt">Got quesions? Visit our help center </div> <div className="txt">Got questions? Visit our help center </div>
</NavLink> </NavLink>
</div> </div>
</Styled> </Styled>
@@ -120,4 +125,6 @@ export default function BlankPlaceholder({ type = "chat" }) {
{inviteModalVisible && <InviteModal closeModal={toggleInviteModalVisible} />} {inviteModalVisible && <InviteModal closeModal={toggleInviteModalVisible} />}
</> </>
); );
} };
export default BlankPlaceholder;
+5 -8
View File
@@ -14,13 +14,13 @@ import { useCreateChannelMutation } from "../../../app/services/channel";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
export default function ChannelModal({ personal = false, closeModal }) { export default function ChannelModal({ personal = false, closeModal }) {
const { conactsData, loginUid } = useAppSelector((store) => { const { contactsData, loginUid } = useAppSelector((store) => {
return { conactsData: store.contacts.byId, loginUid: store.authData.uid }; return { contactsData: store.contacts.byId, loginUid: store.authData.uid };
}); });
const navigateTo = useNavigate(); const navigateTo = useNavigate();
const [data, setData] = useState({ const [data, setData] = useState({
name: "", name: "",
dsecription: "", description: "",
members: [loginUid], members: [loginUid],
is_public: !personal is_public: !personal
}); });
@@ -71,14 +71,12 @@ export default function ChannelModal({ personal = false, closeModal }) {
const { members } = data; const { members } = data;
const { uid } = currentTarget.dataset; const { uid } = currentTarget.dataset;
let tmp = members.includes(+uid) ? members.filter((m) => m != uid) : [...members, +uid]; let tmp = members.includes(+uid) ? members.filter((m) => m != uid) : [...members, +uid];
console.log(uid, currentTarget);
setData((prev) => { setData((prev) => {
return { ...prev, members: tmp }; return { ...prev, members: tmp };
}); });
console.log({ data });
}; };
console.log("contacts", contacts);
const loginUser = conactsData[loginUid]; const loginUser = contactsData[loginUid];
if (!loginUser) return null; if (!loginUser) return null;
const { name, members, is_public } = data; const { name, members, is_public } = data;
return ( return (
@@ -98,7 +96,6 @@ export default function ChannelModal({ personal = false, closeModal }) {
{contacts.map((u) => { {contacts.map((u) => {
const { uid } = u; const { uid } = u;
const checked = members.includes(uid); const checked = members.includes(uid);
console.log({ checked });
return ( return (
<li <li
key={uid} key={uid}
+55 -45
View File
@@ -1,8 +1,18 @@
import { FC, ReactElement } from "react";
import Tippy from "@tippyjs/react"; import Tippy from "@tippyjs/react";
import useContactOperation from "../../hook/useContactOperation"; import useContactOperation from "../../hook/useContactOperation";
import ContextMenu from "../ContextMenu"; import ContextMenu from "../ContextMenu";
export default function ContactContextMenu({ enable = false, uid, cid, visible, hide, children }) { interface Props {
enable?: boolean;
uid: number;
cid: number;
visible: boolean;
hide: () => void;
children: ReactElement;
}
const ContactContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, children }) => {
const { const {
canCall, canCall,
call, call,
@@ -18,48 +28,48 @@ export default function ContactContextMenu({ enable = false, uid, cid, visible,
cid cid
}); });
return ( return (
<> <Tippy
<Tippy disabled={!enable}
disabled={!enable} visible={visible}
visible={visible} followCursor={"initial"}
followCursor={"initial"} interactive
interactive placement="right-start"
placement="right-start" popperOptions={{ strategy: "fixed" }}
popperOptions={{ strategy: "fixed" }} onClickOutside={hide}
onClickOutside={hide} key={uid}
key={uid} content={
content={ <ContextMenu
<ContextMenu hideMenu={hide}
hideMenu={hide} items={[
items={[ {
{ title: "Message",
title: "Message", handler: startChat
handler: startChat },
}, canCall && {
canCall && { title: "Call",
title: "Call", handler: call
handler: call },
}, canCopyEmail && {
canCopyEmail && { title: "Copy Email",
title: "Copy Email", handler: copyEmail
handler: copyEmail },
}, canRemoveFromChannel && {
canRemoveFromChannel && { danger: true,
danger: true, title: "Remove From Channel",
title: "Remove From Channel", handler: removeFromChannel
handler: removeFromChannel },
}, canRemove && {
canRemove && { danger: true,
danger: true, title: "Remove From Server",
title: "Remove From Server", handler: removeUser
handler: removeUser }
} ]}
]} />
/> }
} >
> {children}
{children} </Tippy>
</Tippy>
</>
); );
} };
export default ContactContextMenu;
+22 -7
View File
@@ -1,3 +1,4 @@
import { FC } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Tippy from "@tippyjs/react"; import Tippy from "@tippyjs/react";
import IconOwner from "../../../assets/icons/owner.svg"; import IconOwner from "../../../assets/icons/owner.svg";
@@ -8,17 +9,29 @@ import StyledWrapper from "./styled";
import useContextMenu from "../../hook/useContextMenu"; import useContextMenu from "../../hook/useContextMenu";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
export default function Contact({ interface Props {
cid = null, cid: number;
uid: number;
owner?: number;
dm?: boolean;
interactive?: boolean;
popover?: boolean;
compact?: boolean;
avatarSize?: number;
enableContextMenu?: boolean;
}
const Contact: FC<Props> = ({
cid,
uid,
owner = false, owner = false,
dm = false, dm = false,
interactive = true, interactive = true,
uid = "",
popover = false, popover = false,
compact = false, compact = false,
avatarSize = 32, avatarSize = 32,
enableContextMenu = false enableContextMenu = false
}) { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu(); const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
const curr = useAppSelector((store) => store.contacts.byId[uid]); const curr = useAppSelector((store) => store.contacts.byId[uid]);
@@ -43,10 +56,10 @@ export default function Contact({
content={<Profile uid={uid} type="card" cid={cid} />} content={<Profile uid={uid} type="card" cid={cid} />}
> >
<StyledWrapper <StyledWrapper
onContextMenu={enableContextMenu ? handleContextMenuEvent : null}
size={avatarSize} size={avatarSize}
onDoubleClick={dm ? handleDoubleClick : null}
className={`${interactive ? "interactive" : ""} ${compact ? "compact" : ""}`} className={`${interactive ? "interactive" : ""} ${compact ? "compact" : ""}`}
onDoubleClick={dm ? handleDoubleClick : undefined}
onContextMenu={enableContextMenu ? handleContextMenuEvent : undefined}
> >
<div className="avatar"> <div className="avatar">
<Avatar url={curr.avatar} name={curr.name} alt="avatar" /> <Avatar url={curr.avatar} name={curr.name} alt="avatar" />
@@ -58,4 +71,6 @@ export default function Contact({
</Tippy> </Tippy>
</ContextMenu> </ContextMenu>
); );
} };
export default Contact;
+1 -1
View File
@@ -1,7 +1,7 @@
import { FC, MouseEvent } from "react"; import { FC, MouseEvent } from "react";
import StyledMenu from "./styled/Menu"; import StyledMenu from "./styled/Menu";
interface Item { export interface Item {
title: string; title: string;
icon: string; icon: string;
handler: (e: MouseEvent) => void; handler: (e: MouseEvent) => void;
@@ -11,8 +11,7 @@ interface Props {
} }
const DeleteMessageConfirmModal: FC<Props> = ({ closeModal, mids = [] }) => { const DeleteMessageConfirmModal: FC<Props> = ({ closeModal, mids = [] }) => {
// const dispatch = useDispatch(); const mid_arr: number[] = mids ? (Array.isArray(mids) ? mids : [mids]) : [];
const mid_arr = mids ? (Array.isArray(mids) ? mids : [mids]) : [];
const [ids] = useState(mid_arr); const [ids] = useState(mid_arr);
const { deleteMessage, isDeleting } = useDeleteMessage(); const { deleteMessage, isDeleting } = useDeleteMessage();
const handleDelete = async () => { const handleDelete = async () => {
@@ -40,7 +39,7 @@ const DeleteMessageConfirmModal: FC<Props> = ({ closeModal, mids = [] }) => {
ids.length > 1 ? "these messages" : "this message" ids.length > 1 ? "these messages" : "this message"
}?`} }?`}
> >
{ids.length == 1 && <PreviewMessage mid={ids[0]} />} {ids.length === 1 && <PreviewMessage mid={ids[0]} />}
</StyledModal> </StyledModal>
</Modal> </Modal>
); );
-1
View File
@@ -9,7 +9,6 @@ const Styled = styled.div`
export default function FAQ() { export default function FAQ() {
const { data: serverVersion } = useGetServerVersionQuery(); const { data: serverVersion } = useGetServerVersionQuery();
console.log("build time", serverVersion);
return ( return (
<Styled> <Styled>
<div className="item">Client Version: {process.env.VERSION}</div> <div className="item">Client Version: {process.env.VERSION}</div>
+31 -12
View File
@@ -1,7 +1,6 @@
// import React from 'react' import { FC, ReactElement } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useSelector } from "react-redux";
import Styled from "./styled"; import Styled from "./styled";
import { import {
VideoPreview, VideoPreview,
@@ -13,13 +12,20 @@ import {
} from "./preview"; } from "./preview";
import { getFileIcon, formatBytes } from "../../utils"; import { getFileIcon, formatBytes } from "../../utils";
import IconDownload from "../../../assets/icons/download.svg"; import IconDownload from "../../../assets/icons/download.svg";
import { useAppSelector } from "../../../app/store";
// todo // todo: move to root file
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const renderPreview = (data) => { interface Data {
file_type: string;
name: string;
content: string;
}
const renderPreview = (data: Data) => {
const { file_type, name, content } = data; const { file_type, name, content } = data;
let preview = null; let preview: null | ReactElement = null;
const checks = { const checks = {
image: /^image/gi, image: /^image/gi,
@@ -60,20 +66,31 @@ const renderPreview = (data) => {
} }
return preview; return preview;
}; };
export default function FileBox({
preview = false, interface Props {
preview?: boolean;
flex: boolean;
file_type: string;
name: string;
size: number;
created_at: number;
from_uid: number;
content: string;
}
const FileBox: FC<Props> = ({
preview,
flex, flex,
file_type = "", file_type,
name, name,
size, size,
created_at, created_at,
from_uid, from_uid,
content content
}) { }) => {
const fromUser = useSelector((store) => store.contacts.byId[from_uid]); const fromUser = useAppSelector((store) => store.contacts.byId[from_uid]);
const icon = getFileIcon(file_type, name); const icon = getFileIcon(file_type, name);
if (!content || !fromUser || !name) return null; if (!content || !fromUser || !name) return null;
console.log("file content", content, name, flex);
const previewContent = renderPreview({ file_type, content, name }); const previewContent = renderPreview({ file_type, content, name });
const withPreview = preview && previewContent; const withPreview = preview && previewContent;
return ( return (
@@ -101,4 +118,6 @@ export default function FileBox({
{withPreview && <div className="preview">{previewContent}</div>} {withPreview && <div className="preview">{previewContent}</div>}
</Styled> </Styled>
); );
} };
export default FileBox;
@@ -1,4 +1,4 @@
import { ReactEventHandler, useState } from "react"; import { FC, ReactEventHandler, useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -17,7 +17,11 @@ const Styled = styled.div`
} }
`; `;
export default function Audio({ url = "" }) { interface Props {
url: string;
}
const Audio: FC<Props> = ({ url }) => {
const [err, setErr] = useState(false); const [err, setErr] = useState(false);
const handleError: ReactEventHandler<HTMLAudioElement> = (err) => { const handleError: ReactEventHandler<HTMLAudioElement> = (err) => {
@@ -35,4 +39,6 @@ export default function Audio({ url = "" }) {
)} )}
</Styled> </Styled>
); );
} };
export default Audio;
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -12,7 +12,11 @@ const Styled = styled.div`
color: #eee; color: #eee;
`; `;
export default function Code({ url = "" }) { interface Props {
url: string;
}
const Code: FC<Props> = ({ url }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
useEffect(() => { useEffect(() => {
const getContent = async (url: string) => { const getContent = async (url: string) => {
@@ -26,4 +30,6 @@ export default function Code({ url = "" }) {
if (!content) return null; if (!content) return null;
return <Styled>{content}</Styled>; return <Styled>{content}</Styled>;
} };
export default Code;
+9 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -11,7 +11,11 @@ const Styled = styled.div`
word-break: break-all; word-break: break-all;
`; `;
export default function Doc({ url = "" }) { interface Props {
url: string;
}
const Doc: FC<Props> = ({ url }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
useEffect(() => { useEffect(() => {
const getContent = async (url: string) => { const getContent = async (url: string) => {
@@ -25,4 +29,6 @@ export default function Doc({ url = "" }) {
if (!content) return null; if (!content) return null;
return <Styled>{content}</Styled>; return <Styled>{content}</Styled>;
} };
export default Doc;
+11 -4
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, { FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -11,11 +11,18 @@ const Styled = styled.div`
} }
`; `;
export default function Image({ url = "" }) { interface Props {
url: string;
alt?: string;
}
const Image: FC<Props> = ({ url, alt }) => {
if (!url) return null; if (!url) return null;
return ( return (
<Styled> <Styled>
<img src={url} /> <img src={url} alt={alt} />
</Styled> </Styled>
); );
} };
export default Image;
+9 -3
View File
@@ -1,4 +1,5 @@
import styled from "styled-components"; import styled from "styled-components";
import { FC } from "react";
const Styled = styled.div` const Styled = styled.div`
padding: 8px; padding: 8px;
@@ -10,14 +11,17 @@ const Styled = styled.div`
} }
`; `;
export default function Pdf({ url = "" }) { interface Props {
url: string;
}
const Pdf: FC<Props> = ({ url }) => {
// const [content, setContent] = useState(""); // const [content, setContent] = useState("");
// const [pageNumber, setPageNumber] = useState(1); // const [pageNumber, setPageNumber] = useState(1);
// const [numPages, setNumPages] = useState(null); // const [numPages, setNumPages] = useState(null);
// const onDocumentLoadSuccess = ({ numPages }) => { // const onDocumentLoadSuccess = ({ numPages }) => {
// setNumPages(numPages); // setNumPages(numPages);
// }; // };
if (!url) return null;
return ( return (
<Styled> <Styled>
<embed src={url} type="application/pdf" /> <embed src={url} type="application/pdf" />
@@ -26,4 +30,6 @@ export default function Pdf({ url = "" }) {
</Document> */} </Document> */}
</Styled> </Styled>
); );
} };
export default Pdf;
@@ -1,4 +1,4 @@
import React from "react"; import React, { FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -10,11 +10,16 @@ const Styled = styled.div`
} }
`; `;
export default function Video({ url = "" }) { interface Props {
if (!url) return null; url: string;
}
const Video: FC<Props> = ({ url }) => {
return ( return (
<Styled> <Styled>
<video controls src={url} /> <video controls src={url} />
</Styled> </Styled>
); );
} };
export default Video;
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { CircularProgressbar, buildStyles } from "react-circular-progressbar"; import { CircularProgressbar, buildStyles } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css"; import "react-circular-progressbar/dist/styles.css";
@@ -33,17 +33,25 @@ const Styled = styled.div`
} }
`; `;
export default function ImageMessage({ interface Props {
uploading: boolean;
progress: number;
thumbnail: string;
download: string;
content: string;
properties: { width: number; height: number };
}
const ImageMessage: FC<Props> = ({
uploading, uploading,
progress, progress,
thumbnail, thumbnail,
download, download,
content, content,
properties = {} properties
}) { }) => {
const [url, setUrl] = useState(thumbnail); const [url, setUrl] = useState(thumbnail);
const { width = 0, height = 0 } = getDefaultSize(properties); const { width = 0, height = 0 } = getDefaultSize(properties);
console.log("image props", properties, width, height);
useEffect(() => { useEffect(() => {
const newUrl = thumbnail; const newUrl = thumbnail;
const img = new Image(); const img = new Image();
@@ -64,10 +72,7 @@ export default function ImageMessage({
<CircularProgressbar <CircularProgressbar
value={progress} value={progress}
strokeWidth={50} strokeWidth={50}
styles={buildStyles({ styles={buildStyles({ strokeLinecap: "butt" })}
storke: "#000",
strokeLinecap: "butt"
})}
/> />
</div> </div>
</div> </div>
@@ -85,4 +90,6 @@ export default function ImageMessage({
/> />
</Styled> </Styled>
); );
} };
export default ImageMessage;
+6 -4
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, FC } from "react";
import IconGithub from "../../assets/icons/github.svg"; import IconGithub from "../../assets/icons/github.svg";
import styled from "styled-components"; import styled from "styled-components";
import { KEY_LOCAL_MAGIC_TOKEN } from "../../app/config"; import { KEY_LOCAL_MAGIC_TOKEN } from "../../app/config";
@@ -17,6 +17,7 @@ const StyledSocialButton = styled(Button)`
color: #344054; color: #344054;
border: 1px solid #d0d5dd; border: 1px solid #d0d5dd;
background: none !important; background: none !important;
.icon { .icon {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -28,7 +29,7 @@ type Props = {
type?: "login" | "register"; type?: "login" | "register";
}; };
export default function GithubLoginButton({ type = "login", client_id }: Props) { const GithubLoginButton: FC<Props> = ({ type = "login", client_id }) => {
//拿本地存的magic token //拿本地存的magic token
const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN); const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN);
const [login, { isLoading, isSuccess, error }] = useLoginMutation(); const [login, { isLoading, isSuccess, error }] = useLoginMutation();
@@ -66,7 +67,7 @@ export default function GithubLoginButton({ type = "login", client_id }: Props)
} }
}, [error]); }, [error]);
const handleGithubLogin = () => { const handleGithubLogin = () => {
location.href = `http://github.com/login/oauth/authorize?client_id=${client_id}`; location.href = `https://github.com/login/oauth/authorize?client_id=${client_id}`;
// console.log("github login"); // console.log("github login");
}; };
// console.log("google login ", loaded); // console.log("google login ", loaded);
@@ -76,4 +77,5 @@ export default function GithubLoginButton({ type = "login", client_id }: Props)
{` ${type === "login" ? "Sign in" : "Sign up"} with Github`} {` ${type === "login" ? "Sign in" : "Sign up"} with Github`}
</StyledSocialButton> </StyledSocialButton>
); );
} };
export default GithubLoginButton;
+35 -10
View File
@@ -1,16 +1,16 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect, FC } from "react";
import styled, { keyframes } from "styled-components"; import styled, { keyframes } from "styled-components";
import { useOutsideClick, useKey } from "rooks"; import { useOutsideClick, useKey } from "rooks";
import { Ring } from "@uiball/loaders"; import { Ring } from "@uiball/loaders";
import Modal from "./Modal"; import Modal from "./Modal";
const AniFadeIn = keyframes` const AniFadeIn = keyframes`
from{ from {
background: transparent; background: transparent;
} }
to{ to {
background: rgba(1, 1, 1, 0.9); background: rgba(1, 1, 1, 0.9);
} }
`; `;
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
@@ -23,6 +23,7 @@ const StyledWrapper = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
.box { .box {
position: relative; position: relative;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -30,14 +31,17 @@ const StyledWrapper = styled.div`
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
> .loading { > .loading {
position: absolute; position: absolute;
top: 40%; top: 40%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
> .image { > .image {
overflow: auto; overflow: auto;
img { img {
max-width: 70vw; max-width: 70vw;
max-height: 80vh; max-height: 80vh;
@@ -46,13 +50,16 @@ const StyledWrapper = styled.div`
object-fit: contain; */ object-fit: contain; */
} }
} }
&.loading .image img { &.loading .image img {
filter: blur(2px); filter: blur(2px);
} }
.origin { .origin {
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
color: #aaa; color: #aaa;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
color: #fff; color: #fff;
@@ -61,11 +68,26 @@ const StyledWrapper = styled.div`
} }
`; `;
export default function ImagePreviewModal({ download = true, data, closeModal }) { export interface PreviewImageData {
originUrl: string;
thumbnail: string;
downloadLink: string;
name: string;
type: string;
}
interface Props {
download?: boolean;
data?: PreviewImageData;
closeModal: () => void;
}
const ImagePreviewModal: FC<Props> = ({ download = true, data, closeModal }) => {
const [url, setUrl] = useState(data?.thumbnail); const [url, setUrl] = useState(data?.thumbnail);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const wrapperRef = useRef(); const wrapperRef = useRef<HTMLDivElement>(null);
useOutsideClick(wrapperRef, closeModal); useOutsideClick(wrapperRef, closeModal);
useKey( useKey(
"Escape", "Escape",
() => { () => {
@@ -74,6 +96,7 @@ export default function ImagePreviewModal({ download = true, data, closeModal })
}, },
{ eventTypes: ["keyup"] } { eventTypes: ["keyup"] }
); );
useEffect(() => { useEffect(() => {
if (data) { if (data) {
const { originUrl } = data; const { originUrl } = data;
@@ -126,4 +149,6 @@ export default function ImagePreviewModal({ download = true, data, closeModal })
</StyledWrapper> </StyledWrapper>
</Modal> </Modal>
); );
} };
export default ImagePreviewModal;
+21 -17
View File
@@ -1,16 +1,16 @@
// import { useState, useEffect } from "react"; import { FC } from "react";
import styled, { keyframes } from "styled-components"; import styled, { keyframes } from "styled-components";
import { Ring } from "@uiball/loaders"; import { Ring } from "@uiball/loaders";
import Button from "./styled/Button"; import Button from "./styled/Button";
import useLogout from "../hook/useLogout"; import useLogout from "../hook/useLogout";
const DelayVisible = keyframes` const DelayVisible = keyframes`
from{ from {
opacity: 0; opacity: 0;
} }
to{ to {
opacity: 1; opacity: 1;
} }
`; `;
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
@@ -21,17 +21,15 @@ const StyledWrapper = styled.div`
gap: 15px; gap: 15px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&.fullscreen { &.fullscreen {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
.loading {
display: flex;
align-items: center;
justify-content: center;
}
.reload { .reload {
opacity: 0; opacity: 0;
&.visible { &.visible {
animation: ${DelayVisible} 1s forwards; animation: ${DelayVisible} 1s forwards;
animation-delay: 30s; animation-delay: 30s;
@@ -39,20 +37,26 @@ const StyledWrapper = styled.div`
} }
`; `;
export default function Loading({ reload = false, fullscreen = false }) { interface Props {
reload?: boolean;
fullscreen?: boolean;
}
const Loading: FC<Props> = ({ reload = false, fullscreen = false }) => {
const { clearLocalData } = useLogout(); const { clearLocalData } = useLogout();
const handleReload = () => { const handleReload = () => {
clearLocalData(); clearLocalData();
// todo: only firefox has argument location.reload();
location.reload(true);
}; };
return ( return (
<StyledWrapper className={fullscreen ? "fullscreen" : ""}> <StyledWrapper className={fullscreen ? "fullscreen" : ""}>
<Ring className="loading" size={40} lineWeight={5} speed={2} color="black" /> <Ring size={40} lineWeight={5} speed={2} color="black" />
<Button className={`reload danger ${reload ? "visible" : ""}`} onClick={handleReload}> <Button className={`reload danger ${reload ? "visible" : ""}`} onClick={handleReload}>
Reload Reload
</Button> </Button>
</StyledWrapper> </StyledWrapper>
); );
} };
export default Loading;
+16 -6
View File
@@ -1,11 +1,19 @@
import { useEffect, useState } from "react"; import { FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
export default function Modal({ id = "root-modal", mask = true, children }) { interface Props {
const [wrapper, setWrapper] = useState(null); id?: string;
// let eleRef = useRef(null); mask?: boolean;
children?: ReactNode;
}
// todo: check memory leak
const Modal: FC<Props> = ({ id = "root-modal", mask = true, children }) => {
const [wrapper, setWrapper] = useState<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
const modalRoot = document.getElementById(id); const modalRoot = document.getElementById(id);
if (!modalRoot) return;
if (mask) { if (mask) {
modalRoot.classList.add("mask"); modalRoot.classList.add("mask");
} }
@@ -17,7 +25,9 @@ export default function Modal({ id = "root-modal", mask = true, children }) {
modalRoot.removeChild(wrapper); modalRoot.removeChild(wrapper);
}; };
}, [id, mask]); }, [id, mask]);
if (!wrapper) return null; if (!wrapper) return null;
console.log("create portal");
return createPortal(children, wrapper); return createPortal(children, wrapper);
} };
export default Modal;
+22 -5
View File
@@ -1,3 +1,4 @@
import { FC, ReactElement } from "react";
import EmojiThumbUp from "../../assets/icons/emoji.thumb.up.svg"; import EmojiThumbUp from "../../assets/icons/emoji.thumb.up.svg";
import EmojiThumbDown from "../../assets/icons/emoji.thumb.down.svg"; import EmojiThumbDown from "../../assets/icons/emoji.thumb.down.svg";
import EmojiSmile from "../../assets/icons/emoji.smile.svg"; import EmojiSmile from "../../assets/icons/emoji.smile.svg";
@@ -7,7 +8,18 @@ import EmojiHeart from "../../assets/icons/emoji.heart.svg";
import EmojiRocket from "../../assets/icons/emoji.rocket.svg"; import EmojiRocket from "../../assets/icons/emoji.rocket.svg";
import EmojiLook from "../../assets/icons/emoji.look.svg"; import EmojiLook from "../../assets/icons/emoji.look.svg";
const Emojis = { interface Emojis {
"👍": ReactElement;
"👎": ReactElement;
"😄": ReactElement;
"👀": ReactElement;
"🚀": ReactElement;
"❤️": ReactElement;
"🙁": ReactElement;
"🎉": ReactElement;
}
const emojis: Emojis = {
"👍": <EmojiThumbUp className="emoji" />, "👍": <EmojiThumbUp className="emoji" />,
"👎": <EmojiThumbDown className="emoji" />, "👎": <EmojiThumbDown className="emoji" />,
"😄": <EmojiSmile className="emoji" />, "😄": <EmojiSmile className="emoji" />,
@@ -18,8 +30,13 @@ const Emojis = {
"🎉": <EmojiCelebrate className="emoji" /> "🎉": <EmojiCelebrate className="emoji" />
}; };
export default function ReactionItem({ native = "" }) { interface Props {
if (!native || !Emojis[native]) return null; native?: keyof Emojis;
return Emojis[native];
} }
const ReactionItem: FC<Props> = ({ native }) => {
if (!native) return null;
return emojis[native] ?? null;
};
export default ReactionItem;
+2 -2
View File
@@ -14,7 +14,7 @@ const StyledWrapper = styled.div`
background: #fff; background: #fff;
/* gap: 20px; */ /* gap: 20px; */
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
box-shadow: 0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06); box-shadow: 0 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06);
border-radius: 25px; border-radius: 25px;
.txt { .txt {
padding: 8px; padding: 8px;
@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
background: #1fe1f9; background: #1fe1f9;
border: 1px solid #1fe1f9; border: 1px solid #1fe1f9;
box-sizing: border-box; box-sizing: border-box;
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
border-radius: 25px; border-radius: 25px;
&.reset { &.reset {
background: none; background: none;
+1 -2
View File
@@ -34,11 +34,10 @@ const StyledWrapper = styled.div`
`; `;
export default function Search() { export default function Search() {
console.log("searching");
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="search"> <div className="search">
<img src={searchIcon} /> <img src={searchIcon} alt="search icon" />
<input placeholder="Search..." className="input" /> <input placeholder="Search..." className="input" />
</div> </div>
<Tooltip tip="More" placement="bottom"> <Tooltip tip="More" placement="bottom">
+1 -1
View File
@@ -68,7 +68,7 @@ export default function Server() {
<NavLink to={`/setting?f=${pathname}`}> <NavLink to={`/setting?f=${pathname}`}>
<div className="server"> <div className="server">
<div className="logo"> <div className="logo">
<img src={logo} /> <img alt="logo" src={logo} />
</div> </div>
<div className="info"> <div className="info">
<h3 className="name" title={description}> <h3 className="name" title={description}>
+15 -6
View File
@@ -1,10 +1,11 @@
import { FC, ReactElement } from "react";
import styled, { createGlobalStyle } from "styled-components"; import styled, { createGlobalStyle } from "styled-components";
import Tippy from "@tippyjs/react"; import Tippy from "@tippyjs/react";
import { roundArrow } from "tippy.js"; import { Placement, roundArrow } from "tippy.js";
import "tippy.js/dist/svg-arrow.css"; import "tippy.js/dist/svg-arrow.css";
import { updateUserGuide } from "../../../app/slices/ui"; import { updateUserGuide } from "../../../app/slices/ui";
import steps from "./steps"; import steps from "./steps";
import { useAppDispatch, useAppSelector } from "../../../app/store"; import { useAppDispatch } from "../../../app/store";
const Styled = styled.div` const Styled = styled.div`
display: flex; display: flex;
@@ -57,15 +58,21 @@ const OverrideTippyArrowColor = createGlobalStyle`
} }
`; `;
export default function UserGuide({ step = 1, placement = "right-start", delay = null, children }) { interface Props {
step?: number;
placement: Placement;
delay?: number | [number | null, number | null];
children: ReactElement;
}
const UserGuide: FC<Props> = ({ step = 1, placement = "right-start", delay, children }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { visible, step: reduxStep } = useAppSelector((store) => store.ui.userGuide);
const currStep = steps[step - 1]; const currStep = steps[step - 1];
const isLastStep = steps.length == step; const isLastStep = steps.length == step;
// if (!visible) return children; // if (!visible) return children;
if (!currStep) return null; if (!currStep) return null;
const { title, description } = currStep; const { title, description } = currStep;
const defaultDuration = [300, 250]; const defaultDuration: [number, number] = [300, 250];
const handleSkip = () => { const handleSkip = () => {
// to do // to do
dispatch(updateUserGuide({ visible: false })); dispatch(updateUserGuide({ visible: false }));
@@ -104,4 +111,6 @@ export default function UserGuide({ step = 1, placement = "right-start", delay =
</Tippy> </Tippy>
</> </>
); );
} };
export default UserGuide;
+4 -10
View File
@@ -1,4 +1,4 @@
import { FC, ReactElement } from "react"; import { FC, ReactNode } from "react";
import styled from "styled-components"; import styled from "styled-components";
const Styled = styled.div` const Styled = styled.div`
@@ -43,18 +43,12 @@ const Styled = styled.div`
interface Props { interface Props {
title?: string; title?: string;
description?: string; description?: string;
buttons: ReactElement[] | ReactElement; buttons?: ReactNode;
children?: ReactElement; children?: ReactNode;
className?: string; className?: string;
} }
const StyledModal: FC<Props> = ({ const StyledModal: FC<Props> = ({ title = "", description = "", buttons, children, ...props }) => {
title = "",
description = "",
buttons = null,
children,
...props
}) => {
return ( return (
<Styled {...props}> <Styled {...props}>
{title && <h3 className="title">{title}</h3>} {title && <h3 className="title">{title}</h3>}
+2 -3
View File
@@ -1,9 +1,8 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
// import { useDebounce } from "rooks"; // import { useDebounce } from "rooks";
function useChatScroll(deps = []) { function useChatScroll<T extends HTMLElement>() {
// todo: ref should be initialized to null const ref = useRef<T>(null);
const ref = useRef();
// useEffect(() => { // useEffect(() => {
// console.log("chat scroll", ref); // console.log("chat scroll", ref);
// if (ref.current) { // if (ref.current) {
+13 -7
View File
@@ -1,14 +1,19 @@
import { useState } from "react"; import { useState, MouseEvent, ReactElement } from "react";
import { hideAll } from "tippy.js"; import { hideAll } from "tippy.js";
import Tippy from "@tippyjs/react"; import Tippy from "@tippyjs/react";
import Menu from "../component/ContextMenu"; import Menu, { Item } from "../component/ContextMenu";
interface ContextMenuProps {
key: string | number;
children: ReactElement;
items: Item[];
}
export default function useContextMenu(placement = "right-start") { export default function useContextMenu(placement = "right-start") {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
// for tippy.js // for tippy.js
const [offset, setOffset] = useState({ x: 0, y: 0 }); const [offset, setOffset] = useState({ x: 0, y: 0 });
const handleContextMenuEvent = (evt) => { const handleContextMenuEvent = (evt: MouseEvent) => {
// console.log("context menu event", evt, evt.currentTarget);
hideAll(); hideAll();
evt.preventDefault(); evt.preventDefault();
const { currentTarget, clientX, clientY } = evt; const { currentTarget, clientX, clientY } = evt;
@@ -22,14 +27,14 @@ export default function useContextMenu(placement = "right-start") {
y = top - clientY; y = top - clientY;
} }
setOffset({ x, y }); setOffset({ x, y });
setVisible(true); setVisible(true);
console.log("offset", x, y);
}; };
const hideContextMenu = () => { const hideContextMenu = () => {
setVisible(false); setVisible(false);
}; };
const ContextMenu = ({ key, items, children }) => {
const ContextMenu = ({ key, items, children }: ContextMenuProps) => {
return ( return (
<Tippy <Tippy
visible={visible} visible={visible}
@@ -45,6 +50,7 @@ export default function useContextMenu(placement = "right-start") {
</Tippy> </Tippy>
); );
}; };
return { return {
ContextMenu, ContextMenu,
offset, offset,
+12 -6
View File
@@ -1,14 +1,20 @@
// import second from 'first'
import { useDispatch } from "react-redux";
import { removeMessage } from "../../app/slices/message"; import { removeMessage } from "../../app/slices/message";
import { removeChannelMsg } from "../../app/slices/message.channel"; import { removeChannelMsg } from "../../app/slices/message.channel";
import { removeUserMsg } from "../../app/slices/message.user"; import { removeUserMsg } from "../../app/slices/message.user";
export default function useRemoveLocalMessage({ context = "user", id = 0 }) { import { useAppDispatch } from "../../app/store";
const dispatch = useDispatch();
// todo: check usage
interface Props {
context: "user" | "channel";
id: number;
}
export default function useRemoveLocalMessage({ context = "user", id = 0 }: Props) {
const dispatch = useAppDispatch();
const removeContextMessage = context == "channel" ? removeChannelMsg : removeUserMsg; const removeContextMessage = context == "channel" ? removeChannelMsg : removeUserMsg;
const removeLocalMessage = (mid) => {
return (mid: number) => {
dispatch(removeContextMessage({ id, mid })); dispatch(removeContextMessage({ id, mid }));
dispatch(removeMessage(mid)); dispatch(removeMessage(mid));
}; };
return removeLocalMessage;
} }
+35 -10
View File
@@ -1,14 +1,34 @@
// import second from 'first' import toast from "react-hot-toast";
import { removeReplyingMessage, addReplyingMessage } from "../../app/slices/message"; import { removeReplyingMessage, addReplyingMessage } from "../../app/slices/message";
import { useSendChannelMsgMutation } from "../../app/services/channel"; import { useSendChannelMsgMutation } from "../../app/services/channel";
import { useSendMsgMutation } from "../../app/services/contact"; import { useSendMsgMutation } from "../../app/services/contact";
import { useReplyMessageMutation } from "../../app/services/message"; import { useReplyMessageMutation } from "../../app/services/message";
import { useDispatch, useSelector } from "react-redux"; import { useAppDispatch, useAppSelector } from "../../app/store";
import toast from "react-hot-toast";
export default function useSendMessage(props) { interface Props {
context: "user" | "channel";
from: number;
to: number;
}
interface SendMessagesDTO {
type: "text";
content: string;
users: number[];
channels: number[];
}
interface SendMessageDTO {
type: "text";
content: string;
properties: object;
reply_mid?: number;
}
export default function useSendMessage(props?: Props) {
const { context = "user", from = null, to = null } = props || {}; const { context = "user", from = null, to = null } = props || {};
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const stageFiles = useSelector((store) => store.ui.uploadFiles[`${context}_${to}`] || []); const stageFiles = useAppSelector((store) => store.ui.uploadFiles[`${context}_${to}`] || []);
const [replyMessage, { isError: replyErr, isLoading: replying, isSuccess: replySuccess }] = const [replyMessage, { isError: replyErr, isLoading: replying, isSuccess: replySuccess }] =
useReplyMessageMutation(); useReplyMessageMutation();
const [ const [
@@ -18,7 +38,12 @@ export default function useSendMessage(props) {
const [sendUserMsg, { isLoading: userSending, isSuccess: userSuccess, isError: userError }] = const [sendUserMsg, { isLoading: userSending, isSuccess: userSuccess, isError: userError }] =
useSendMsgMutation(); useSendMsgMutation();
const sendFn = context == "user" ? sendUserMsg : sendChannelMsg; const sendFn = context == "user" ? sendUserMsg : sendChannelMsg;
const sendMessages = async ({ type = "text", content, users = [], channels = [] }) => { const sendMessages = async ({
type = "text",
content,
users = [],
channels = []
}: SendMessagesDTO) => {
if (users.length) { if (users.length) {
for await (const uid of users) { for await (const uid of users) {
await sendUserMsg({ await sendUserMsg({
@@ -42,9 +67,9 @@ export default function useSendMessage(props) {
type = "text", type = "text",
content, content,
properties = {}, properties = {},
reply_mid = null, reply_mid,
...rest ...rest
}) => { }: SendMessageDTO) => {
if (reply_mid) { if (reply_mid) {
removeReplying(); removeReplying();
await replyMessage({ await replyMessage({
@@ -66,7 +91,7 @@ export default function useSendMessage(props) {
}); });
} }
}; };
const setReplying = (mid) => { const setReplying = (mid: number) => {
if (stageFiles.length !== 0) { if (stageFiles.length !== 0) {
toast.error("Only text is supported when replying a message"); toast.error("Only text is supported when replying a message");
return; return;
+73 -1
View File
@@ -1 +1,73 @@
/// <reference types="react-scripts" /> /// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: "development" | "production" | "test";
readonly PUBLIC_URL: string;
}
}
declare module "*.avif" {
const src: string;
export default src;
}
declare module "*.bmp" {
const src: string;
export default src;
}
declare module "*.gif" {
const src: string;
export default src;
}
declare module "*.jpg" {
const src: string;
export default src;
}
declare module "*.jpeg" {
const src: string;
export default src;
}
declare module "*.png" {
const src: string;
export default src;
}
declare module "*.webp" {
const src: string;
export default src;
}
// fix type error: TS2307: Cannot find module '...svg?url' or its corresponding type declarations.
declare module "*.svg?url" {
const src: string;
export default src;
}
declare module "*.svg" {
import * as React from "react";
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement> & { title?: string }>;
export default ReactComponent;
}
declare module "*.module.css" {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module "*.module.scss" {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module "*.module.sass" {
const classes: { readonly [key: string]: string };
export default classes;
}
+3 -3
View File
@@ -1,7 +1,8 @@
import Overview from "./Overview"; import Overview from "./Overview";
import ManageMembers from "../../common/component/ManageMembers"; import ManageMembers from "../../common/component/ManageMembers";
const useNavs = (channelId) => {
const navs = [ const useNavs = (channelId: number) => {
return [
{ {
title: "General", title: "General",
items: [ items: [
@@ -36,7 +37,6 @@ const useNavs = (channelId) => {
// ], // ],
// }, // },
]; ];
return navs;
}; };
export default useNavs; export default useNavs;
-3
View File
@@ -1,4 +1,3 @@
export interface AuthToken { export interface AuthToken {
// common // common
server_id: string; server_id: string;
@@ -7,11 +6,9 @@ export interface AuthToken {
expired_in: number; expired_in: number;
} }
// todo: check gender values
export type Gender = 0 | 1; export type Gender = 0 | 1;
export interface User { export interface User {
// avatar: string; // todo: check transform data in redux slice
uid: number; uid: number;
email: string; email: string;
name: string; name: string;
+4 -14
View File
@@ -1,18 +1,8 @@
export interface ChannelMember {}
export interface ChannelMember { export interface Message {}
} export type ContentType = "text/plain" | "text/markdown" | "rustchat/file" | "rustchat/archive";
// todo: check message fields
export interface ChannelMessage {
mid: number;
}
export type ContentType =
'text/plain' |
'text/markdown' |
'rustchat/file' |
'rustchat/archive';
export interface PinnedMessage { export interface PinnedMessage {
mid: number; mid: number;
@@ -38,7 +28,7 @@ export interface Channel {
} }
export interface UpdateChannelDTO { export interface UpdateChannelDTO {
operation: 'add_member' | 'remove_member'; operation: "add_member" | "remove_member";
members?: number[]; members?: number[];
// type = 'group_changed' // type = 'group_changed'
+4 -4
View File
@@ -1,10 +1,10 @@
import { PrecacheEntry } from "workbox-precaching/src/_types";
import localforage from "localforage";
export declare global { export declare global {
import { PrecacheEntry } from "workbox-precaching/src/_types";
import localforage from "localforage";
interface Window { interface Window {
__WB_MANIFEST: Array<PrecacheEntry | string>; __WB_MANIFEST: Array<PrecacheEntry | string>;
skipWaiting: () => void; skipWaiting: () => void;
CACHE: { [key: string]: typeof localforage }; CACHE: { [key: string]: typeof localforage | undefined };
} }
} }
+103 -63
View File
@@ -1,24 +1,24 @@
import { User } from './auth'; import { User } from "./auth";
import { Channel, ContentType } from './channel'; import { Channel, ContentType } from "./channel";
export interface ReadyEvent { export interface ReadyEvent {
type: 'ready'; type: "ready";
} }
export interface UsersSnapshotEvent { export interface UsersSnapshotEvent {
type: 'users_snapshot'; type: "users_snapshot";
users: User[]; users: User[];
version: number; version: number;
} }
// todo: check if create_by field exists // todo: check if create_by field exists
export interface UserLog extends Omit<User, 'create_by'> { export interface UserLog extends Omit<User, "create_by"> {
log_id: number; log_id: number;
action: 'create' | 'update' | 'delete'; action: "create" | "update" | "delete";
} }
export interface UsersLogEvent { export interface UsersLogEvent {
type: 'users_log'; type: "users_log";
logs: UserLog[]; logs: UserLog[];
} }
@@ -28,87 +28,126 @@ export interface UserState {
} }
export interface UsersStateEvent { export interface UsersStateEvent {
type: 'users_state'; type: "users_state";
users: UserState[]; users: UserState[];
} }
interface UsersStateChangedEvent extends UserState{ interface UsersStateChangedEvent extends UserState {
type: 'users_state_changed'; type: "users_state_changed";
} }
interface UserSettingsEvent { export interface MuteUser {
type: 'user_settings'; uid: number;
mute_users?: { uid: number; expired_at: number; }[]; expired_at: number;
mute_groups?: { gid: number; expired_at: number; }[];
read_index_users?: { uid: number; mid: number; }[];
read_index_groups?: { gid: number; mid: number; }[];
burn_after_reading_users?: { uid: number; expires_in: number; }[];
burn_after_reading_groups?: { gid: number; expires_in: number; }[];
} }
interface UserSettingsChangedEvent { export interface MuteChannel {
type: 'user_settings_changed'; gid: number;
expired_at: number;
}
export interface UserSettingsEvent {
type: "user_settings";
mute_users?: MuteUser[];
mute_groups?: MuteChannel[];
read_index_users?: { uid: number; mid: number }[];
read_index_groups?: { gid: number; mid: number }[];
burn_after_reading_users?: { uid: number; expires_in: number }[];
burn_after_reading_groups?: { gid: number; expires_in: number }[];
}
export interface UserSettingsChangedEvent {
type: "user_settings_changed";
from_device?: string; from_device?: string;
add_mute_users?: { uid: number; expired_at: number; }[]; add_mute_users?: MuteUser[];
remove_mute_users?: number[]; remove_mute_users?: number[];
add_mute_groups?: { gid: number; expired_at: number; }[]; add_mute_groups?: MuteChannel[];
remove_mute_groups?: number[]; remove_mute_groups?: number[];
read_index_users?: { uid: number; mid: number; }[]; read_index_users?: { uid: number; mid: number }[];
read_index_groups?: { gid: number; mid: number; }[]; read_index_groups?: { gid: number; mid: number }[];
burn_after_reading_users?: { uid: number; expires_in: number; }[]; burn_after_reading_users?: { uid: number; expires_in: number }[];
burn_after_reading_groups?: { gid: number; expires_in: number; }[]; burn_after_reading_groups?: { gid: number; expires_in: number }[];
} }
interface RelatedGroupsEvent { export interface RelatedGroupsEvent {
type: 'related_groups'; type: "related_groups";
groups: Channel[]; groups: Channel[];
} }
interface ChatEvent { export interface NormalMessage {
type: 'chat'; type: "normal";
properties: {};
content_type: ContentType;
content: string;
expires_in: number;
}
export interface EditReactionDetail {
properties: {};
content_type: ContentType;
content: string;
type: "edit";
}
export interface LikeReactionDetail {
action: string;
type: "like";
}
export interface DeleteReactionDetail {
type: "delete";
}
export interface ReactionMessage {
type: "reaction";
mid: number; // original message id
detail: EditReactionDetail | LikeReactionDetail | DeleteReactionDetail;
}
export interface ReplyMessage {
type: "reply";
mid: number; // original message id
properties: {};
content_type: ContentType;
content: string;
}
export interface ChatEvent {
type: "chat";
mid: number; mid: number;
from_uid: number; from_uid: number;
created_at: number; created_at: number;
target: { uid: number }; target: { uid: number };
detail: { detail: NormalMessage | ReactionMessage | ReplyMessage;
properties: {};
content: string;
content_type: ContentType;
expires_in: number;
type: 'normal';
};
} }
interface KickEvent { interface KickEvent {
type: 'kick'; type: "kick";
reason: string; reason: string;
} }
interface UserJoinedGroupEvent { interface UserJoinedGroupEvent {
type: 'user_joined_group'; type: "user_joined_group";
gid: number; gid: number;
uid: number[]; uid: number[];
} }
interface UserLeavedGroupEvent { interface UserLeavedGroupEvent {
type: 'user_leaved_group'; type: "user_leaved_group";
gid: number; gid: number;
uid: number[]; uid: number[];
} }
interface JoinedGroupEvent { interface JoinedGroupEvent {
type: 'joined_group'; type: "joined_group";
group: Channel; group: Channel;
} }
interface KickFromGroupEvent { interface KickFromGroupEvent {
type: 'kick_from_group'; type: "kick_from_group";
gid: number; gid: number;
reason: string; reason: string;
} }
interface GroupChangedEvent { interface GroupChangedEvent {
type: 'group_changed'; type: "group_changed";
gid: number; gid: number;
name: string; name: string;
description: string; description: string;
@@ -117,7 +156,7 @@ interface GroupChangedEvent {
} }
interface PinnedMessageUpdatedEvent { interface PinnedMessageUpdatedEvent {
type: 'pinned_message_updated'; type: "pinned_message_updated";
gid: number; gid: number;
mid: number; mid: number;
msg: { msg: {
@@ -127,28 +166,29 @@ interface PinnedMessageUpdatedEvent {
properties: {}; properties: {};
content: string; content: string;
content_type: ContentType; content_type: ContentType;
} };
} }
interface HeartbeatEvent { interface HeartbeatEvent {
type: 'heartbeat'; type: "heartbeat";
time: number; time: number;
} }
export type ServerEvent = ReadyEvent | export type ServerEvent =
UsersSnapshotEvent | | ReadyEvent
UsersLogEvent | | UsersSnapshotEvent
UsersStateEvent | | UsersLogEvent
UsersStateChangedEvent | | UsersStateEvent
UserSettingsEvent | | UsersStateChangedEvent
UserSettingsChangedEvent | | UserSettingsEvent
RelatedGroupsEvent | | UserSettingsChangedEvent
ChatEvent | | RelatedGroupsEvent
KickEvent | | ChatEvent
UserJoinedGroupEvent | | KickEvent
UserLeavedGroupEvent | | UserJoinedGroupEvent
JoinedGroupEvent | | UserLeavedGroupEvent
KickFromGroupEvent | | JoinedGroupEvent
GroupChangedEvent | | KickFromGroupEvent
PinnedMessageUpdatedEvent | | GroupChangedEvent
HeartbeatEvent; | PinnedMessageUpdatedEvent
| HeartbeatEvent;