feat: merge alex version

This commit is contained in:
Tristan Yang
2023-05-15 08:53:12 +08:00
parent 83023423ab
commit 67e3538588
55 changed files with 911 additions and 217 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vocechat-web",
"version": "0.3.60",
"version": "0.3.61",
"homepage": "https://voce.chat",
"dependencies": {
"@emoji-mart/react": "^1.1.1",
+8 -1
View File
@@ -40,6 +40,8 @@
"new_msg": "{{num}} new messages",
"mark_read": "Mark As Read",
"pin_chat": "Sticky on top",
"unpin_chat": "Unsticky",
"reply_msg_del": "This message has been deleted.",
@@ -54,5 +56,10 @@
"unmute": "Unmute",
"camera_on": "Turn on camera",
"camera_off": "Turn off camera",
"leave_voice": "Leave Voice Chat"
"leave_voice": "Leave Voice Chat",
"add_contact": "Add to contacts",
"block": "Block",
"unblock": "Unblock",
"contact_tip": "This user is not in your contact",
"contact_block_tip": "This user has been blocked by you"
}
+1
View File
@@ -17,6 +17,7 @@
"new_private_channel": "New Private Channel",
"new_msg": "New Message",
"invite_people": "Invite People",
"search_people": "Search People",
"create": "Create",
"open": "Open",
"update": "Update",
+8 -3
View File
@@ -13,14 +13,19 @@
"new_pwd": "New Password",
"confirm_new_pwd": "Confirm New Password",
"manage_members": "Manage Members",
"manage_members": "Manage Contacts",
"manage_tip": "Disabling your account means you can recover it at any time after taking this action.",
"admin": "Admin",
"user": "User",
"copy_email": "Copy Email",
"send_msg": "Send Message",
"remove": "Remove",
"remove": "Remove from Server",
"remove_from_contact": "Remove From Contact",
"remove_account": "Remove Account",
"remove_account_desc": "Are you sure remove this account?",
"remove_from_channel": "Remove From Channel"
"remove_from_channel": "Remove From Channel",
"search_not_found": "Not found, or this user is not allowed to be searched.",
"search_by_id": "Search by ID",
"search_by_email": "Search by Email"
}
+2 -4
View File
@@ -6,7 +6,7 @@
"nav": {
"general": "General",
"overview": "Overview",
"members": "Members",
"members": "Contacts",
"auto_delete_msg": "Auto-delete Messages",
"user": "User",
"my_account": "My Account",
@@ -21,9 +21,7 @@
"license": "License",
"about": "About",
"api_doc": "API Document",
"version": "Version",
"faq": "FAQ",
"feedback": "Feedback"
"version": "Version"
},
"channel": {
"leave": "Leave Channel",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"title": "Welcome to {{name}} server",
"desc": "Here are some steps to help you get started. For more, check out our Getting Started guide",
"desc": "Here are some steps to help you get started.",
"invite": "Invite your friends or teammates",
"start_by_channel": "Create a Channel to Start a Conversation",
"start_by_dm": "Send a Direct Message",
+8 -2
View File
@@ -41,7 +41,8 @@
"new_msg": "{{num}} 条新消息",
"mark_read": "设为已读",
"pin_chat": "置顶",
"unpin_chat": "取消置顶",
"reply_msg_del": "被回复的消息已删除!",
"file": "文件",
@@ -55,5 +56,10 @@
"unmute": "取消静音",
"camera_on": "开启摄像头",
"camera_off": "关闭摄像头",
"leave_voice": "离开"
"leave_voice": "离开",
"add_contact": "加为联系人",
"block": "屏蔽",
"unblock": "解除屏蔽",
"contact_tip": "该用户不在您的联系人列表中",
"contact_block_tip": "该用户已被你屏蔽"
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"chat": "聊天",
"members": "成员",
"members": "联系人",
"favs": "收藏",
"files": "文件",
"setting": "设置",
@@ -16,6 +16,7 @@
"new_private_channel": "新建私有频道",
"new_msg": "发消息",
"invite_people": "邀请TA人",
"search_people": "搜索用户",
"search": "搜索",
"create": "创建",
"open": "打开",
+9 -4
View File
@@ -12,14 +12,19 @@
"current_pwd": "当前密码",
"new_pwd": "新密码",
"confirm_new_pwd": "确认密码",
"manage_members": "成员管理",
"manage_tip": "请在此管理你的成员",
"manage_members": "联系人管理",
"manage_tip": "请在此管理你的联系人",
"admin": "管理员",
"user": "普通用户",
"copy_email": "复制邮箱",
"send_msg": "发消息",
"remove": "删除",
"remove": "从服务器删除",
"remove_from_contact": "从联系人中移除",
"remove_account": "注销账号",
"remove_account_desc": "确定永久注销该账号?",
"remove_from_channel": "从频道移除"
"remove_from_channel": "从频道移除",
"search_not_found": "用户不存在,或者不允许搜索",
"search_by_id": "用户ID",
"search_by_email": "用户邮箱"
}
+1 -1
View File
@@ -6,7 +6,7 @@
"nav": {
"general": "通用",
"overview": "概况",
"members": "成员",
"members": "联系人",
"auto_delete_msg": "自动删除消息",
"my_account": "我的账号",
"config": "配置",
+12 -1
View File
@@ -1,5 +1,5 @@
import localforage from "localforage";
import { User } from "../../types/user";
import { ContactStatus, User } from "../../types/user";
import clearTable from "./clear.handler";
export default async function handler({ operation, data, payload }) {
@@ -18,6 +18,17 @@ export default async function handler({ operation, data, payload }) {
await table.setItems(arr);
}
break;
case "updateContactStatus":
{
const tmp = payload as { uid: number, status: ContactStatus } | { uid: number, status: ContactStatus }[];
const arr = Array.isArray(tmp) ? tmp : [tmp];
const opts = arr.map(({ uid, status }) => {
return { key: uid + "", value: { ...data.byId[uid], status } };
});
await table.setItems(opts);
}
break;
case "updateUsersByLogs":
{
const changeLogs = payload;
+7 -2
View File
@@ -102,8 +102,13 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
}
}
break;
case 403:
toast.error("Request Not Allowed");
case 403: {
const whiteList403 = ["sendMsg"];
if (!whiteList403.includes(api.endpoint)) {
toast.error("Request Not Allowed");
}
}
break;
case 404:
{
+28
View File
@@ -117,6 +117,25 @@ export const channelApi = createApi({
}
}
}),
createPrivateInviteLink: builder.query<string, number | void>({
query: (gid) => ({
headers: {
"content-type": "text/plain",
accept: "text/plain"
},
// 七天过期
url: `/group/create_invite_private_magic_link?expired_in=604800&max_times=1&gid=${gid}`,
responseHandler: "text"
}),
transformResponse: (link: string) => {
// 确保http开头
const _link = link.startsWith("http") ? link : `http://${link}`;
// return _link;
// 替换掉域名
const invite = new URL(_link);
return `${location.origin}${invite.pathname}${invite.hash}${invite.search}`;
}
}),
removeChannel: builder.query<void, number>({
query: (id) => ({
url: `/group/${id}`,
@@ -186,6 +205,13 @@ export const channelApi = createApi({
body: members
})
}),
joinPrivateChannel: builder.mutation<Channel, { magic_token: string }>({
query: (body) => ({
url: `/user/join_private`,
method: "POST",
body
})
}),
updateIcon: builder.mutation<void, { gid: number; image: File }>({
query: ({ gid, image }) => ({
headers: {
@@ -216,6 +242,8 @@ export const {
useChangeChannelTypeMutation,
useLazyLeaveChannelQuery,
useLazyCreateInviteLinkQuery,
useJoinPrivateChannelMutation,
useLazyCreatePrivateInviteLinkQuery,
useCreateInviteLinkQuery,
useGetChannelQuery,
useLazyGetChannelQuery,
+11 -5
View File
@@ -3,7 +3,7 @@ import { batch } from "react-redux";
import { ContentTypes } from "../config";
import { addChannelMsg, removeChannelMsg } from "../slices/message.channel";
import { addUserMsg, removeUserMsg } from "../slices/message.user";
import { addMessage, removeMessage } from "../slices/message";
import { addMessage, removeMessage, updateMessage } from "../slices/message";
// import { ContentType } from "../../types/message";
export const onMessageSendStarted = async (
@@ -57,10 +57,16 @@ export const onMessageSendStarted = async (
dispatch(removeMessage(ts));
}, 300);
// dispatch(removePendingMessage({ id, mid:ts, type: from }));
} catch {
toast.error("Send Message Failed");
dispatch(removeContextMessage({ id, mid: ts }));
dispatch(removeMessage(ts));
} catch (error) {
if (error?.error?.status == 403) {
// 403 means blocked
// toast.error(`Failed to send, blocked maybe.`,);
dispatch(updateMessage({ mid: ts, failed: true }));
} else {
toast.error(`Send Message Failed ${JSON.stringify(error)}`,);
dispatch(removeContextMessage({ id, mid: ts }));
dispatch(removeMessage(ts));
}
// patchResult.undo();
}
};
+83 -8
View File
@@ -2,18 +2,19 @@ import { createApi } from "@reduxjs/toolkit/query/react";
// import toast from "react-hot-toast";
import baseQuery from "./base.query";
import { updateAutoDeleteSetting, updateMute } from "../slices/footprint";
import { fillUsers } from "../slices/users";
import { fillUsers, updateContactStatus as updateStatus } from "../slices/users";
import BASE_URL, { ContentTypes } from "../config";
import { onMessageSendStarted } from "./handlers";
import { AutoDeleteMsgDTO, BotAPIKey, User, UserCreateDTO, UserDTO, UserForAdmin, UserForAdminDTO } from "../../types/user";
import { AutoDeleteMsgDTO, BotAPIKey, ContactAction, ContactResponse, ContactStatus, User, UserCreateDTO, UserDTO, UserForAdmin, UserForAdminDTO } from "../../types/user";
import { ContentTypeKey, MuteDTO } from "../../types/message";
import { RootState } from "../store";
export const userApi = createApi({
reducerPath: "userApi",
baseQuery,
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => ({ url: `user` }),
query: () => ({ url: `/user` }),
transformResponse: (data: User[]) => {
return data.map((user) => {
return {
@@ -25,12 +26,38 @@ export const userApi = createApi({
};
});
},
async onQueryStarted(data, { dispatch, queryFulfilled, getState }) {
try {
const { data: users } = await queryFulfilled;
const { authData: { user: loginUser } } = getState() as RootState;
dispatch(fillUsers(users.map((u) => {
const status = loginUser?.uid == u.uid ? "added" : "";
return {
...u,
status
};
})));
} catch {
console.log("get user list error");
}
}
}),
getContacts: builder.query<ContactResponse[], void>({
query: () => ({ url: `/user/contacts` }),
async onQueryStarted(data, { dispatch, queryFulfilled }) {
try {
const { data: users } = await queryFulfilled;
dispatch(fillUsers(users));
const payloads = users.map((c) => {
const uid = c.target_uid;
const status = c.contact_info.status;
return {
uid,
status
};
});
dispatch(updateStatus(payloads));
} catch {
console.log("get user list error");
console.log("get contact list error");
}
}
}),
@@ -44,6 +71,27 @@ export const userApi = createApi({
method: "POST"
})
}),
searchUser: builder.mutation<User, { search_type: "id" | "email", keyword: string }>({
query: (input) => ({
url: `/user/search`,
body: input,
method: "POST"
})
}),
pinChat: builder.mutation<void, { uid: number } | { gid: number }>({
query: (data) => ({
url: `/user/pin_chat`,
method: "POST",
body: { target: data }
})
}),
unpinChat: builder.mutation<void, { uid: number } | { gid: number }>({
query: (data) => ({
url: `/user/unpin_chat`,
method: "POST",
body: { target: data }
})
}),
updateUser: builder.mutation<UserForAdmin, UserForAdminDTO>({
query: ({ id, ...rest }) => ({
url: `/admin/user/${id}`,
@@ -75,6 +123,28 @@ export const userApi = createApi({
}
}),
updateContactStatus: builder.mutation<void, { action: ContactAction, target_uid: number }>({
query: (payload) => ({
url: `/user/update_contact_status`,
method: "POST",
body: payload
}),
async onQueryStarted(data, { dispatch, queryFulfilled }) {
const map = {
"add": "added",
"block": "blocked",
"remove": "",
"unblock": "",
};
try {
await queryFulfilled;
const status = map[data.action] as ContactStatus;
dispatch(updateStatus({ uid: data.target_uid, status }));
} catch (error) {
console.log("update mute failed", error);
}
}
}),
updateMuteSetting: builder.mutation<void, MuteDTO>({
query: (data) => ({
url: `/user/mute`,
@@ -152,6 +222,7 @@ export const userApi = createApi({
body: type == "file" ? JSON.stringify(content) : content
}),
async onQueryStarted(param1, param2) {
// @ts-ignore
await onMessageSendStarted.call(this, param1, param2, "user");
}
}),
@@ -160,6 +231,7 @@ export const userApi = createApi({
});
export const {
useLazyGetUsersQuery,
useGetUserByAdminQuery,
useUpdateAvatarByAdminMutation,
useUpdateAutoDeleteMsgMutation,
@@ -169,10 +241,13 @@ export const {
useLazyDeleteUserQuery,
useUpdateInfoMutation,
useUpdateAvatarMutation,
useGetUsersQuery,
useLazyGetUsersQuery,
useLazyGetContactsQuery,
useSendMsgMutation,
useCreateBotAPIKeyMutation,
useLazyDeleteBotAPIKeyQuery,
useGetBotAPIKeysQuery
useGetBotAPIKeysQuery,
useSearchUserMutation,
useUpdateContactStatusMutation,
usePinChatMutation,
useUnpinChatMutation
} = userApi;
+1 -1
View File
@@ -53,7 +53,7 @@ const authDataSlice = createSlice({
const { initialized = true, user, token, refresh_token, expired_in = 0 } = payload;
const { uid, create_by } = user;
state.initialized = initialized;
state.user = { ...state.user, ...user };
state.user = { ...state.user, ...user, status: "added" };
state.guest = create_by == "guest";
state.token = token;
state.refreshToken = refresh_token;
+27 -5
View File
@@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { MuteDTO } from "../../types/message";
import { OG } from "../../types/resource";
import { AutoDeleteMessageSettingDTO, AutoDeleteMsgForGroup, AutoDeleteMsgForUser, AutoDeleteSettingForChannels, AutoDeleteSettingForUsers } from "../../types/sse";
import { AutoDeleteMessageSettingDTO, AutoDeleteMsgForGroup, AutoDeleteMsgForUser, AutoDeleteSettingForChannels, AutoDeleteSettingForUsers, PinChat, PinChatTarget } from "../../types/sse";
import { resetAuthData } from "./auth.data";
type ChannelAside = "members" | "voice" | "voice_fullscreen" | null;
@@ -20,6 +20,7 @@ export interface State {
autoDeleteMsgChannels: AutoDeleteMsgForGroup[];
channelAsides: { [cid: number]: ChannelAside };
dmAsides: { [uid: number]: DMAside };
pinChats: PinChat[];
}
@@ -36,7 +37,8 @@ const initialState: State = {
autoDeleteMsgUsers: [],
autoDeleteMsgChannels: [],
channelAsides: {},
dmAsides: {}
dmAsides: {},
pinChats: []
};
const footprintSlice = createSlice({
@@ -60,7 +62,8 @@ const footprintSlice = createSlice({
autoDeleteMsgUsers = [],
autoDeleteMsgChannels = [],
channelAsides = {},
dmAsides = {}
dmAsides = {},
pinChats = []
} = action.payload;
return {
og,
@@ -75,7 +78,8 @@ const footprintSlice = createSlice({
autoDeleteMsgUsers,
autoDeleteMsgChannels,
channelAsides,
dmAsides
dmAsides,
pinChats
};
},
updateUsersVersion(state, action: PayloadAction<number>) {
@@ -164,6 +168,22 @@ const footprintSlice = createSlice({
}
});
},
upsertPinChats(state, action: PayloadAction<{ pins: PinChat[], override?: boolean }>) {
const { pins, override = false } = action.payload;
if (override) {
state.pinChats = pins;
} else {
state.pinChats = [...pins, ...state.pinChats];
}
},
removePinChats(state, action: PayloadAction<PinChatTarget[]>) {
const pins = action.payload;
state.pinChats = state.pinChats.filter(pin => {
const key = "uid" in pin.target ? "uid" : "gid";
// @ts-ignore
return !pins.some(p => p[key] == pin.target[key]);
});
},
upsertOG(state, action: PayloadAction<{ key: string, value: OG }>) {
const { key, value } = action.payload;
state.og[key] = value;
@@ -228,7 +248,9 @@ export const {
updateHistoryMark,
upsertOG,
updateChannelVisibleAside,
updateDMVisibleAside
updateDMVisibleAside,
upsertPinChats,
removePinChats
} = footprintSlice.actions;
export default footprintSlice.reducer;
+1
View File
@@ -22,6 +22,7 @@ export interface MessagePayload {
thumbnail?: string;
edited?: boolean | number;
reply_mid?: number;
failed?: boolean;
}
export interface State {
[key: number]: MessagePayload;
+2 -4
View File
@@ -9,8 +9,6 @@ export interface StoredServer extends Server {
link: string;
expire: number;
};
show_user_online_status: boolean,
webclient_auto_update: boolean
}
const initialState: StoredServer = {
version: "",
@@ -23,7 +21,7 @@ const initialState: StoredServer = {
expire: 0
},
show_user_online_status: false,
webclient_auto_update: true
webclient_auto_update: true,
};
const serverSlice = createSlice({
@@ -45,7 +43,7 @@ const serverSlice = createSlice({
name = "",
description = "",
show_user_online_status = true,
webclient_auto_update = true
webclient_auto_update = true,
} = action.payload || {};
return { version, upgraded, name, logo, description, inviteLink, show_user_online_status, webclient_auto_update };
},
+12 -4
View File
@@ -1,11 +1,11 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { isNull, omitBy } from "lodash";
import BASE_URL from "../config";
import { User } from "../../types/user";
import { Contact, ContactStatus } from "../../types/user";
import { UserLog, UserState } from "../../types/sse";
type DMAside = "voice" | null;
export interface StoredUser extends User {
export interface StoredUser extends Contact {
online?: boolean;
voice?: boolean;
avatar?: string;
@@ -16,7 +16,6 @@ export interface State {
ids: number[];
byId: { [id: number]: StoredUser };
}
const initialState: State = {
ids: [],
byId: {}
@@ -69,6 +68,7 @@ const usersSlice = createSlice({
? ""
: `${BASE_URL}/resource/avatar?uid=${uid}&t=${rest.avatar_updated_at}`,
create_by: "", // todo: missing properties create_by
status: state.byId[uid]?.status ?? "",
...rest
};
const idx = state.ids.findIndex((i) => i == uid);
@@ -98,9 +98,17 @@ const usersSlice = createSlice({
state.byId[uid]!.online = online;
}
});
},
updateContactStatus(state, action: PayloadAction<{ uid: number, status: ContactStatus } | { uid: number, status: ContactStatus }[]>) {
const arr = Array.isArray(action.payload) ? action.payload : [action.payload];
arr.forEach((data) => {
if (state.byId[data.uid]) {
state.byId[data.uid]!.status = data.status;
}
});
}
}
});
export const { resetUsers, fillUsers, updateUsersByLogs, updateUsersStatus } = usersSlice.actions;
export const { updateContactStatus, resetUsers, fillUsers, updateUsersByLogs, updateUsersStatus } = usersSlice.actions;
export default usersSlice.reducer;
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#101828" xmlns="http://www.w3.org/2000/svg">
<path d="M4.93 4.93L19.07 19.07M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" stroke="#088AB2" fill="white" xmlns="http://www.w3.org/2000/svg">
<svg width="20" height="20" viewBox="0 0 20 20" stroke="#088AB2" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1130_82786)">
<path d="M9.99984 13.3334V10M9.99984 6.66669H10.0082M18.3332 10C18.3332 14.6024 14.6022 18.3334 9.99984 18.3334C5.39746 18.3334 1.6665 14.6024 1.6665 10C1.6665 5.39765 5.39746 1.66669 9.99984 1.66669C14.6022 1.66669 18.3332 5.39765 18.3332 10Z" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</g>

Before

Width:  |  Height:  |  Size: 575 B

After

Width:  |  Height:  |  Size: 574 B

+19 -1
View File
@@ -3,18 +3,21 @@ import { hideAll } from "tippy.js";
import { useAppSelector } from "../../app/store";
import IconInvite from "../../assets/icons/add.person.svg";
import IconMention from "../../assets/icons/mention.svg";
import IconSearch from "../../assets/icons/search.svg";
import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal";
import UsersModal from "./UsersModal";
import InviteModal from "./InviteModal";
import { useTranslation } from "react-i18next";
import SearchUser from "./SearchUser";
import ServerVersionChecker from "./ServerVersionChecker";
export default function AddEntriesMenu() {
const { t } = useTranslation();
const currentUser = useAppSelector((store) => store.authData.user);
const [isPrivate, setIsPrivate] = useState(false);
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [searchModalVisible, setSearchModalVisible] = useState(false);
const [channelModalVisible, setChannelModalVisible] = useState(false);
const [usersModalVisible, setUsersModalVisible] = useState(false);
const toggleInviteModalVisible = () => {
@@ -25,6 +28,14 @@ export default function AddEntriesMenu() {
return !prev;
});
};
const toggleSearchModalVisible = () => {
setSearchModalVisible((prevVisible) => {
if (!prevVisible) {
hideAll();
}
return !prevVisible;
});
};
const toggleUsersModalVisible = () => {
setUsersModalVisible((prevVisible) => {
if (!prevVisible) {
@@ -66,10 +77,17 @@ export default function AddEntriesMenu() {
<IconInvite className={iconClass} />
{t("action.invite_people")}
</li>
<ServerVersionChecker version="0.3.7" empty={true}>
<li className={itemClass} onClick={toggleSearchModalVisible}>
<IconSearch className={iconClass} />
{t("action.search_people")}
</li>
</ServerVersionChecker>
</ul>
{channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />}
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
{inviteModalVisible && <InviteModal closeModal={toggleInviteModalVisible} />}
{searchModalVisible && <SearchUser closeModal={toggleSearchModalVisible} />}
</>
);
}
+6 -2
View File
@@ -22,12 +22,13 @@ import useMessageOperation from "./useMessageOperation";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
type Props = {
isSelf: boolean;
context: "user" | "channel";
contextId: number;
mid: number;
toggleEditMessage: () => void;
};
const Commands: FC<Props> = ({ context = "user", contextId = 0, mid = 0, toggleEditMessage }) => {
const Commands: FC<Props> = ({ isSelf, context = "user", contextId = 0, mid = 0, toggleEditMessage }) => {
const { t } = useTranslation();
const {
canDelete,
@@ -87,7 +88,10 @@ const Commands: FC<Props> = ({ context = "user", contextId = 0, mid = 0, toggleE
<>
<ul
ref={cmdsRef}
className={clsx(`bg-white dark:bg-gray-900 rounded-md z-[999] absolute right-2.5 top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`, tippyVisible && '!visible')}>
className={clsx(`bg-white dark:bg-gray-900 rounded-md z-[999] absolute top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`,
tippyVisible && '!visible',
isSelf ? "left-2.5" : "right-2.5"
)}>
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
+18 -8
View File
@@ -17,6 +17,7 @@ import useContextMenu from "../../hook/useContextMenu";
import usePinMessage from "../../hook/usePinMessage";
import { useAppSelector } from "../../../app/store";
import ExpireTimer from "./ExpireTimer";
import IconInfo from '../../../assets/icons/info.svg';
interface IProps {
readOnly?: boolean;
@@ -39,11 +40,12 @@ const Message: FC<IProps> = ({
const [edit, setEdit] = useState(false);
const avatarRef = useRef(null);
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
const { message, reactionMessageData, usersData } = useAppSelector((store) => {
const { message, reactionMessageData, usersData, loginUid } = useAppSelector((store) => {
return {
reactionMessageData: store.reactionMessage,
message: store.message[mid],
usersData: store.users.byId
usersData: store.users.byId,
loginUid: store.authData.user?.uid
};
});
@@ -75,7 +77,9 @@ const Message: FC<IProps> = ({
content_type = "text/plain",
edited,
properties,
expires_in = 0
expires_in = 0,
failed = false
} = message;
@@ -90,6 +94,7 @@ const Message: FC<IProps> = ({
// return null;
const _key = properties?.local_id || mid;
const showExpire = (expires_in ?? 0) > 0;
const isSelf = fromUid == loginUid;
return (
<div
@@ -100,7 +105,8 @@ const Message: FC<IProps> = ({
className={clsx(`group w-full relative flex items-start gap-2 md:gap-4 p-1 md:p-2 my-2 rounded-lg md:dark:hover:bg-gray-800 md:hover:bg-gray-100`,
readOnly && "hover:bg-transparent",
showExpire && "bg-red-200 dark:bg-red-200/40",
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7"
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7",
isSelf && "flex-row-reverse",
)}
>
<Tippy
@@ -121,18 +127,18 @@ const Message: FC<IProps> = ({
context={context}
contextId={contextId}
mid={mid}
visible={contextMenuVisible}
visible={contextMenuVisible && !failed}
hide={hideContextMenu}
>
<div
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative")}
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative", isSelf && "items-end")}
data-pin-tip={`pinned by ${pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
}`}
>
{pinInfo && <span className="absolute left-0 -top-1 -translate-y-full text-xs text-gray-400">
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
</span>}
<div className="flex items-center gap-2 font-semibold">
<div className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}>
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
<Tooltip
delay={200}
@@ -146,6 +152,9 @@ const Message: FC<IProps> = ({
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</time>
</Tooltip>
{failed && <span className="text-red-500 text-xs flex items-center gap-1">
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
</span>}
</div>
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
sending && "opacity-90",
@@ -181,8 +190,9 @@ const Message: FC<IProps> = ({
createAt={time ?? 0}
/>
)}
{!edit && !readOnly && (
{!edit && !failed && !readOnly && (
<Commands
isSelf={isSelf}
context={context}
contextId={contextId}
mid={mid}
+57 -58
View File
@@ -1,4 +1,4 @@
import { useRef, useEffect, useState, useCallback, ClipboardEvent, FC } from "react";
import { useRef, useEffect, ClipboardEvent, FC } from "react";
import { useKey } from "rooks";
import { Editor, Transforms } from "slate";
import {
@@ -37,6 +37,7 @@ type Props = {
id: `${ctx}_${number}`;
placeholder: string;
sendMessages: any;
updateMessages: any;
members: number[];
};
const Plugins: FC<Props> = ({
@@ -45,6 +46,7 @@ const Plugins: FC<Props> = ({
id,
placeholder = "Write some markdown...",
sendMessages,
updateMessages,
members = []
}) => {
@@ -53,7 +55,6 @@ const Plugins: FC<Props> = ({
const { addStageFile } = useUploadFile({ context, id: to });
const enableMentions = members.length > 0;
const userData = useAppSelector((store) => store.users.byId);
const [msgs, setMsgs] = useState([]);
const editableRef = useRef(null);
const initialProps = {
...CONFIG.editableProps,
@@ -94,23 +95,13 @@ const Plugins: FC<Props> = ({
}
evt.preventDefault();
// return true;
handleSend();
sendMessages();
},
{
when: !isMobile(),
target: editableRef,
}
);
const handleSend = () => {
sendMessages(msgs);
// 清空
Transforms.delete(plateEditor, {
at: {
anchor: Editor.start(plateEditor, []),
focus: Editor.end(plateEditor, [])
}
});
};
const pluginArr = [
createParagraphPlugin(),
createNodeIdPlugin(),
@@ -142,55 +133,52 @@ const Plugins: FC<Props> = ({
}
);
const handleChange = useCallback(
async (val: any) => {
// console.log("tmps changed", val);
const tmps = [];
const getMixedText = (children: any) => {
const mentions: any = [];
const arr = children.map(({ type, text, uid }: any) => {
if (type == "mention") {
mentions.push(uid);
return ` @${uid} `;
}
return text;
});
return { value: arr.join(""), mentions };
};
for (const v of val) {
// p
const { value, mentions } = getMixedText(v.children);
const prev = tmps[tmps.length - 1];
if (!prev) {
tmps.push([{ type: "text", content: value, properties: { mentions } }]);
const handleChange = (val: any) => {
// console.log("tmps changed", val);
const tmps = [];
const getMixedText = (children: any) => {
const mentions: any = [];
const arr = children.map(({ type, text, uid }: any) => {
if (type == "mention") {
mentions.push(uid);
return ` @${uid} `;
}
return text;
});
return { value: arr.join(""), mentions };
};
for (const v of val) {
// p
const { value, mentions } = getMixedText(v.children);
const prev = tmps[tmps.length - 1];
if (!prev) {
tmps.push([{ type: "text", content: value, properties: { mentions } }]);
} else {
if (Array.isArray(prev)) {
tmps[tmps.length - 1].push({
type: "text",
content: value,
properties: { mentions }
});
} else {
if (Array.isArray(prev)) {
tmps[tmps.length - 1].push({
type: "text",
content: value,
properties: { mentions }
});
} else {
tmps.push([{ type: "text", content: value, properties: { mentions } }]);
}
tmps.push([{ type: "text", content: value, properties: { mentions } }]);
}
}
const arr = tmps.map((tmp) => {
return Array.isArray(tmp)
? {
type: "text",
content: tmp.map((t) => t.content).join("\n"),
properties: {
mentions: tmp.map((t) => t.properties?.mentions || []).flat()
}
}
const arr = tmps.map((tmp) => {
return Array.isArray(tmp)
? {
type: "text",
content: tmp.map((t) => t.content).join("\n"),
properties: {
mentions: tmp.map((t) => t.properties?.mentions || []).flat()
}
: tmp;
});
const msgs = arr.filter(({ content }) => !!content);
setMsgs(msgs);
},
[msgs]
);
}
: tmp;
});
const msgs = arr.filter(({ content }) => !!content.trim());
updateMessages(msgs);
};
return (
@@ -245,7 +233,18 @@ export const useMixedEditor = (key: string) => {
editorRef.insertText(txt);
}
};
const resetInput = () => {
if (!editorRef) return;
// 清空
Transforms.delete(editorRef, {
at: {
anchor: Editor.start(editorRef, []),
focus: Editor.end(editorRef, [])
}
});
};
return {
resetInput,
focus,
insertText
};
+14
View File
@@ -0,0 +1,14 @@
import { FC, ReactElement } from "react";
import useTabBroadcast from "../hook/useTabBroadcast";
import InactiveScreen from "./InactiveScreen";
interface Props {
children: ReactElement;
}
const RequireSingleTab: FC<Props> = ({ children }) => {
const { tabActive } = useTabBroadcast();
// console.log("authhhhh", allowGuest, token, guest);
return tabActive ? children : <InactiveScreen />;
};
export default RequireSingleTab;
+108
View File
@@ -0,0 +1,108 @@
// import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { FC, ChangeEvent, useState, useRef, FormEvent } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import IconClose from "../../assets/icons/close.svg";
import IconSearch from "../../assets/icons/search.svg";
import { useSearchUserMutation, useUpdateContactStatusMutation } from "../../app/services/user";
import StyledButton from "./styled/Button";
import Input from "./styled/Input";
import Avatar from "./Avatar";
import BASE_URL from "../../app/config";
import { useAppSelector } from "../../app/store";
import { useNavigate } from "react-router-dom";
type Props = {
closeModal: () => void;
};
type Type = "id" | "email";
const SearchUser: FC<Props> = ({ closeModal }) => {
const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation();
const usersData = useAppSelector(store => store.users.byId);
const { t } = useTranslation();
const navigateTo = useNavigate();
const inputRef = useRef(null);
const [type, setType] = useState<Type>("id");
const [input, setInput] = useState({
id: "",
email: ""
});
const [searchUser, { data, isSuccess, isLoading, reset }] = useSearchUserMutation();
const handleInput = (evt: ChangeEvent<HTMLInputElement>) => {
const tmp = {
[type]: evt.target.value
};
setInput(prev => ({ ...prev, ...tmp }));
};
const resetInput = () => {
reset();
setInput(prev => ({ ...prev, [type]: "" }));
};
const handleSubmit = (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
if (!inputRef.current) return;
const form = inputRef.current as HTMLFormElement;
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const currInput = input[type];
// if (!currInput) return;
// return true;
searchUser({
search_type: type,
keyword: currInput
});
};
const handleChangeKeyword = (type: Type) => {
setType(type);
};
const handleChat = async (directChat: boolean) => {
if (!data) return;
if (!directChat) {
await updateContactStatus({ target_uid: data.uid, action: "add" });
}
closeModal();
navigateTo(`/chat/dm/${data.uid}`);
};
// const tmpData = { "avatar_updated_at": 0, "birthday": 0, "create_by": "password", "email": "tristan@alex.com", "gender": 0, "is_admin": true, "is_bot": false, "language": "en-US", "name": "Admin", "uid": 1 };
// const showResult = isSuccess && data;
const inContact = Boolean(data && usersData[data.uid] && usersData[data.uid].status == "added");
const inputType = type == "id" ? "number" : "email";
return (
<Modal>
<div className=" relative flex flex-col gap-2 w-96 px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-900 text-slate-900 dark:text-slate-100">
<div className="flex items-center gap-2 py-2">
<StyledButton className={clsx("mini", type == "email" && "ghost !border-none !shadow-none")} onClick={handleChangeKeyword.bind(null, "id")}>
{t("search_by_id", { ns: "member" })}
</StyledButton>
<StyledButton className={clsx("mini", type == "id" && "ghost !border-none !shadow-none")} onClick={handleChangeKeyword.bind(null, "email")}>
{t("search_by_email", { ns: "member" })}
</StyledButton>
</div>
<form className="w-full" ref={inputRef} action="/" onSubmit={handleSubmit}>
<Input required type={inputType} className="none" disabled={isLoading} prefix={<IconSearch className="dark:fill-gray-400 w-6 h-6 shrink-0" />} value={input[type]} placeholder={`${t("action.search")}...`} onChange={handleInput} />
</form>
<div className="min-h-[280px] flex-center pb-10">
{isSuccess ? (data ? <div className="flex flex-col items-center pt-10">
<Avatar className="rounded-full" src={data.avatar_updated_at === 0 ? "" : `${BASE_URL}/resource/avatar?uid=${data.uid}&t=${data.avatar_updated_at}`} name={data.name} width={120} height={120} />
<span className="my-2 dark:text-gray-100 text-gray-950">{data.name}</span>
<div className="flex gap-2 my-2">
<StyledButton className="mini ghost" onClick={resetInput}>{t("action.cancel")}</StyledButton>
<StyledButton disabled={adding} onClick={handleChat.bind(null, inContact)} className="mini">{inContact ? t(`chat`) : `Add to Contact`}</StyledButton>
</div>
</div> : <div className="w-full h-full text-center flex flex-col gap-3 items-center">
<span className="text-sm text-gray-800 dark:text-gray-200">
{t("search_not_found", { ns: "member" })}
</span>
<StyledButton className="mini" onClick={resetInput}>Ok</StyledButton>
</div>) : null}
</div>
<IconClose role="button" className="absolute top-2 right-2 dark:fill-white w-5 h-5" onClick={closeModal} />
</div>
</Modal>
);
};
export default SearchUser;
+11 -2
View File
@@ -2,20 +2,25 @@ import { ChangeEvent, useRef, FC } from "react";
import Tooltip from "../Tooltip";
import AddIcon from "../../../assets/icons/add.solid.svg";
import MarkdownIcon from "../../../assets/icons/markdown.svg";
import SendIcon from "../../../assets/icons/send.svg";
import FullscreenIcon from "../../../assets/icons/fullscreen.svg";
import ExitFullscreenIcon from "../../../assets/icons/fullscreen.exit.svg";
import useUploadFile from "../../hook/useUploadFile";
import { useTranslation } from "react-i18next";
type Props = {
sendMessages: () => void;
toggleMarkdownFullscreen: () => void;
fullscreen: boolean;
toggleMode: () => void;
mode: "markdown" | "text";
to: number;
context: "user" | "channel";
sendVisible: boolean
};
const Toolbar: FC<Props> = ({
sendMessages,
sendVisible,
toggleMarkdownFullscreen,
fullscreen,
toggleMode,
@@ -61,7 +66,7 @@ const Toolbar: FC<Props> = ({
</Tooltip>
))}
</div>
{!isMarkdown && <Tooltip placement="top" tip={t("action.upload")}>
{!isMarkdown && <><Tooltip placement="top" tip={t("action.upload")}>
<div className="cursor-pointer relative w-6 h-6">
<AddIcon className="dark:fill-gray-300" />
<label htmlFor="file" className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0">
@@ -77,7 +82,11 @@ const Toolbar: FC<Props> = ({
/>
</label>
</div>
</Tooltip>}
</Tooltip>
{sendVisible && <Tooltip placement="top" tip="Send">
<SendIcon className={"w-6 h-6 cursor-pointer animate-zoomIn dark:stroke-gray-300"} onClick={sendMessages} />
</Tooltip>}
</>}
</div>
);
};
+18 -3
View File
@@ -18,6 +18,8 @@ import useDraft from "../../hook/useDraft";
import useUploadFile from "../../hook/useUploadFile";
import { useAppDispatch, useAppSelector } from "../../../app/store";
import TextInput from "../TextInput";
import useUserOperation from "../../hook/useUserOperation";
import StyledButton from "../styled/Button";
const Modes = {
text: "text",
@@ -33,9 +35,11 @@ const Send: FC<IProps> = ({
id
}) => {
const { t } = useTranslation("chat");
const { unblockThisContact, blocked } = useUserOperation({ uid: context == "user" ? id : undefined, cid: context == "channel" ? id : undefined });
const { resetStageFiles } = useUploadFile({ context, id });
const { getDraft, getUpdateDraft } = useDraft({ context, id });
const editor = useMixedEditor(`${context}_${id}`);
const [msgs, setMsgs] = useState([]);
const [markdownEditor, setMarkdownEditor] = useState(null);
const [markdownFullscreen, setMarkdownFullscreen] = useState(false);
const dispatch = useAppDispatch();
@@ -57,7 +61,7 @@ const Send: FC<IProps> = ({
mode: store.ui.inputMode,
from_uid: store.authData.user?.uid,
replying_mid: store.message.replying[`${context}_${id}`],
uploadFiles: store.ui.uploadFiles[`${context}_${id}`]
uploadFiles: store.ui.uploadFiles[`${context}_${id}`] ?? []
};
});
const { sendMessage } = useSendMessage({ context, from: from_uid, to: id });
@@ -76,12 +80,14 @@ const Send: FC<IProps> = ({
editor.insertText(emoji);
}
};
const handleSendMessage = async (msgs = []) => {
const handleSendMessage = async () => {
if (!id) return;
editor.resetInput();
if (msgs && msgs.length) {
// send text msgs
for await (const msg of msgs) {
const { type: content_type, content, properties = {} } = msg;
if ((content as string).trim() === '') continue; // 空消息不发送
properties.local_id = properties.local_id ?? +new Date();
await sendMessage({
id,
@@ -94,7 +100,7 @@ const Send: FC<IProps> = ({
}
}
// send files
if (uploadFiles && uploadFiles.length !== 0) {
if (uploadFiles.length !== 0) {
uploadFiles.forEach((fileInfo) => {
const ts = +new Date();
const { url, name, size, type } = fileInfo;
@@ -138,6 +144,12 @@ const Send: FC<IProps> = ({
const members =
context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : [];
const isMarkdownMode = mode == Modes.markdown;
if (context == "user" && blocked) {
return <div className="p-5 bg-gray-200 rounded-lg w-full dark:bg-gray-600 text-red-300">
{t("contact_block_tip")}
<StyledButton className="mini ml-4" onClick={unblockThisContact}>{t("unblock")}</StyledButton>
</div>;
}
return (
<>
{/* mobile input */}
@@ -154,6 +166,7 @@ const Send: FC<IProps> = ({
<EmojiPicker selectEmoji={insertEmoji} />
{mode == Modes.text && (
<MixedInput
updateMessages={setMsgs}
updateDraft={getUpdateDraft()}
initialValue={getDraft()}
members={members}
@@ -163,6 +176,8 @@ const Send: FC<IProps> = ({
/>
)}
<Toolbar
sendMessages={handleSendMessage}
sendVisible={msgs.length > 0 || uploadFiles.length > 0}
context={context}
to={id}
mode={mode}
+16
View File
@@ -15,11 +15,16 @@ interface Props {
const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, children }) => {
const { t } = useTranslation("member");
const { t: chatTran } = useTranslation("chat");
const {
blockThisContact,
removeFromContact,
copyEmail,
canCopyEmail,
startChat,
canRemove,
canRemoveFromContact,
canBlock,
canRemoveFromChannel,
removeFromChannel,
removeUser
@@ -55,11 +60,22 @@ const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, c
title: t("remove_from_channel"),
handler: removeFromChannel
},
canRemoveFromContact && {
danger: true,
title: t("remove_from_contact"),
handler: removeFromContact
},
canBlock && {
danger: true,
title: chatTran("block"),
handler: blockThisContact
},
canRemove && {
danger: true,
title: t("remove"),
handler: removeUser
}
].filter(Boolean) as Item[]
}
/>
+1 -1
View File
@@ -26,7 +26,7 @@ const UsersModal: FC<Props> = ({ closeModal }) => {
<Modal>
<div className="flex flex-col w-80 md:w-[440px] max-h-[402px] bg-white dark:bg-gray-900 drop-shadow rounded-lg" ref={wrapperRef}>
<div className="shadow-md p-2">
<input className="p-2 text-sm bg-transparent dark:text-white" value={input} onChange={handleSearch} placeholder={t("search_user_placeholder")} />
<input className="p-2 text-sm bg-transparent dark:text-white w-full outline-none" value={input} onChange={handleSearch} placeholder={t("search_user_placeholder")} />
</div>
{users && (
<ul className="flex flex-col overflow-y-scroll h-[260px] py-4">
-5
View File
@@ -23,11 +23,6 @@ const Version: FC<Props> = () => {
<li>{t("client_version")}: {process.env.VERSION}</li>
<li>{t("server_version")}: {serverVersion}</li>
<li>{t("build_time")}: {ts} <span className="text-gray-700 dark:text-gray-300">({dayjs(ts * 1000).fromNow()})</span></li>
<li>GitHub:&nbsp;
<strong className="font-bold">
<a className="text-primary-600 underline underline-offset-2" href="https://github.com/Privoce/vocechat-web/issues" target="_blank" rel="noopener noreferrer">vocechat-web/issues</a>
</strong>
</li>
<li>
<Button disabled={syncing} onClick={handleSync}> {syncing ? <Ring size={18} color="#fff" /> : t("sync")}</Button>
</li>
+9 -2
View File
@@ -38,6 +38,7 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
hideScrollbar: true,
// mediaControls: true,
normalize: true,
responsive: true
});
wave.load(audioSrc);
@@ -64,6 +65,12 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss');
setDuration(durDisplay);
}
// tricky
if (containerRef.current) {
const container = containerRef.current as HTMLDivElement;
const w = (container.querySelector("wave>canvas") as HTMLCanvasElement)?.width;
container.style.width = `${w}px`;
}
});
VoiceMap[file_path] = wave;
};
@@ -103,11 +110,11 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
};
const notReady = status !== "ready";
return (
<div className={clsx("relative select-none flex items-center gap-2 p-2 rounded-lg max-w-sm", status === "error" ? "bg-red-200" : "bg-primary-100 dark:bg-primary-900")}>
<div className={clsx("relative whitespace-nowrap select-none flex items-center gap-2 p-2 rounded-lg max-w-sm", status === "error" ? "bg-red-200" : "bg-primary-100 dark:bg-primary-900")}>
<button className='disabled:opacity-60' onClick={handleClick} disabled={notReady}>
{playing ? <IconPause className="stroke-primary-500" /> : <IconPlay className="stroke-primary-500" />}
</button>
<div ref={containerRef} className={clsx('flex-1 h-8', notReady && "flex-center flex-1 whitespace-nowrap")} >
<div ref={containerRef} className={clsx('flex-1 h-8 min-w-[100px]')} >
{status == "loading" && <span className='text-xs'>Loading voice message...</span>}
{status == "error" && <span className='text-xs text-red-800'>Load voice message error</span>}
</div>
+4 -4
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useState, FC, DetailedHTMLProps, InputHTMLAttributes } from "react";
import { useState, FC, DetailedHTMLProps, InputHTMLAttributes, ReactElement } from "react";
import IconEyeOpen from "../../../assets/icons/eye.open.svg";
import IconEyeClose from "../../../assets/icons/eye.close.svg";
interface Props
@@ -24,7 +24,7 @@ interface Props
>,
HTMLInputElement
> {
prefix?: string;
prefix?: string | ReactElement;
ref?: any;
}
@@ -46,7 +46,7 @@ const Input: FC<Props> = ({ type = "text", prefix = "", className = "", ...rest
placeholder:text-gray-400`,
// noInner && 'rounded border border-solid border-gray-200 shadow',
isLarge && 'py-3',
isNone && "border-none bg-transparent shadow-none",
isNone && "!border-none bg-transparent shadow-none",
isPwd && "pr-[30px]"
);
return type == "password" ? (
@@ -59,7 +59,7 @@ const Input: FC<Props> = ({ type = "text", prefix = "", className = "", ...rest
) : prefix ? (
<div className={`w-full relative flex overflow-hidden rounded border border-solid border-gray-200 dark:border-gray-400 shadow-sm ${className}`}>
{typeof prefix === "string" ? <span className="px-4 py-2 text-sm text-gray-500 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 shadow-[rgb(0_0_0_/_10%)_-1px_0px_0px_inset]">{prefix}</span> : <span className="flex-center p-2 bg-gray-100 dark:bg-gray-800 ">{prefix}</span>}
<input className={`${inputClass} ${className}`} type={inputType} {...rest} />
<input className={`${inputClass} ${className}`} type={type} {...rest} />
</div>
) : (
<input type={inputType} className={`${inputClass} rounded border border-solid border-gray-200 dark:border-gray-400 shadow-sm ${className}`} {...rest} />
+10 -2
View File
@@ -1,11 +1,19 @@
import { useState, useEffect } from "react";
import { StoredUser } from "../../app/slices/users";
import { useAppSelector } from "../../app/store";
import { compareVersion } from "../utils";
export default function useFilteredUsers() {
const [input, setInput] = useState("");
const users = useAppSelector((store) => Object.values(store.users.byId));
const { originUsers, serverVersion } = useAppSelector((store) => {
return {
originUsers: Object.values(store.users.byId),
serverVersion: store.server.version
};
});
const enableContact = compareVersion(serverVersion, "0.3.7") >= 0;
const [filteredUsers, setFilteredUsers] = useState<StoredUser[]>([]);
const users = enableContact ? originUsers.filter((u) => u.status == "added") : originUsers;
useEffect(() => {
if (!input) {
setFilteredUsers(users);
@@ -19,7 +27,7 @@ export default function useFilteredUsers() {
})
);
}
}, [input]);
}, [input, users.length]);
const updateInput = (val: string) => {
setInput(val);
+14 -7
View File
@@ -1,35 +1,42 @@
import { useState, useEffect } from "react";
import useCopy from "./useCopy";
import { useGetSMTPStatusQuery } from "../../app/services/server";
import { useLazyCreateInviteLinkQuery as useCreateChannelInviteLinkQuery } from "../../app/services/channel";
import { useLazyCreateInviteLinkQuery as useCreateChannelInviteLinkQuery, useLazyCreatePrivateInviteLinkQuery } from "../../app/services/channel";
import { useAppSelector } from "../../app/store";
export default function useInviteLink(cid?: number) {
const [finalLink, setFinalLink] = useState("");
const channel = useAppSelector(store => store.channels.byId[cid ?? 0]);
const { data: SMTPEnabled, isSuccess: smtpStatusFetchSuccess } = useGetSMTPStatusQuery();
const [generateChannelInviteLink, { data: channelInviteLink, isLoading: generatingChannelLink }] =
useCreateChannelInviteLinkQuery();
const [generatePrivateInviteLink, { data: privateInviteLink, isLoading: generatingPrivateLink }] = useLazyCreatePrivateInviteLinkQuery();
const { copied, copy } = useCopy({ enableToast: false });
const copyLink = () => {
copy(finalLink);
};
useEffect(() => {
generateChannelInviteLink(cid);
}, [cid]);
if (!cid || channel?.is_public) {
generateChannelInviteLink(cid);
} else {
generatePrivateInviteLink(cid);
}
}, [cid, channel]);
useEffect(() => {
const _link = channelInviteLink;
const _link = channelInviteLink || privateInviteLink;
if (_link && smtpStatusFetchSuccess) {
setFinalLink(_link);
}
}, [channelInviteLink, smtpStatusFetchSuccess]);
}, [channelInviteLink, privateInviteLink, smtpStatusFetchSuccess]);
const genServerLink = () => {
generateChannelInviteLink();
};
const generating = generatingPrivateLink || generatingChannelLink;
return {
enableSMTP: SMTPEnabled,
generating: generatingChannelLink,
generating,
generateNewLink: cid ? generateChannelInviteLink.bind(null, cid) : genServerLink,
link: finalLink,
linkCopied: copied,
+21 -16
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import dayjs from "dayjs";
import initCache, { useRehydrate } from "../../app/cache";
import { useLazyGetFavoritesQuery } from "../../app/services/message";
import { useLazyGetUsersQuery } from "../../app/services/user";
import { useLazyGetContactsQuery, useLazyGetUsersQuery } from "../../app/services/user";
import { useLazyGetSystemCommonQuery, useLazyGetServerQuery, useLazyGetServerVersionQuery } from "../../app/services/server";
import useStreaming from "./useStreaming";
import { useAppSelector } from "../../app/store";
@@ -44,19 +44,21 @@ export default function usePreload() {
data: favorites
}
] = useLazyGetFavoritesQuery();
const [getUsers, { isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users }] = useLazyGetUsersQuery();
const [
getUsers,
{ isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users }
] = useLazyGetUsersQuery();
getContacts,
{ isLoading: contactsLoading, isSuccess: contactsSuccess, isError: contactsError, data: contacts }
] = useLazyGetContactsQuery();
const [
getServer,
{ isLoading: serverLoading, isSuccess: serverSuccess, isError: serverError, data: server }
] = useLazyGetServerQuery();
const [getServerVersion, { isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery();
const [getServerVersion, { data: serverVersion, isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery();
const [getSystemCommon] = useLazyGetSystemCommonQuery();
useEffect(() => {
initCache();
rehydrate();
getServerVersion();
return () => {
setStreamingReady(false);
};
@@ -75,18 +77,21 @@ export default function usePreload() {
}
}, [channelIds, channelMessageData, isGuest]);
useEffect(() => {
if (rehydrated) {
getUsers();
getServer();
getFavorites();
getServerVersion().then((resp) => {
if (resp.data && compareVersion(resp.data, '0.3.4') >= 0) {
// 根据版本号判断是否需要调用system common api
getSystemCommon();
if (rehydrated && serverVersion) {
getUsers().then(() => {
if (compareVersion(serverVersion, '0.3.7') >= 0) {
// 根据版本号判断是否需要初始化contact
getContacts();
}
});
getServer();
getFavorites();
if (compareVersion(serverVersion, '0.3.4') >= 0) {
// 根据版本号判断是否需要调用system common api
getSystemCommon();
}
}
}, [rehydrated]);
}, [rehydrated, serverVersion]);
const tokenAlmostExpire = dayjs().isAfter(new Date(expireTime - 20 * 1000));
const canStreaming = !!loginUid && rehydrated && !!token && !tokenAlmostExpire;
// console.log("ttt", loginUid, rehydrated, token);
@@ -94,13 +99,13 @@ export default function usePreload() {
useEffect(() => {
setStreamingReady(canStreaming);
}, [canStreaming]);
const enableContact = serverVersion ? compareVersion(serverVersion, '0.3.7') >= 0 : false;
return {
loading: usersLoading || serverLoading || favoritesLoading || !rehydrated || loadingLicense || loadingServerVersion,
error: usersError && serverError && favoritesError,
success: usersSuccess && serverSuccess && favoritesSuccess && serverVersionSuccess,
data: {
users,
users: enableContact ? contacts : users,
server,
favorites
}
+49 -3
View File
@@ -17,13 +17,15 @@ import {
updateReadChannels,
updateReadUsers,
updateMute,
updateAutoDeleteSetting
updateAutoDeleteSetting,
upsertPinChats,
removePinChats
} from "../../../app/slices/footprint";
import { updateUsersByLogs, updateUsersStatus } from "../../../app/slices/users";
import { updateContactStatus, updateUsersByLogs, updateUsersStatus } from "../../../app/slices/users";
import { resetAuthData, updateLoginUser, updateRoleChanged } from "../../../app/slices/auth.data";
import chatMessageHandler from "./chat.handler";
import { useAppDispatch, useAppSelector } from "../../../app/store";
import { ServerEvent, UsersStateEvent } from "../../../types/sse";
import { ServerEvent, UserSettingsChangedEvent, UsersStateEvent } from "../../../types/sse";
import { useRenewMutation } from "../../../app/services/auth";
import { getLocalAuthData } from "../../utils";
import { removeUserSession } from "../../../app/slices/message.user";
@@ -149,6 +151,11 @@ export default function useStreaming() {
dispatch(clearChannelMessage(gid));
break;
}
case "pinned_chats": {
const { chats } = data;
dispatch(upsertPinChats({ pins: chats, override: true }));
break;
}
case "users_snapshot": {
const { version } = data;
dispatch(updateUsersVersion(version));
@@ -187,6 +194,20 @@ export default function useStreaming() {
case "read_index_users":
dispatch(updateReadUsers(data[key]));
break;
case "add_pin_chats": {
const pins = (data as UserSettingsChangedEvent).add_pin_chats ?? [];
if (pins.length) {
dispatch(upsertPinChats({ pins }));
}
}
break;
case "remove_pin_chats": {
const pins = (data as UserSettingsChangedEvent).remove_pin_chats ?? [];
if (pins.length) {
dispatch(removePinChats(pins));
}
}
break;
case "add_mute_users":
case "mute_users":
case "add_mute_groups":
@@ -199,6 +220,31 @@ export default function useStreaming() {
}
}
break;
case "remove_contacts": {
const arr = (data as UserSettingsChangedEvent).remove_contacts ?? [];
if (arr.length) {
dispatch(updateContactStatus(
arr.map(
(uid: number) => {
return { uid, status: "" };
}
)
));
}
}
break;
case "add_contacts": {
const arr = (data as UserSettingsChangedEvent).add_contacts ?? [];
if (arr.length) {
const transformed = arr.map(
({ target_uid, info: { status } }) => {
return { uid: target_uid, status };
}
);
dispatch(updateContactStatus(transformed));
}
}
break;
case "remove_mute_users":
case "remove_mute_groups":
{
+23 -1
View File
@@ -4,7 +4,7 @@ import toast from "react-hot-toast";
import { useNavigate, useMatch } from "react-router-dom";
import { hideAll } from "tippy.js";
import { useRemoveMembersMutation } from "../../app/services/channel";
import { useLazyDeleteUserQuery } from "../../app/services/user";
import { useLazyDeleteUserQuery, useUpdateContactStatusMutation } from "../../app/services/user";
// import useConfig from "./useConfig";
import useCopy from "./useCopy";
import { useAppSelector } from "../../app/store";
@@ -18,6 +18,7 @@ const useUserOperation = ({ uid, cid }: IProps) => {
const { t: ct } = useTranslation();
const [passedUid, setPassedUid] = useState<number | undefined>(undefined);
const isUserDetailPath = useMatch(`/users/${uid}`);
const [updateContactStatus] = useUpdateContactStatusMutation();
const [removeUser, { isSuccess: removeUserSuccess }] = useLazyDeleteUserQuery();
const [removeInChannel, { isSuccess: removeSuccess }] = useRemoveMembersMutation();
const navigateTo = useNavigate();
@@ -71,6 +72,19 @@ const useUserOperation = ({ uid, cid }: IProps) => {
const startChat = () => {
navigateTo(`/chat/dm/${uid}`);
};
const removeFromContact = () => {
if (uid) {
updateContactStatus({ target_uid: uid, action: "remove" });
}
};
const blockThisContact = () => {
if (uid === undefined) return;
updateContactStatus({ target_uid: uid, action: "block" });
};
const unblockThisContact = () => {
if (uid === undefined) return;
updateContactStatus({ target_uid: uid, action: "unblock" });
};
const isAdmin = !!loginUser?.is_admin;
const loginUid = loginUser?.uid;
@@ -81,8 +95,16 @@ const useUserOperation = ({ uid, cid }: IProps) => {
(isAdmin || channel?.owner == loginUid) &&
uid != channel?.owner;
const canRemove: boolean = isAdmin && loginUid != uid && !cid && uid !== 1;
const canBlock: boolean = loginUid != uid;
const canRemoveFromContact: boolean = loginUid != uid;
const canInviteChannel = !!cid && (loginUser?.is_admin || channel?.owner == loginUser?.uid);
return {
removeFromContact,
canBlock,
canRemoveFromContact,
blocked: user?.status == "blocked",
blockThisContact,
unblockThisContact,
canInviteChannel,
canDeleteChannel,
canRemove,
+51
View File
@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
// import React from 'react';
import IconAdd from '../../../assets/icons/add.person.svg';
import IconBlock from '../../../assets/icons/block.svg';
import { useAppSelector } from '../../../app/store';
import { useUpdateContactStatusMutation } from '../../../app/services/user';
import { ContactAction } from '../../../types/user';
import ServerVersionChecker from '../../../common/component/ServerVersionChecker';
// import toast from 'react-hot-toast';
type Props = {
uid: number
}
const AddContactTip = (props: Props) => {
const { t } = useTranslation("chat");
const [updateContactStatus] = useUpdateContactStatusMutation();
const targetUser = useAppSelector(store => store.users.byId[props.uid]);
const handleContactStatus = (action: ContactAction) => {
updateContactStatus({ target_uid: props.uid, action });
};
// useEffect(() => {
// if(isSuccess){
// toast.success(t("tip"))
// }
// }, [isSuccess])
const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`;
if (!targetUser) return null;
if (targetUser.status == "added") return null;
const blocked = targetUser.status == "blocked";
return (
<ServerVersionChecker version='0.3.7' empty={true}>
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-100 dark:bg-slate-600">
<h3 className='text-gray-700 dark:text-gray-300 text-sm font-semibold'>{blocked ? t("contact_block_tip") : t("contact_tip")}</h3>
<ul className='flex gap-4'>
{!blocked && <li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
<IconAdd className="fill-primary-400" />
<span>{t("add_contact")}</span>
</li>}
<li className={itemClass} onClick={blocked ? handleContactStatus.bind(null, "unblock") : handleContactStatus.bind(null, "block")}>
<IconBlock className="stroke-primary-400" />
<span>{blocked ? t("unblock") : t("block")}</span>
</li>
</ul>
</div>
</ServerVersionChecker>
);
};
export default AddContactTip;
+2
View File
@@ -18,6 +18,7 @@ import ImagePreview from "../../../common/component/ImagePreview";
import VirtualMessageFeed from "./VirtualMessageFeed";
import DMVoice from "./DMVoicing";
import AddContactTip from "./AddContactTip";
interface Props {
readonly?: boolean;
@@ -100,6 +101,7 @@ const Layout: FC<Props> = ({
<div className="w-full h-full flex items-start justify-between relative" >
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
{context == "user" && <DMVoice uid={to} />}
{context == "user" && <AddContactTip uid={to} />}
{/* 消息流 */}
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={to} />
{/* 发送框 */}
+26 -2
View File
@@ -2,7 +2,7 @@ import { FC, ReactElement } from "react";
import Tippy from "@tippyjs/react";
import { useDispatch } from "react-redux";
import { useNavigate, useLocation, useMatch } from "react-router-dom";
import { useUpdateMuteSettingMutation } from "../../../app/services/user";
import { usePinChatMutation, useUnpinChatMutation, useUpdateMuteSettingMutation } from "../../../app/services/user";
import { useReadMessageMutation } from "../../../app/services/message";
import { removeUserSession } from "../../../app/slices/message.user";
import ContextMenu, { Item } from "../../../common/component/ContextMenu";
@@ -10,8 +10,10 @@ import useUserOperation from "../../../common/hook/useUserOperation";
import { useAppSelector } from "../../../app/store";
import { useTranslation } from "react-i18next";
import { ChatContext } from "../../../types/common";
import { compareVersion } from "../../../common/utils";
type Props = {
context: ChatContext;
pinned: boolean;
id: number;
visible: boolean;
mid: number;
@@ -21,6 +23,7 @@ type Props = {
children: ReactElement;
};
const SessionContextMenu: FC<Props> = ({
pinned,
context = "dm",
id,
visible,
@@ -36,13 +39,16 @@ const SessionContextMenu: FC<Props> = ({
cid: context == "channel" ? id : undefined
});
const [muteChannel] = useUpdateMuteSettingMutation();
const [pinChat] = usePinChatMutation();
const [unpinChat] = useUnpinChatMutation();
const [updateReadIndex] = useReadMessageMutation();
const pathMatched = useMatch(`/chat/dm/${id}`);
const dispatch = useDispatch();
const navigateTo = useNavigate();
const { pathname } = useLocation();
const { channelMuted } = useAppSelector((store) => {
const { channelMuted, serverVersion } = useAppSelector((store) => {
return {
serverVersion: store.server.version,
channelMuted: context == "channel" ? store.footprint.muteChannels[id] : false
};
});
@@ -70,13 +76,27 @@ const SessionContextMenu: FC<Props> = ({
const data = channelMuted ? { remove_groups: [id] } : { add_groups: [{ gid: id }] };
muteChannel(data);
};
const handlePinChat = () => {
const data = context == "dm" ? { uid: id } : { gid: id };
if (pinned) {
unpinChat(data);
} else {
pinChat(data);
}
};
const handleDMSetting = () => {
navigateTo(`/setting/dm/${id}/overview?f=${pathname}`);
};
const pinTxt = t(pinned ? "unpin_chat" : "pin_chat", { ns: "chat" });
const pinVisible = compareVersion(serverVersion, "0.3.7") >= 0;
const items =
context == "dm"
? [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("action.mark_read"),
handler: handleReadAll
@@ -96,6 +116,10 @@ const SessionContextMenu: FC<Props> = ({
}
]
: [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{
title: t("setting"),
underline: true,
+5 -2
View File
@@ -21,11 +21,13 @@ interface IProps {
type?: ChatContext;
id: number;
mid: number;
pinned?: boolean;
setDeleteChannelId: (param: number) => void;
setInviteChannelId: (param: number) => void;
}
const Session: FC<IProps> = ({
type = "dm",
pinned = false,
id,
mid,
setDeleteChannelId,
@@ -85,7 +87,7 @@ const Session: FC<IProps> = ({
useEffect(() => {
const tmp = type == "dm" ? userData[id] : channelData[id];
if (!tmp) return;
if ("avatar" in tmp) {
if (type == "dm") {
// user
const { name, avatar } = tmp;
setData({ name, icon: avatar, mid, is_public: true });
@@ -110,13 +112,14 @@ const Session: FC<IProps> = ({
console.log("unreads", unreads, isCurrentPath);
return (
<li className="session">
<li className={clsx("session")}>
<ContextMenu
visible={contextMenuVisible}
hide={hideContextMenu}
context={type}
id={id}
mid={mid}
pinned={pinned}
setInviteChannelId={setInviteChannelId}
deleteChannel={setDeleteChannelId}
>
+62 -15
View File
@@ -6,6 +6,7 @@ import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
import InviteModal from "../../../common/component/InviteModal";
import { useAppSelector } from "../../../app/store";
import { ChatContext } from "../../../types/common";
import { PinChatTargetChannel, PinChatTargetUser } from "../../../types/sse";
export interface ChatSession {
type: ChatContext;
id: number;
@@ -20,9 +21,11 @@ const SessionList: FC<Props> = ({ tempSession }) => {
const [deleteId, setDeleteId] = useState<number>();
const [inviteChannelId, setInviteChannelId] = useState<number>();
const [sessions, setSessions] = useState<ChatSession[]>([]);
const { channelIDs, DMs, readChannels, readUsers, channelMessage, userMessage, loginUid } =
const [pinSessions, setPinSessions] = useState<ChatSession[]>([]);
const { pins, channelIDs, DMs, readChannels, readUsers, channelMessage, userMessage, loginUid } =
useAppSelector((store) => {
return {
pins: store.footprint.pinChats,
loginUid: store.authData.user?.uid,
channelIDs: store.channels.ids,
DMs: store.userMessage.ids,
@@ -34,24 +37,49 @@ const SessionList: FC<Props> = ({ tempSession }) => {
});
useEffect(() => {
const cSessions = channelIDs.map((id) => {
const mids = channelMessage[id];
// const pinDMs=
const getSessionObj = (id: number, type: "dm" | "channel") => {
const mids = type == "dm" ? userMessage[id] : channelMessage[id];
if (!mids || mids.length == 0) {
return { unreads: 0, id, type: "channel" };
return { unread: 0, id, type } as ChatSession;
}
// 先转换成数字,再排序
const mid = [...mids].sort((a, b) => +a - +b).pop();
return { id, mid, type: "channel" };
return { id, mid, type } as ChatSession;
};
const pinTmps = pins.map((p) => {
const { target } = p;
if ("uid" in target) {
return getSessionObj((target as PinChatTargetUser).uid, "dm");
}
if ("gid" in target) {
return getSessionObj((target as PinChatTargetChannel).gid, "channel");
}
return null;
}).filter((p) => !!p);
const channelPinIds = pins.map(p => {
if (p.target.gid) {
return p.target.gid;
}
return null;
}).filter(id => !!id);
const dmPinIds = pins.map(p => {
if (p.target.uid) {
return p.target.uid;
}
return null;
}).filter(id => !!id);
const cSessions = channelIDs.filter(id => {
return !channelPinIds.includes(id);
}).map((id) => {
return getSessionObj(id, "channel");
});
const uSessions = DMs.map((id) => {
// console.log("adddd", id);
const mids = userMessage[id];
if (!mids || mids.length == 0) {
return { unreads: 0, id, type: "dm" };
}
// 先转换成数字,再排序
const mid = [...mids].sort((a, b) => +a - +b).pop();
return { type: "dm", id, mid };
const uSessions = DMs.filter(id => {
return !dmPinIds.includes(id);
}).map((id) => {
return getSessionObj(id, "dm");
});
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => {
const { mid: aMid = 0 } = a;
@@ -62,6 +90,7 @@ const SessionList: FC<Props> = ({ tempSession }) => {
const newSessions = tempSession ? [tempSession, ...temps] : temps;
// console.log("qqqq", newSessions);
setSessions(newSessions);
setPinSessions(pinTmps);
}, [
channelIDs,
DMs,
@@ -70,11 +99,29 @@ const SessionList: FC<Props> = ({ tempSession }) => {
readUsers,
loginUid,
userMessage,
tempSession
tempSession,
pins
]);
return (
<>
{pinSessions.length ? <ul className="flex flex-col gap-0.5 p-2 bg-primary-500/10">
{pinSessions.map((p) => {
const { type, id, mid = 0 } = p;
const key = `${type}_${id}`;
return (
<Session
key={key}
type={type}
pinned={true}
id={id}
mid={mid}
setInviteChannelId={setInviteChannelId}
setDeleteChannelId={setDeleteId}
/>
);
})}
</ul> : null}
<ul ref={ref} className="flex flex-1 flex-col gap-0.5 p-2 overflow-auto">
<ViewportList
initialPrerender={10}
+1 -4
View File
@@ -72,10 +72,7 @@ function ChatPage() {
)}>
<Server readonly={isGuest} />
{isGuest ? <GuestSessionList /> : <SessionList tempSession={tmpSession} />}
{isGuest ? <footer className="hidden md:block py-1 text-xs text-gray-300 dark:text-gray-700 text-center">
Host your own <a href="https://voce.chat" target="_blank" rel="noopener noreferrer" className="text-gray-400 dark:text-gray-600">voce.chat</a>
</footer> : <RTCWidget id={+contextId} context={context} />}
<RTCWidget id={+contextId} context={context} />
</div>
<div className={clsx(`right-container md:rounded-r-2xl w-full bg-white dark:!bg-gray-700`, placeholderVisible && "h-full flex-center", isMainPath && "hidden md:flex")}>
{voiceFullscreenVisible && <VoiceFullscreen id={contextId} context={context} />}
+9 -6
View File
@@ -32,11 +32,10 @@ import LazyIt from './lazy';
import store, { useAppSelector } from "../app/store";
import useDeviceToken from "../common/component/Notification/useDeviceToken";
import { vapidKey } from "../app/config";
import useTabBroadcast from "../common/hook/useTabBroadcast";
import InactiveScreen from "../common/component/InactiveScreen";
import RequireSingleTab from "../common/component/RequireSingleTab";
import InvitePrivate from "./invitePrivate";
let toastId: string;
const PageRoutes = () => {
const {
ui: { online },
fileMessages
@@ -59,6 +58,7 @@ const PageRoutes = () => {
<HashRouter>
<Routes>
<Route path="/guest_login" element={<LazyIt><GuestLogin /></LazyIt>} />
<Route path="/invite_private/:channel_id" element={<LazyIt><RequireAuth><InvitePrivate /></RequireAuth></LazyIt>} />
<Route path="/cb/:type/:payload" element={<LazyIt><CallbackPage /></LazyIt>} />
<Route path="/oauth/:token" element={<LazyIt><OAuthPage /></LazyIt>} />
<Route
@@ -112,7 +112,10 @@ const PageRoutes = () => {
element={
<LazyIt>
<RequireAuth>
<HomePage />
{/* 只允许活跃一个tab标签 */}
<RequireSingleTab>
<HomePage />
</RequireSingleTab>
</RequireAuth>
</LazyIt>
}
@@ -199,11 +202,11 @@ const PageRoutes = () => {
};
export default function ReduxRoutes() {
const { tabActive } = useTabBroadcast();
return (
<Provider store={store}>
<Meta />
{tabActive ? <PageRoutes /> : <InactiveScreen />}
<PageRoutes />
</Provider>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from '../../app/services/channel';
import StyledButton from '../../common/component/styled/Button';
import { useCheckMagicTokenValidMutation } from '../../app/services/auth';
import { useAppSelector } from '../../app/store';
const InvitePrivate = () => {
const { channel_id } = useParams();
const server = useAppSelector(store => store.server);
const navigateTo = useNavigate();
const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation();
const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] = useLazyGetChannelQuery();
const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] =
useCheckMagicTokenValidMutation();
let [searchParams] = useSearchParams(new URLSearchParams(location.search));
const magic_token = searchParams.get("magic_token") ?? "";
useEffect(() => {
if (channel_id) {
fetchChannelInfo(+channel_id);
}
}, [channel_id]);
useEffect(() => {
if (magic_token) {
checkTokenInvalid(magic_token);
}
}, [magic_token]);
useEffect(() => {
if (data && isSuccess) {
// joinChannel(data)
navigateTo(`/chat/channel/${data.gid}`);
}
}, [isSuccess, data]);
const handleJoin = async () => {
const resp = await joinChannel({ magic_token });
if ("error" in resp) {
if (resp.error.originalStatus === 409) {
// alert("The invite link is invalid or expired");
}
}
};
if (!fetchChannelSuccess) return null;
return (
<div className="flex-center flex-col gap-4 h-screen overflow-x-hidden overflow-y-auto dark:bg-gray-700 dark:text-slate-100">
<div className="flex flex-col gap-4 items-center py-8 px-10 rounded-lg shadow-md bg-slate-100/30 dark:bg-gray-800 text-center">
<div className="flex flex-col items-center gap-4">
<img src={server.logo} className='w-20 h-20' alt="server logo" />
<h2 className="text-2xl font-bold">
{server.name}
</h2>
</div>
<span>
{checkingToken ? "Checking..." : isTokenValid ? <>You are invited to join private channel <strong className='text-primary-400'>#{channel?.name}</strong></> : "The invite link is invalid or expired"}
</span>
<StyledButton disabled={isLoading || checkingToken || !isTokenValid} onClick={handleJoin}>Join</StyledButton>
</div>
</div>
);
};
export default InvitePrivate;
-8
View File
@@ -88,14 +88,6 @@ const navs = [
name: "version",
component: <Version />
},
{
name: "faq",
link: "https://doc.voce.chat/faq",
},
{
name: "feedback",
link: `https://privoce.voce.chat/widget?from=${encodeURIComponent(location.host)}`,
},
]
}
];
+13 -7
View File
@@ -1,30 +1,36 @@
import { FC, ChangeEvent } from "react";
import Tippy from "@tippyjs/react";
import IconSearch from "../../assets/icons/search.svg";
import IconAdd from "../../assets/icons/add.svg";
import AddEntriesMenu from "../../common/component/AddEntriesMenu";
import Tooltip from "../../common/component/Tooltip";
import { FC, ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../../app/store";
import { compareVersion } from "../../common/utils";
import Tooltip from "../../common/component/Tooltip";
import AddEntriesMenu from "../../common/component/AddEntriesMenu";
type Props = {
input: string,
openModal: () => void,
updateInput: (input: string) => void
};
const Search: FC<Props> = ({ input, updateInput }) => {
const Search: FC<Props> = ({ input, updateInput, openModal }) => {
const serverVersion = useAppSelector(store => store.server.version);
const { t } = useTranslation();
const handleInput = (evt: ChangeEvent<HTMLInputElement>) => {
updateInput(evt.target.value);
};
const enableContact = compareVersion(serverVersion, "0.3.7") >= 0;
return (
<div className="hidden md:flex relative min-h-[56px] px-2 py-3 items-center justify-between gap-2 shadow-[rgb(0_0_0_/_10%)_0px_1px_0px] dark:border-b-gray-500">
<div className="flex items-center gap-1">
<IconSearch className="dark:fill-gray-400 w-6 h-6 shrink-0" />
<input value={input} placeholder={`${t("action.search")}...`} className="w-full text-sm bg-transparent dark:text-gray-50" onChange={handleInput} />
<input value={input} placeholder={`${t("action.search")}...`} className="w-full text-sm bg-transparent dark:text-gray-50 outline-none" onChange={handleInput} />
</div>
<Tooltip tip={t("more")} placement="bottom">
{enableContact ? <IconAdd onClick={openModal} role="button" className="dark:fill-gray-400" /> : <Tooltip tip={t("more")} placement="bottom">
<Tippy interactive placement="bottom-end" trigger="click" content={<AddEntriesMenu />}>
<IconAdd role="button" className="dark:fill-gray-400" />
</Tippy>
</Tooltip>
</Tooltip>}
</div>
);
};
+8 -3
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef } from "react";
import { memo, useEffect, useRef, useState } from "react";
import { NavLink, useParams, useLocation } from "react-router-dom";
import { useDispatch } from "react-redux";
import { ViewportList } from 'react-viewport-list';
@@ -12,6 +12,7 @@ import BlankPlaceholder from "../../common/component/BlankPlaceholder";
import useFilteredUsers from "../../common/hook/useFilteredUsers";
import clsx from "clsx";
import GoBackNav from "../../common/component/GoBackNav";
import SearchUser from "../../common/component/SearchUser";
function UsersPage() {
const ref = useRef<HTMLDivElement | null>(
@@ -19,6 +20,7 @@ function UsersPage() {
);
const dispatch = useDispatch();
const { pathname } = useLocation();
const [modalVisible, setModalVisible] = useState(false);
const { input, updateInput, users } = useFilteredUsers();
const { user_id } = useParams();
// 记住路由
@@ -28,7 +30,9 @@ function UsersPage() {
dispatch(updateRememberedNavs({ key: "user", path: pathname }));
};
}, [pathname]);
const toggleModal = () => {
setModalVisible(prev => !prev);
};
if (!users) return null;
const isUserDetail = !!user_id;
return (
@@ -36,7 +40,8 @@ function UsersPage() {
<div className={clsx("md:rounded-l-2xl bg-white dark:bg-gray-800 relative flex flex-col w-full md:w-auto md:min-w-[268px] shadow-[inset_-1px_0px_0px_rgba(0,_0,_0,_0.1)]",
isUserDetail && "hidden md:flex"
)}>
<Search input={input} updateInput={updateInput} />
<Search input={input} updateInput={updateInput} openModal={toggleModal} />
{modalVisible && <SearchUser closeModal={toggleModal} />}
<div className="flex flex-col md:gap-1 px-2 pt-3 pb-20 md:py-3 overflow-scroll" ref={ref}>
<ViewportList
viewportRef={ref}
+1 -1
View File
@@ -2,7 +2,7 @@
import { PriceType } from "./common";
import { User } from "./user";
export interface Server {
export interface Server extends SystemCommon {
name: string;
description: string;
}
+20 -4
View File
@@ -1,4 +1,4 @@
import { User } from "./user";
import { ContactInfo, User } from "./user";
import { Channel } from "./channel";
import { ContentType } from "./message";
@@ -52,7 +52,7 @@ export type AutoDeleteMsgForUser = {
export type AutoDeleteMsgForGroup = {
gid: number; expires_in: number | null
}
export interface UserSettingsEvent {
export type UserSettingsEvent = {
type: "user_settings";
mute_users?: MuteUser[];
mute_groups?: MuteChannel[];
@@ -68,13 +68,17 @@ export type AutoDeleteSettingForChannels = {
burn_after_reading_groups: AutoDeleteMsgForGroup[]
}
export type AutoDeleteMessageSettingDTO = AutoDeleteSettingForUsers | AutoDeleteSettingForChannels
export interface UserSettingsChangedEvent {
export type UserSettingsChangedEvent = {
type: "user_settings_changed";
from_device?: string;
add_mute_users?: MuteUser[];
remove_mute_users?: number[];
add_mute_groups?: MuteChannel[];
add_contacts?: { target_uid: number, info: ContactInfo }[];
remove_contacts?: number[];
remove_mute_groups?: number[];
add_pin_chats?: PinChat[];
remove_pin_chats?: PinChatTarget[];
read_index_users?: { uid: number; mid: number }[];
read_index_groups?: { gid: number; mid: number }[];
burn_after_reading_users?: AutoDeleteMsgForUser[];
@@ -85,6 +89,18 @@ export interface RelatedGroupsEvent {
type: "related_groups";
groups: Channel[];
}
export type PinChatTargetUser = {
uid: number;
};
export type PinChatTargetChannel = {
gid: number;
};
export type PinChatTarget = PinChatTargetUser | PinChatTargetChannel;
export type PinChat = { target: PinChatTarget[], updated_at: number }
export interface PinnedChatsEvent {
type: "pinned_chats";
chats: PinChat[];
}
export interface NormalMessage {
mid: number;
@@ -215,4 +231,4 @@ export type ServerEvent =
| PinnedMessageUpdatedEvent
| HeartbeatEvent
| GroupClearEvent
;
| PinnedChatsEvent;
+15
View File
@@ -19,6 +19,21 @@ export interface User {
is_bot?: boolean;
log_id?: number;
}
export type ContactAction = "add" | "remove" | "block" | "unblock";
export type ContactStatus = "added" | "blocked" | "";
export type ContactInfo = {
status: ContactStatus;
created_at: number;
updated_at: number;
}
export interface ContactResponse {
target_uid: number;
target_info: User;
contact_info: ContactInfo
}
export interface Contact extends User {
status: ContactStatus
}
export type UserStatus = "normal" | "frozen";
export type UserDevice = {
device: string;
+10 -1
View File
@@ -23,9 +23,18 @@ module.exports = {
}
},
animation: {
speaking: "speaking 0.5s infinite cubic-bezier(.36, .11, .89, .32) alternate"
speaking: "speaking 0.5s infinite cubic-bezier(.36, .11, .89, .32) alternate",
zoomIn: "zoomIn 0.2s ease-in forwards"
},
keyframes: {
zoomIn: {
from: {
transform: `scale(0.2)`
},
to: {
transform: `scale(1)`
}
},
fadeInUp: {
from: {
opacity: 0,