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", "name": "vocechat-web",
"version": "0.3.60", "version": "0.3.61",
"homepage": "https://voce.chat", "homepage": "https://voce.chat",
"dependencies": { "dependencies": {
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
+8 -1
View File
@@ -40,6 +40,8 @@
"new_msg": "{{num}} new messages", "new_msg": "{{num}} new messages",
"mark_read": "Mark As Read", "mark_read": "Mark As Read",
"pin_chat": "Sticky on top",
"unpin_chat": "Unsticky",
"reply_msg_del": "This message has been deleted.", "reply_msg_del": "This message has been deleted.",
@@ -54,5 +56,10 @@
"unmute": "Unmute", "unmute": "Unmute",
"camera_on": "Turn on camera", "camera_on": "Turn on camera",
"camera_off": "Turn off 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_private_channel": "New Private Channel",
"new_msg": "New Message", "new_msg": "New Message",
"invite_people": "Invite People", "invite_people": "Invite People",
"search_people": "Search People",
"create": "Create", "create": "Create",
"open": "Open", "open": "Open",
"update": "Update", "update": "Update",
+8 -3
View File
@@ -13,14 +13,19 @@
"new_pwd": "New Password", "new_pwd": "New Password",
"confirm_new_pwd": "Confirm 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.", "manage_tip": "Disabling your account means you can recover it at any time after taking this action.",
"admin": "Admin", "admin": "Admin",
"user": "User", "user": "User",
"copy_email": "Copy Email", "copy_email": "Copy Email",
"send_msg": "Send Message", "send_msg": "Send Message",
"remove": "Remove", "remove": "Remove from Server",
"remove_from_contact": "Remove From Contact",
"remove_account": "Remove Account", "remove_account": "Remove Account",
"remove_account_desc": "Are you sure remove this 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": { "nav": {
"general": "General", "general": "General",
"overview": "Overview", "overview": "Overview",
"members": "Members", "members": "Contacts",
"auto_delete_msg": "Auto-delete Messages", "auto_delete_msg": "Auto-delete Messages",
"user": "User", "user": "User",
"my_account": "My Account", "my_account": "My Account",
@@ -21,9 +21,7 @@
"license": "License", "license": "License",
"about": "About", "about": "About",
"api_doc": "API Document", "api_doc": "API Document",
"version": "Version", "version": "Version"
"faq": "FAQ",
"feedback": "Feedback"
}, },
"channel": { "channel": {
"leave": "Leave Channel", "leave": "Leave Channel",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"title": "Welcome to {{name}} server", "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", "invite": "Invite your friends or teammates",
"start_by_channel": "Create a Channel to Start a Conversation", "start_by_channel": "Create a Channel to Start a Conversation",
"start_by_dm": "Send a Direct Message", "start_by_dm": "Send a Direct Message",
+8 -2
View File
@@ -41,7 +41,8 @@
"new_msg": "{{num}} 条新消息", "new_msg": "{{num}} 条新消息",
"mark_read": "设为已读", "mark_read": "设为已读",
"pin_chat": "置顶",
"unpin_chat": "取消置顶",
"reply_msg_del": "被回复的消息已删除!", "reply_msg_del": "被回复的消息已删除!",
"file": "文件", "file": "文件",
@@ -55,5 +56,10 @@
"unmute": "取消静音", "unmute": "取消静音",
"camera_on": "开启摄像头", "camera_on": "开启摄像头",
"camera_off": "关闭摄像头", "camera_off": "关闭摄像头",
"leave_voice": "离开" "leave_voice": "离开",
"add_contact": "加为联系人",
"block": "屏蔽",
"unblock": "解除屏蔽",
"contact_tip": "该用户不在您的联系人列表中",
"contact_block_tip": "该用户已被你屏蔽"
} }
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"chat": "聊天", "chat": "聊天",
"members": "成员", "members": "联系人",
"favs": "收藏", "favs": "收藏",
"files": "文件", "files": "文件",
"setting": "设置", "setting": "设置",
@@ -16,6 +16,7 @@
"new_private_channel": "新建私有频道", "new_private_channel": "新建私有频道",
"new_msg": "发消息", "new_msg": "发消息",
"invite_people": "邀请TA人", "invite_people": "邀请TA人",
"search_people": "搜索用户",
"search": "搜索", "search": "搜索",
"create": "创建", "create": "创建",
"open": "打开", "open": "打开",
+9 -4
View File
@@ -12,14 +12,19 @@
"current_pwd": "当前密码", "current_pwd": "当前密码",
"new_pwd": "新密码", "new_pwd": "新密码",
"confirm_new_pwd": "确认密码", "confirm_new_pwd": "确认密码",
"manage_members": "成员管理", "manage_members": "联系人管理",
"manage_tip": "请在此管理你的成员", "manage_tip": "请在此管理你的联系人",
"admin": "管理员", "admin": "管理员",
"user": "普通用户", "user": "普通用户",
"copy_email": "复制邮箱", "copy_email": "复制邮箱",
"send_msg": "发消息", "send_msg": "发消息",
"remove": "删除", "remove": "从服务器删除",
"remove_from_contact": "从联系人中移除",
"remove_account": "注销账号", "remove_account": "注销账号",
"remove_account_desc": "确定永久注销该账号?", "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": { "nav": {
"general": "通用", "general": "通用",
"overview": "概况", "overview": "概况",
"members": "成员", "members": "联系人",
"auto_delete_msg": "自动删除消息", "auto_delete_msg": "自动删除消息",
"my_account": "我的账号", "my_account": "我的账号",
"config": "配置", "config": "配置",
+12 -1
View File
@@ -1,5 +1,5 @@
import localforage from "localforage"; import localforage from "localforage";
import { User } from "../../types/user"; import { ContactStatus, User } from "../../types/user";
import clearTable from "./clear.handler"; import clearTable from "./clear.handler";
export default async function handler({ operation, data, payload }) { export default async function handler({ operation, data, payload }) {
@@ -18,6 +18,17 @@ export default async function handler({ operation, data, payload }) {
await table.setItems(arr); await table.setItems(arr);
} }
break; 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": case "updateUsersByLogs":
{ {
const changeLogs = payload; const changeLogs = payload;
+7 -2
View File
@@ -102,8 +102,13 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
} }
} }
break; break;
case 403: case 403: {
toast.error("Request Not Allowed"); const whiteList403 = ["sendMsg"];
if (!whiteList403.includes(api.endpoint)) {
toast.error("Request Not Allowed");
}
}
break; break;
case 404: 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>({ removeChannel: builder.query<void, number>({
query: (id) => ({ query: (id) => ({
url: `/group/${id}`, url: `/group/${id}`,
@@ -186,6 +205,13 @@ export const channelApi = createApi({
body: members 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 }>({ updateIcon: builder.mutation<void, { gid: number; image: File }>({
query: ({ gid, image }) => ({ query: ({ gid, image }) => ({
headers: { headers: {
@@ -216,6 +242,8 @@ export const {
useChangeChannelTypeMutation, useChangeChannelTypeMutation,
useLazyLeaveChannelQuery, useLazyLeaveChannelQuery,
useLazyCreateInviteLinkQuery, useLazyCreateInviteLinkQuery,
useJoinPrivateChannelMutation,
useLazyCreatePrivateInviteLinkQuery,
useCreateInviteLinkQuery, useCreateInviteLinkQuery,
useGetChannelQuery, useGetChannelQuery,
useLazyGetChannelQuery, useLazyGetChannelQuery,
+11 -5
View File
@@ -3,7 +3,7 @@ import { batch } from "react-redux";
import { ContentTypes } from "../config"; import { ContentTypes } from "../config";
import { addChannelMsg, removeChannelMsg } from "../slices/message.channel"; import { addChannelMsg, removeChannelMsg } from "../slices/message.channel";
import { addUserMsg, removeUserMsg } from "../slices/message.user"; 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"; // import { ContentType } from "../../types/message";
export const onMessageSendStarted = async ( export const onMessageSendStarted = async (
@@ -57,10 +57,16 @@ export const onMessageSendStarted = async (
dispatch(removeMessage(ts)); dispatch(removeMessage(ts));
}, 300); }, 300);
// dispatch(removePendingMessage({ id, mid:ts, type: from })); // dispatch(removePendingMessage({ id, mid:ts, type: from }));
} catch { } catch (error) {
toast.error("Send Message Failed"); if (error?.error?.status == 403) {
dispatch(removeContextMessage({ id, mid: ts })); // 403 means blocked
dispatch(removeMessage(ts)); // 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(); // patchResult.undo();
} }
}; };
+83 -8
View File
@@ -2,18 +2,19 @@ import { createApi } from "@reduxjs/toolkit/query/react";
// import toast from "react-hot-toast"; // import toast from "react-hot-toast";
import baseQuery from "./base.query"; import baseQuery from "./base.query";
import { updateAutoDeleteSetting, updateMute } from "../slices/footprint"; 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 BASE_URL, { ContentTypes } from "../config";
import { onMessageSendStarted } from "./handlers"; 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 { ContentTypeKey, MuteDTO } from "../../types/message";
import { RootState } from "../store";
export const userApi = createApi({ export const userApi = createApi({
reducerPath: "userApi", reducerPath: "userApi",
baseQuery, baseQuery,
endpoints: (builder) => ({ endpoints: (builder) => ({
getUsers: builder.query<User[], void>({ getUsers: builder.query<User[], void>({
query: () => ({ url: `user` }), query: () => ({ url: `/user` }),
transformResponse: (data: User[]) => { transformResponse: (data: User[]) => {
return data.map((user) => { return data.map((user) => {
return { 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 }) { async onQueryStarted(data, { dispatch, queryFulfilled }) {
try { try {
const { data: users } = await queryFulfilled; 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 { } catch {
console.log("get user list error"); console.log("get contact list error");
} }
} }
}), }),
@@ -44,6 +71,27 @@ export const userApi = createApi({
method: "POST" 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>({ updateUser: builder.mutation<UserForAdmin, UserForAdminDTO>({
query: ({ id, ...rest }) => ({ query: ({ id, ...rest }) => ({
url: `/admin/user/${id}`, 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>({ updateMuteSetting: builder.mutation<void, MuteDTO>({
query: (data) => ({ query: (data) => ({
url: `/user/mute`, url: `/user/mute`,
@@ -152,6 +222,7 @@ export const userApi = createApi({
body: type == "file" ? JSON.stringify(content) : content body: type == "file" ? JSON.stringify(content) : content
}), }),
async onQueryStarted(param1, param2) { async onQueryStarted(param1, param2) {
// @ts-ignore
await onMessageSendStarted.call(this, param1, param2, "user"); await onMessageSendStarted.call(this, param1, param2, "user");
} }
}), }),
@@ -160,6 +231,7 @@ export const userApi = createApi({
}); });
export const { export const {
useLazyGetUsersQuery,
useGetUserByAdminQuery, useGetUserByAdminQuery,
useUpdateAvatarByAdminMutation, useUpdateAvatarByAdminMutation,
useUpdateAutoDeleteMsgMutation, useUpdateAutoDeleteMsgMutation,
@@ -169,10 +241,13 @@ export const {
useLazyDeleteUserQuery, useLazyDeleteUserQuery,
useUpdateInfoMutation, useUpdateInfoMutation,
useUpdateAvatarMutation, useUpdateAvatarMutation,
useGetUsersQuery, useLazyGetContactsQuery,
useLazyGetUsersQuery,
useSendMsgMutation, useSendMsgMutation,
useCreateBotAPIKeyMutation, useCreateBotAPIKeyMutation,
useLazyDeleteBotAPIKeyQuery, useLazyDeleteBotAPIKeyQuery,
useGetBotAPIKeysQuery useGetBotAPIKeysQuery,
useSearchUserMutation,
useUpdateContactStatusMutation,
usePinChatMutation,
useUnpinChatMutation
} = userApi; } = userApi;
+1 -1
View File
@@ -53,7 +53,7 @@ const authDataSlice = createSlice({
const { initialized = true, user, token, refresh_token, expired_in = 0 } = payload; const { initialized = true, user, token, refresh_token, expired_in = 0 } = payload;
const { uid, create_by } = user; const { uid, create_by } = user;
state.initialized = initialized; state.initialized = initialized;
state.user = { ...state.user, ...user }; state.user = { ...state.user, ...user, status: "added" };
state.guest = create_by == "guest"; state.guest = create_by == "guest";
state.token = token; state.token = token;
state.refreshToken = refresh_token; state.refreshToken = refresh_token;
+27 -5
View File
@@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { MuteDTO } from "../../types/message"; import { MuteDTO } from "../../types/message";
import { OG } from "../../types/resource"; 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"; import { resetAuthData } from "./auth.data";
type ChannelAside = "members" | "voice" | "voice_fullscreen" | null; type ChannelAside = "members" | "voice" | "voice_fullscreen" | null;
@@ -20,6 +20,7 @@ export interface State {
autoDeleteMsgChannels: AutoDeleteMsgForGroup[]; autoDeleteMsgChannels: AutoDeleteMsgForGroup[];
channelAsides: { [cid: number]: ChannelAside }; channelAsides: { [cid: number]: ChannelAside };
dmAsides: { [uid: number]: DMAside }; dmAsides: { [uid: number]: DMAside };
pinChats: PinChat[];
} }
@@ -36,7 +37,8 @@ const initialState: State = {
autoDeleteMsgUsers: [], autoDeleteMsgUsers: [],
autoDeleteMsgChannels: [], autoDeleteMsgChannels: [],
channelAsides: {}, channelAsides: {},
dmAsides: {} dmAsides: {},
pinChats: []
}; };
const footprintSlice = createSlice({ const footprintSlice = createSlice({
@@ -60,7 +62,8 @@ const footprintSlice = createSlice({
autoDeleteMsgUsers = [], autoDeleteMsgUsers = [],
autoDeleteMsgChannels = [], autoDeleteMsgChannels = [],
channelAsides = {}, channelAsides = {},
dmAsides = {} dmAsides = {},
pinChats = []
} = action.payload; } = action.payload;
return { return {
og, og,
@@ -75,7 +78,8 @@ const footprintSlice = createSlice({
autoDeleteMsgUsers, autoDeleteMsgUsers,
autoDeleteMsgChannels, autoDeleteMsgChannels,
channelAsides, channelAsides,
dmAsides dmAsides,
pinChats
}; };
}, },
updateUsersVersion(state, action: PayloadAction<number>) { 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 }>) { upsertOG(state, action: PayloadAction<{ key: string, value: OG }>) {
const { key, value } = action.payload; const { key, value } = action.payload;
state.og[key] = value; state.og[key] = value;
@@ -228,7 +248,9 @@ export const {
updateHistoryMark, updateHistoryMark,
upsertOG, upsertOG,
updateChannelVisibleAside, updateChannelVisibleAside,
updateDMVisibleAside updateDMVisibleAside,
upsertPinChats,
removePinChats
} = footprintSlice.actions; } = footprintSlice.actions;
export default footprintSlice.reducer; export default footprintSlice.reducer;
+1
View File
@@ -22,6 +22,7 @@ export interface MessagePayload {
thumbnail?: string; thumbnail?: string;
edited?: boolean | number; edited?: boolean | number;
reply_mid?: number; reply_mid?: number;
failed?: boolean;
} }
export interface State { export interface State {
[key: number]: MessagePayload; [key: number]: MessagePayload;
+2 -4
View File
@@ -9,8 +9,6 @@ export interface StoredServer extends Server {
link: string; link: string;
expire: number; expire: number;
}; };
show_user_online_status: boolean,
webclient_auto_update: boolean
} }
const initialState: StoredServer = { const initialState: StoredServer = {
version: "", version: "",
@@ -23,7 +21,7 @@ const initialState: StoredServer = {
expire: 0 expire: 0
}, },
show_user_online_status: false, show_user_online_status: false,
webclient_auto_update: true webclient_auto_update: true,
}; };
const serverSlice = createSlice({ const serverSlice = createSlice({
@@ -45,7 +43,7 @@ const serverSlice = createSlice({
name = "", name = "",
description = "", description = "",
show_user_online_status = true, show_user_online_status = true,
webclient_auto_update = true webclient_auto_update = true,
} = action.payload || {}; } = action.payload || {};
return { version, upgraded, name, logo, description, inviteLink, show_user_online_status, webclient_auto_update }; 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 { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { isNull, omitBy } from "lodash"; import { isNull, omitBy } from "lodash";
import BASE_URL from "../config"; import BASE_URL from "../config";
import { User } from "../../types/user"; import { Contact, ContactStatus } from "../../types/user";
import { UserLog, UserState } from "../../types/sse"; import { UserLog, UserState } from "../../types/sse";
type DMAside = "voice" | null; type DMAside = "voice" | null;
export interface StoredUser extends User { export interface StoredUser extends Contact {
online?: boolean; online?: boolean;
voice?: boolean; voice?: boolean;
avatar?: string; avatar?: string;
@@ -16,7 +16,6 @@ export interface State {
ids: number[]; ids: number[];
byId: { [id: number]: StoredUser }; byId: { [id: number]: StoredUser };
} }
const initialState: State = { const initialState: State = {
ids: [], ids: [],
byId: {} byId: {}
@@ -69,6 +68,7 @@ const usersSlice = createSlice({
? "" ? ""
: `${BASE_URL}/resource/avatar?uid=${uid}&t=${rest.avatar_updated_at}`, : `${BASE_URL}/resource/avatar?uid=${uid}&t=${rest.avatar_updated_at}`,
create_by: "", // todo: missing properties create_by create_by: "", // todo: missing properties create_by
status: state.byId[uid]?.status ?? "",
...rest ...rest
}; };
const idx = state.ids.findIndex((i) => i == uid); const idx = state.ids.findIndex((i) => i == uid);
@@ -98,9 +98,17 @@ const usersSlice = createSlice({
state.byId[uid]!.online = online; 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; 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)"> <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"/> <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> </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 { useAppSelector } from "../../app/store";
import IconInvite from "../../assets/icons/add.person.svg"; import IconInvite from "../../assets/icons/add.person.svg";
import IconMention from "../../assets/icons/mention.svg"; import IconMention from "../../assets/icons/mention.svg";
import IconSearch from "../../assets/icons/search.svg";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal"; import ChannelModal from "./ChannelModal";
import UsersModal from "./UsersModal"; import UsersModal from "./UsersModal";
import InviteModal from "./InviteModal"; import InviteModal from "./InviteModal";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SearchUser from "./SearchUser";
import ServerVersionChecker from "./ServerVersionChecker";
export default function AddEntriesMenu() { export default function AddEntriesMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const currentUser = useAppSelector((store) => store.authData.user); const currentUser = useAppSelector((store) => store.authData.user);
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const [inviteModalVisible, setInviteModalVisible] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [searchModalVisible, setSearchModalVisible] = useState(false);
const [channelModalVisible, setChannelModalVisible] = useState(false); const [channelModalVisible, setChannelModalVisible] = useState(false);
const [usersModalVisible, setUsersModalVisible] = useState(false); const [usersModalVisible, setUsersModalVisible] = useState(false);
const toggleInviteModalVisible = () => { const toggleInviteModalVisible = () => {
@@ -25,6 +28,14 @@ export default function AddEntriesMenu() {
return !prev; return !prev;
}); });
}; };
const toggleSearchModalVisible = () => {
setSearchModalVisible((prevVisible) => {
if (!prevVisible) {
hideAll();
}
return !prevVisible;
});
};
const toggleUsersModalVisible = () => { const toggleUsersModalVisible = () => {
setUsersModalVisible((prevVisible) => { setUsersModalVisible((prevVisible) => {
if (!prevVisible) { if (!prevVisible) {
@@ -66,10 +77,17 @@ export default function AddEntriesMenu() {
<IconInvite className={iconClass} /> <IconInvite className={iconClass} />
{t("action.invite_people")} {t("action.invite_people")}
</li> </li>
<ServerVersionChecker version="0.3.7" empty={true}>
<li className={itemClass} onClick={toggleSearchModalVisible}>
<IconSearch className={iconClass} />
{t("action.search_people")}
</li>
</ServerVersionChecker>
</ul> </ul>
{channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />} {channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />}
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />} {usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
{inviteModalVisible && <InviteModal closeModal={toggleInviteModalVisible} />} {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 { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
type Props = { type Props = {
isSelf: boolean;
context: "user" | "channel"; context: "user" | "channel";
contextId: number; contextId: number;
mid: number; mid: number;
toggleEditMessage: () => void; 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 { t } = useTranslation();
const { const {
canDelete, canDelete,
@@ -87,7 +88,10 @@ const Commands: FC<Props> = ({ context = "user", contextId = 0, mid = 0, toggleE
<> <>
<ul <ul
ref={cmdsRef} 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 <Tippy
onShow={handleTippyVisible.bind(null, true)} onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)} onHide={handleTippyVisible.bind(null, false)}
+18 -8
View File
@@ -17,6 +17,7 @@ import useContextMenu from "../../hook/useContextMenu";
import usePinMessage from "../../hook/usePinMessage"; import usePinMessage from "../../hook/usePinMessage";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
import ExpireTimer from "./ExpireTimer"; import ExpireTimer from "./ExpireTimer";
import IconInfo from '../../../assets/icons/info.svg';
interface IProps { interface IProps {
readOnly?: boolean; readOnly?: boolean;
@@ -39,11 +40,12 @@ const Message: FC<IProps> = ({
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const avatarRef = useRef(null); const avatarRef = useRef(null);
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0); const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
const { message, reactionMessageData, usersData } = useAppSelector((store) => { const { message, reactionMessageData, usersData, loginUid } = useAppSelector((store) => {
return { return {
reactionMessageData: store.reactionMessage, reactionMessageData: store.reactionMessage,
message: store.message[mid], 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", content_type = "text/plain",
edited, edited,
properties, properties,
expires_in = 0 expires_in = 0,
failed = false
} = message; } = message;
@@ -90,6 +94,7 @@ const Message: FC<IProps> = ({
// return null; // return null;
const _key = properties?.local_id || mid; const _key = properties?.local_id || mid;
const showExpire = (expires_in ?? 0) > 0; const showExpire = (expires_in ?? 0) > 0;
const isSelf = fromUid == loginUid;
return ( return (
<div <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`, 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", readOnly && "hover:bg-transparent",
showExpire && "bg-red-200 dark:bg-red-200/40", 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 <Tippy
@@ -121,18 +127,18 @@ const Message: FC<IProps> = ({
context={context} context={context}
contextId={contextId} contextId={contextId}
mid={mid} mid={mid}
visible={contextMenuVisible} visible={contextMenuVisible && !failed}
hide={hideContextMenu} hide={hideContextMenu}
> >
<div <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 : "" 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"> {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 : ""}`} {`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
</span>} </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> <span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
<Tooltip <Tooltip
delay={200} delay={200}
@@ -146,6 +152,9 @@ const Message: FC<IProps> = ({
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")} : dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</time> </time>
</Tooltip> </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>
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`, <div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
sending && "opacity-90", sending && "opacity-90",
@@ -181,8 +190,9 @@ const Message: FC<IProps> = ({
createAt={time ?? 0} createAt={time ?? 0}
/> />
)} )}
{!edit && !readOnly && ( {!edit && !failed && !readOnly && (
<Commands <Commands
isSelf={isSelf}
context={context} context={context}
contextId={contextId} contextId={contextId}
mid={mid} 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 { useKey } from "rooks";
import { Editor, Transforms } from "slate"; import { Editor, Transforms } from "slate";
import { import {
@@ -37,6 +37,7 @@ type Props = {
id: `${ctx}_${number}`; id: `${ctx}_${number}`;
placeholder: string; placeholder: string;
sendMessages: any; sendMessages: any;
updateMessages: any;
members: number[]; members: number[];
}; };
const Plugins: FC<Props> = ({ const Plugins: FC<Props> = ({
@@ -45,6 +46,7 @@ const Plugins: FC<Props> = ({
id, id,
placeholder = "Write some markdown...", placeholder = "Write some markdown...",
sendMessages, sendMessages,
updateMessages,
members = [] members = []
}) => { }) => {
@@ -53,7 +55,6 @@ const Plugins: FC<Props> = ({
const { addStageFile } = useUploadFile({ context, id: to }); const { addStageFile } = useUploadFile({ context, id: to });
const enableMentions = members.length > 0; const enableMentions = members.length > 0;
const userData = useAppSelector((store) => store.users.byId); const userData = useAppSelector((store) => store.users.byId);
const [msgs, setMsgs] = useState([]);
const editableRef = useRef(null); const editableRef = useRef(null);
const initialProps = { const initialProps = {
...CONFIG.editableProps, ...CONFIG.editableProps,
@@ -94,23 +95,13 @@ const Plugins: FC<Props> = ({
} }
evt.preventDefault(); evt.preventDefault();
// return true; // return true;
handleSend(); sendMessages();
}, },
{ {
when: !isMobile(), when: !isMobile(),
target: editableRef, target: editableRef,
} }
); );
const handleSend = () => {
sendMessages(msgs);
// 清空
Transforms.delete(plateEditor, {
at: {
anchor: Editor.start(plateEditor, []),
focus: Editor.end(plateEditor, [])
}
});
};
const pluginArr = [ const pluginArr = [
createParagraphPlugin(), createParagraphPlugin(),
createNodeIdPlugin(), createNodeIdPlugin(),
@@ -142,55 +133,52 @@ const Plugins: FC<Props> = ({
} }
); );
const handleChange = useCallback( const handleChange = (val: any) => {
async (val: any) => { // console.log("tmps changed", val);
// console.log("tmps changed", val); const tmps = [];
const tmps = []; const getMixedText = (children: any) => {
const getMixedText = (children: any) => { const mentions: any = [];
const mentions: any = []; const arr = children.map(({ type, text, uid }: any) => {
const arr = children.map(({ type, text, uid }: any) => { if (type == "mention") {
if (type == "mention") { mentions.push(uid);
mentions.push(uid); return ` @${uid} `;
return ` @${uid} `; }
} return text;
return text; });
}); return { value: arr.join(""), mentions };
return { value: arr.join(""), mentions }; };
}; for (const v of val) {
for (const v of val) { // p
// p const { value, mentions } = getMixedText(v.children);
const { value, mentions } = getMixedText(v.children); const prev = tmps[tmps.length - 1];
const prev = tmps[tmps.length - 1]; if (!prev) {
if (!prev) { tmps.push([{ type: "text", content: value, properties: { mentions } }]);
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 { } else {
if (Array.isArray(prev)) { tmps.push([{ type: "text", content: value, properties: { mentions } }]);
tmps[tmps.length - 1].push({
type: "text",
content: value,
properties: { mentions }
});
} else {
tmps.push([{ type: "text", content: value, properties: { mentions } }]);
}
} }
} }
const arr = tmps.map((tmp) => { }
return Array.isArray(tmp) const arr = tmps.map((tmp) => {
? { return Array.isArray(tmp)
type: "text", ? {
content: tmp.map((t) => t.content).join("\n"), type: "text",
properties: { content: tmp.map((t) => t.content).join("\n"),
mentions: tmp.map((t) => t.properties?.mentions || []).flat() properties: {
} mentions: tmp.map((t) => t.properties?.mentions || []).flat()
} }
: tmp; }
}); : tmp;
const msgs = arr.filter(({ content }) => !!content); });
setMsgs(msgs); const msgs = arr.filter(({ content }) => !!content.trim());
}, updateMessages(msgs);
[msgs] };
);
return ( return (
@@ -245,7 +233,18 @@ export const useMixedEditor = (key: string) => {
editorRef.insertText(txt); editorRef.insertText(txt);
} }
}; };
const resetInput = () => {
if (!editorRef) return;
// 清空
Transforms.delete(editorRef, {
at: {
anchor: Editor.start(editorRef, []),
focus: Editor.end(editorRef, [])
}
});
};
return { return {
resetInput,
focus, focus,
insertText 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 Tooltip from "../Tooltip";
import AddIcon from "../../../assets/icons/add.solid.svg"; import AddIcon from "../../../assets/icons/add.solid.svg";
import MarkdownIcon from "../../../assets/icons/markdown.svg"; import MarkdownIcon from "../../../assets/icons/markdown.svg";
import SendIcon from "../../../assets/icons/send.svg";
import FullscreenIcon from "../../../assets/icons/fullscreen.svg"; import FullscreenIcon from "../../../assets/icons/fullscreen.svg";
import ExitFullscreenIcon from "../../../assets/icons/fullscreen.exit.svg"; import ExitFullscreenIcon from "../../../assets/icons/fullscreen.exit.svg";
import useUploadFile from "../../hook/useUploadFile"; import useUploadFile from "../../hook/useUploadFile";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type Props = { type Props = {
sendMessages: () => void;
toggleMarkdownFullscreen: () => void; toggleMarkdownFullscreen: () => void;
fullscreen: boolean; fullscreen: boolean;
toggleMode: () => void; toggleMode: () => void;
mode: "markdown" | "text"; mode: "markdown" | "text";
to: number; to: number;
context: "user" | "channel"; context: "user" | "channel";
sendVisible: boolean
}; };
const Toolbar: FC<Props> = ({ const Toolbar: FC<Props> = ({
sendMessages,
sendVisible,
toggleMarkdownFullscreen, toggleMarkdownFullscreen,
fullscreen, fullscreen,
toggleMode, toggleMode,
@@ -61,7 +66,7 @@ const Toolbar: FC<Props> = ({
</Tooltip> </Tooltip>
))} ))}
</div> </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"> <div className="cursor-pointer relative w-6 h-6">
<AddIcon className="dark:fill-gray-300" /> <AddIcon className="dark:fill-gray-300" />
<label htmlFor="file" className=" cursor-pointer absolute left-0 top-0 w-full h-full opacity-0"> <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> </label>
</div> </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> </div>
); );
}; };
+18 -3
View File
@@ -18,6 +18,8 @@ import useDraft from "../../hook/useDraft";
import useUploadFile from "../../hook/useUploadFile"; import useUploadFile from "../../hook/useUploadFile";
import { useAppDispatch, useAppSelector } from "../../../app/store"; import { useAppDispatch, useAppSelector } from "../../../app/store";
import TextInput from "../TextInput"; import TextInput from "../TextInput";
import useUserOperation from "../../hook/useUserOperation";
import StyledButton from "../styled/Button";
const Modes = { const Modes = {
text: "text", text: "text",
@@ -33,9 +35,11 @@ const Send: FC<IProps> = ({
id id
}) => { }) => {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { unblockThisContact, blocked } = useUserOperation({ uid: context == "user" ? id : undefined, cid: context == "channel" ? id : undefined });
const { resetStageFiles } = useUploadFile({ context, id }); const { resetStageFiles } = useUploadFile({ context, id });
const { getDraft, getUpdateDraft } = useDraft({ context, id }); const { getDraft, getUpdateDraft } = useDraft({ context, id });
const editor = useMixedEditor(`${context}_${id}`); const editor = useMixedEditor(`${context}_${id}`);
const [msgs, setMsgs] = useState([]);
const [markdownEditor, setMarkdownEditor] = useState(null); const [markdownEditor, setMarkdownEditor] = useState(null);
const [markdownFullscreen, setMarkdownFullscreen] = useState(false); const [markdownFullscreen, setMarkdownFullscreen] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -57,7 +61,7 @@ const Send: FC<IProps> = ({
mode: store.ui.inputMode, mode: store.ui.inputMode,
from_uid: store.authData.user?.uid, from_uid: store.authData.user?.uid,
replying_mid: store.message.replying[`${context}_${id}`], 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 }); const { sendMessage } = useSendMessage({ context, from: from_uid, to: id });
@@ -76,12 +80,14 @@ const Send: FC<IProps> = ({
editor.insertText(emoji); editor.insertText(emoji);
} }
}; };
const handleSendMessage = async (msgs = []) => { const handleSendMessage = async () => {
if (!id) return; if (!id) return;
editor.resetInput();
if (msgs && msgs.length) { if (msgs && msgs.length) {
// send text msgs // send text msgs
for await (const msg of msgs) { for await (const msg of msgs) {
const { type: content_type, content, properties = {} } = msg; const { type: content_type, content, properties = {} } = msg;
if ((content as string).trim() === '') continue; // 空消息不发送
properties.local_id = properties.local_id ?? +new Date(); properties.local_id = properties.local_id ?? +new Date();
await sendMessage({ await sendMessage({
id, id,
@@ -94,7 +100,7 @@ const Send: FC<IProps> = ({
} }
} }
// send files // send files
if (uploadFiles && uploadFiles.length !== 0) { if (uploadFiles.length !== 0) {
uploadFiles.forEach((fileInfo) => { uploadFiles.forEach((fileInfo) => {
const ts = +new Date(); const ts = +new Date();
const { url, name, size, type } = fileInfo; const { url, name, size, type } = fileInfo;
@@ -138,6 +144,12 @@ const Send: FC<IProps> = ({
const members = const members =
context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : []; context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : [];
const isMarkdownMode = mode == Modes.markdown; 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 ( return (
<> <>
{/* mobile input */} {/* mobile input */}
@@ -154,6 +166,7 @@ const Send: FC<IProps> = ({
<EmojiPicker selectEmoji={insertEmoji} /> <EmojiPicker selectEmoji={insertEmoji} />
{mode == Modes.text && ( {mode == Modes.text && (
<MixedInput <MixedInput
updateMessages={setMsgs}
updateDraft={getUpdateDraft()} updateDraft={getUpdateDraft()}
initialValue={getDraft()} initialValue={getDraft()}
members={members} members={members}
@@ -163,6 +176,8 @@ const Send: FC<IProps> = ({
/> />
)} )}
<Toolbar <Toolbar
sendMessages={handleSendMessage}
sendVisible={msgs.length > 0 || uploadFiles.length > 0}
context={context} context={context}
to={id} to={id}
mode={mode} mode={mode}
+16
View File
@@ -15,11 +15,16 @@ interface Props {
const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, children }) => { const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, children }) => {
const { t } = useTranslation("member"); const { t } = useTranslation("member");
const { t: chatTran } = useTranslation("chat");
const { const {
blockThisContact,
removeFromContact,
copyEmail, copyEmail,
canCopyEmail, canCopyEmail,
startChat, startChat,
canRemove, canRemove,
canRemoveFromContact,
canBlock,
canRemoveFromChannel, canRemoveFromChannel,
removeFromChannel, removeFromChannel,
removeUser removeUser
@@ -55,11 +60,22 @@ const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, c
title: t("remove_from_channel"), title: t("remove_from_channel"),
handler: removeFromChannel handler: removeFromChannel
}, },
canRemoveFromContact && {
danger: true,
title: t("remove_from_contact"),
handler: removeFromContact
},
canBlock && {
danger: true,
title: chatTran("block"),
handler: blockThisContact
},
canRemove && { canRemove && {
danger: true, danger: true,
title: t("remove"), title: t("remove"),
handler: removeUser handler: removeUser
} }
].filter(Boolean) as Item[] ].filter(Boolean) as Item[]
} }
/> />
+1 -1
View File
@@ -26,7 +26,7 @@ const UsersModal: FC<Props> = ({ closeModal }) => {
<Modal> <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="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"> <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> </div>
{users && ( {users && (
<ul className="flex flex-col overflow-y-scroll h-[260px] py-4"> <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("client_version")}: {process.env.VERSION}</li>
<li>{t("server_version")}: {serverVersion}</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>{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> <li>
<Button disabled={syncing} onClick={handleSync}> {syncing ? <Ring size={18} color="#fff" /> : t("sync")}</Button> <Button disabled={syncing} onClick={handleSync}> {syncing ? <Ring size={18} color="#fff" /> : t("sync")}</Button>
</li> </li>
+9 -2
View File
@@ -38,6 +38,7 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
hideScrollbar: true, hideScrollbar: true,
// mediaControls: true, // mediaControls: true,
normalize: true, normalize: true,
responsive: true
}); });
wave.load(audioSrc); wave.load(audioSrc);
@@ -64,6 +65,12 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss'); const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss');
setDuration(durDisplay); 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; VoiceMap[file_path] = wave;
}; };
@@ -103,11 +110,11 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => {
}; };
const notReady = status !== "ready"; const notReady = status !== "ready";
return ( 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}> <button className='disabled:opacity-60' onClick={handleClick} disabled={notReady}>
{playing ? <IconPause className="stroke-primary-500" /> : <IconPlay className="stroke-primary-500" />} {playing ? <IconPause className="stroke-primary-500" /> : <IconPlay className="stroke-primary-500" />}
</button> </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 == "loading" && <span className='text-xs'>Loading voice message...</span>}
{status == "error" && <span className='text-xs text-red-800'>Load voice message error</span>} {status == "error" && <span className='text-xs text-red-800'>Load voice message error</span>}
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
import clsx from "clsx"; 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 IconEyeOpen from "../../../assets/icons/eye.open.svg";
import IconEyeClose from "../../../assets/icons/eye.close.svg"; import IconEyeClose from "../../../assets/icons/eye.close.svg";
interface Props interface Props
@@ -24,7 +24,7 @@ interface Props
>, >,
HTMLInputElement HTMLInputElement
> { > {
prefix?: string; prefix?: string | ReactElement;
ref?: any; ref?: any;
} }
@@ -46,7 +46,7 @@ const Input: FC<Props> = ({ type = "text", prefix = "", className = "", ...rest
placeholder:text-gray-400`, placeholder:text-gray-400`,
// noInner && 'rounded border border-solid border-gray-200 shadow', // noInner && 'rounded border border-solid border-gray-200 shadow',
isLarge && 'py-3', isLarge && 'py-3',
isNone && "border-none bg-transparent shadow-none", isNone && "!border-none bg-transparent shadow-none",
isPwd && "pr-[30px]" isPwd && "pr-[30px]"
); );
return type == "password" ? ( return type == "password" ? (
@@ -59,7 +59,7 @@ const Input: FC<Props> = ({ type = "text", prefix = "", className = "", ...rest
) : prefix ? ( ) : prefix ? (
<div className={`w-full relative flex overflow-hidden rounded border border-solid border-gray-200 dark:border-gray-400 shadow-sm ${className}`}> <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>} {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> </div>
) : ( ) : (
<input type={inputType} className={`${inputClass} rounded border border-solid border-gray-200 dark:border-gray-400 shadow-sm ${className}`} {...rest} /> <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 { useState, useEffect } from "react";
import { StoredUser } from "../../app/slices/users"; import { StoredUser } from "../../app/slices/users";
import { useAppSelector } from "../../app/store"; import { useAppSelector } from "../../app/store";
import { compareVersion } from "../utils";
export default function useFilteredUsers() { export default function useFilteredUsers() {
const [input, setInput] = useState(""); 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 [filteredUsers, setFilteredUsers] = useState<StoredUser[]>([]);
const users = enableContact ? originUsers.filter((u) => u.status == "added") : originUsers;
useEffect(() => { useEffect(() => {
if (!input) { if (!input) {
setFilteredUsers(users); setFilteredUsers(users);
@@ -19,7 +27,7 @@ export default function useFilteredUsers() {
}) })
); );
} }
}, [input]); }, [input, users.length]);
const updateInput = (val: string) => { const updateInput = (val: string) => {
setInput(val); setInput(val);
+14 -7
View File
@@ -1,35 +1,42 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import useCopy from "./useCopy"; import useCopy from "./useCopy";
import { useGetSMTPStatusQuery } from "../../app/services/server"; 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) { export default function useInviteLink(cid?: number) {
const [finalLink, setFinalLink] = useState(""); const [finalLink, setFinalLink] = useState("");
const channel = useAppSelector(store => store.channels.byId[cid ?? 0]);
const { data: SMTPEnabled, isSuccess: smtpStatusFetchSuccess } = useGetSMTPStatusQuery(); const { data: SMTPEnabled, isSuccess: smtpStatusFetchSuccess } = useGetSMTPStatusQuery();
const [generateChannelInviteLink, { data: channelInviteLink, isLoading: generatingChannelLink }] = const [generateChannelInviteLink, { data: channelInviteLink, isLoading: generatingChannelLink }] =
useCreateChannelInviteLinkQuery(); useCreateChannelInviteLinkQuery();
const [generatePrivateInviteLink, { data: privateInviteLink, isLoading: generatingPrivateLink }] = useLazyCreatePrivateInviteLinkQuery();
const { copied, copy } = useCopy({ enableToast: false }); const { copied, copy } = useCopy({ enableToast: false });
const copyLink = () => { const copyLink = () => {
copy(finalLink); copy(finalLink);
}; };
useEffect(() => { useEffect(() => {
generateChannelInviteLink(cid); if (!cid || channel?.is_public) {
}, [cid]); generateChannelInviteLink(cid);
} else {
generatePrivateInviteLink(cid);
}
}, [cid, channel]);
useEffect(() => { useEffect(() => {
const _link = channelInviteLink; const _link = channelInviteLink || privateInviteLink;
if (_link && smtpStatusFetchSuccess) { if (_link && smtpStatusFetchSuccess) {
setFinalLink(_link); setFinalLink(_link);
} }
}, [channelInviteLink, smtpStatusFetchSuccess]); }, [channelInviteLink, privateInviteLink, smtpStatusFetchSuccess]);
const genServerLink = () => { const genServerLink = () => {
generateChannelInviteLink(); generateChannelInviteLink();
}; };
const generating = generatingPrivateLink || generatingChannelLink;
return { return {
enableSMTP: SMTPEnabled, enableSMTP: SMTPEnabled,
generating: generatingChannelLink, generating,
generateNewLink: cid ? generateChannelInviteLink.bind(null, cid) : genServerLink, generateNewLink: cid ? generateChannelInviteLink.bind(null, cid) : genServerLink,
link: finalLink, link: finalLink,
linkCopied: copied, linkCopied: copied,
+21 -16
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import initCache, { useRehydrate } from "../../app/cache"; import initCache, { useRehydrate } from "../../app/cache";
import { useLazyGetFavoritesQuery } from "../../app/services/message"; 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 { useLazyGetSystemCommonQuery, useLazyGetServerQuery, useLazyGetServerVersionQuery } from "../../app/services/server";
import useStreaming from "./useStreaming"; import useStreaming from "./useStreaming";
import { useAppSelector } from "../../app/store"; import { useAppSelector } from "../../app/store";
@@ -44,19 +44,21 @@ export default function usePreload() {
data: favorites data: favorites
} }
] = useLazyGetFavoritesQuery(); ] = useLazyGetFavoritesQuery();
const [getUsers, { isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users }] = useLazyGetUsersQuery();
const [ const [
getUsers, getContacts,
{ isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users } { isLoading: contactsLoading, isSuccess: contactsSuccess, isError: contactsError, data: contacts }
] = useLazyGetUsersQuery(); ] = useLazyGetContactsQuery();
const [ const [
getServer, getServer,
{ isLoading: serverLoading, isSuccess: serverSuccess, isError: serverError, data: server } { isLoading: serverLoading, isSuccess: serverSuccess, isError: serverError, data: server }
] = useLazyGetServerQuery(); ] = useLazyGetServerQuery();
const [getServerVersion, { isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery(); const [getServerVersion, { data: serverVersion, isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery();
const [getSystemCommon] = useLazyGetSystemCommonQuery(); const [getSystemCommon] = useLazyGetSystemCommonQuery();
useEffect(() => { useEffect(() => {
initCache(); initCache();
rehydrate(); rehydrate();
getServerVersion();
return () => { return () => {
setStreamingReady(false); setStreamingReady(false);
}; };
@@ -75,18 +77,21 @@ export default function usePreload() {
} }
}, [channelIds, channelMessageData, isGuest]); }, [channelIds, channelMessageData, isGuest]);
useEffect(() => { useEffect(() => {
if (rehydrated) { if (rehydrated && serverVersion) {
getUsers(); getUsers().then(() => {
getServer(); if (compareVersion(serverVersion, '0.3.7') >= 0) {
getFavorites(); // 根据版本号判断是否需要初始化contact
getServerVersion().then((resp) => { getContacts();
if (resp.data && compareVersion(resp.data, '0.3.4') >= 0) {
// 根据版本号判断是否需要调用system common api
getSystemCommon();
} }
}); });
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 tokenAlmostExpire = dayjs().isAfter(new Date(expireTime - 20 * 1000));
const canStreaming = !!loginUid && rehydrated && !!token && !tokenAlmostExpire; const canStreaming = !!loginUid && rehydrated && !!token && !tokenAlmostExpire;
// console.log("ttt", loginUid, rehydrated, token); // console.log("ttt", loginUid, rehydrated, token);
@@ -94,13 +99,13 @@ export default function usePreload() {
useEffect(() => { useEffect(() => {
setStreamingReady(canStreaming); setStreamingReady(canStreaming);
}, [canStreaming]); }, [canStreaming]);
const enableContact = serverVersion ? compareVersion(serverVersion, '0.3.7') >= 0 : false;
return { return {
loading: usersLoading || serverLoading || favoritesLoading || !rehydrated || loadingLicense || loadingServerVersion, loading: usersLoading || serverLoading || favoritesLoading || !rehydrated || loadingLicense || loadingServerVersion,
error: usersError && serverError && favoritesError, error: usersError && serverError && favoritesError,
success: usersSuccess && serverSuccess && favoritesSuccess && serverVersionSuccess, success: usersSuccess && serverSuccess && favoritesSuccess && serverVersionSuccess,
data: { data: {
users, users: enableContact ? contacts : users,
server, server,
favorites favorites
} }
+49 -3
View File
@@ -17,13 +17,15 @@ import {
updateReadChannels, updateReadChannels,
updateReadUsers, updateReadUsers,
updateMute, updateMute,
updateAutoDeleteSetting updateAutoDeleteSetting,
upsertPinChats,
removePinChats
} from "../../../app/slices/footprint"; } 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 { resetAuthData, updateLoginUser, updateRoleChanged } from "../../../app/slices/auth.data";
import chatMessageHandler from "./chat.handler"; import chatMessageHandler from "./chat.handler";
import { useAppDispatch, useAppSelector } from "../../../app/store"; 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 { useRenewMutation } from "../../../app/services/auth";
import { getLocalAuthData } from "../../utils"; import { getLocalAuthData } from "../../utils";
import { removeUserSession } from "../../../app/slices/message.user"; import { removeUserSession } from "../../../app/slices/message.user";
@@ -149,6 +151,11 @@ export default function useStreaming() {
dispatch(clearChannelMessage(gid)); dispatch(clearChannelMessage(gid));
break; break;
} }
case "pinned_chats": {
const { chats } = data;
dispatch(upsertPinChats({ pins: chats, override: true }));
break;
}
case "users_snapshot": { case "users_snapshot": {
const { version } = data; const { version } = data;
dispatch(updateUsersVersion(version)); dispatch(updateUsersVersion(version));
@@ -187,6 +194,20 @@ export default function useStreaming() {
case "read_index_users": case "read_index_users":
dispatch(updateReadUsers(data[key])); dispatch(updateReadUsers(data[key]));
break; 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 "add_mute_users":
case "mute_users": case "mute_users":
case "add_mute_groups": case "add_mute_groups":
@@ -199,6 +220,31 @@ export default function useStreaming() {
} }
} }
break; 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_users":
case "remove_mute_groups": 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 { useNavigate, useMatch } from "react-router-dom";
import { hideAll } from "tippy.js"; import { hideAll } from "tippy.js";
import { useRemoveMembersMutation } from "../../app/services/channel"; import { useRemoveMembersMutation } from "../../app/services/channel";
import { useLazyDeleteUserQuery } from "../../app/services/user"; import { useLazyDeleteUserQuery, useUpdateContactStatusMutation } from "../../app/services/user";
// import useConfig from "./useConfig"; // import useConfig from "./useConfig";
import useCopy from "./useCopy"; import useCopy from "./useCopy";
import { useAppSelector } from "../../app/store"; import { useAppSelector } from "../../app/store";
@@ -18,6 +18,7 @@ const useUserOperation = ({ uid, cid }: IProps) => {
const { t: ct } = useTranslation(); const { t: ct } = useTranslation();
const [passedUid, setPassedUid] = useState<number | undefined>(undefined); const [passedUid, setPassedUid] = useState<number | undefined>(undefined);
const isUserDetailPath = useMatch(`/users/${uid}`); const isUserDetailPath = useMatch(`/users/${uid}`);
const [updateContactStatus] = useUpdateContactStatusMutation();
const [removeUser, { isSuccess: removeUserSuccess }] = useLazyDeleteUserQuery(); const [removeUser, { isSuccess: removeUserSuccess }] = useLazyDeleteUserQuery();
const [removeInChannel, { isSuccess: removeSuccess }] = useRemoveMembersMutation(); const [removeInChannel, { isSuccess: removeSuccess }] = useRemoveMembersMutation();
const navigateTo = useNavigate(); const navigateTo = useNavigate();
@@ -71,6 +72,19 @@ const useUserOperation = ({ uid, cid }: IProps) => {
const startChat = () => { const startChat = () => {
navigateTo(`/chat/dm/${uid}`); 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 isAdmin = !!loginUser?.is_admin;
const loginUid = loginUser?.uid; const loginUid = loginUser?.uid;
@@ -81,8 +95,16 @@ const useUserOperation = ({ uid, cid }: IProps) => {
(isAdmin || channel?.owner == loginUid) && (isAdmin || channel?.owner == loginUid) &&
uid != channel?.owner; uid != channel?.owner;
const canRemove: boolean = isAdmin && loginUid != uid && !cid && uid !== 1; 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); const canInviteChannel = !!cid && (loginUser?.is_admin || channel?.owner == loginUser?.uid);
return { return {
removeFromContact,
canBlock,
canRemoveFromContact,
blocked: user?.status == "blocked",
blockThisContact,
unblockThisContact,
canInviteChannel, canInviteChannel,
canDeleteChannel, canDeleteChannel,
canRemove, 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 VirtualMessageFeed from "./VirtualMessageFeed";
import DMVoice from "./DMVoicing"; import DMVoice from "./DMVoicing";
import AddContactTip from "./AddContactTip";
interface Props { interface Props {
readonly?: boolean; readonly?: boolean;
@@ -100,6 +101,7 @@ const Layout: FC<Props> = ({
<div className="w-full h-full flex items-start justify-between relative" > <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}> <div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
{context == "user" && <DMVoice uid={to} />} {context == "user" && <DMVoice uid={to} />}
{context == "user" && <AddContactTip uid={to} />}
{/* 消息流 */} {/* 消息流 */}
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={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 Tippy from "@tippyjs/react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { useNavigate, useLocation, useMatch } from "react-router-dom"; 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 { useReadMessageMutation } from "../../../app/services/message";
import { removeUserSession } from "../../../app/slices/message.user"; import { removeUserSession } from "../../../app/slices/message.user";
import ContextMenu, { Item } from "../../../common/component/ContextMenu"; import ContextMenu, { Item } from "../../../common/component/ContextMenu";
@@ -10,8 +10,10 @@ import useUserOperation from "../../../common/hook/useUserOperation";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChatContext } from "../../../types/common"; import { ChatContext } from "../../../types/common";
import { compareVersion } from "../../../common/utils";
type Props = { type Props = {
context: ChatContext; context: ChatContext;
pinned: boolean;
id: number; id: number;
visible: boolean; visible: boolean;
mid: number; mid: number;
@@ -21,6 +23,7 @@ type Props = {
children: ReactElement; children: ReactElement;
}; };
const SessionContextMenu: FC<Props> = ({ const SessionContextMenu: FC<Props> = ({
pinned,
context = "dm", context = "dm",
id, id,
visible, visible,
@@ -36,13 +39,16 @@ const SessionContextMenu: FC<Props> = ({
cid: context == "channel" ? id : undefined cid: context == "channel" ? id : undefined
}); });
const [muteChannel] = useUpdateMuteSettingMutation(); const [muteChannel] = useUpdateMuteSettingMutation();
const [pinChat] = usePinChatMutation();
const [unpinChat] = useUnpinChatMutation();
const [updateReadIndex] = useReadMessageMutation(); const [updateReadIndex] = useReadMessageMutation();
const pathMatched = useMatch(`/chat/dm/${id}`); const pathMatched = useMatch(`/chat/dm/${id}`);
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigateTo = useNavigate(); const navigateTo = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { channelMuted } = useAppSelector((store) => { const { channelMuted, serverVersion } = useAppSelector((store) => {
return { return {
serverVersion: store.server.version,
channelMuted: context == "channel" ? store.footprint.muteChannels[id] : false 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 }] }; const data = channelMuted ? { remove_groups: [id] } : { add_groups: [{ gid: id }] };
muteChannel(data); muteChannel(data);
}; };
const handlePinChat = () => {
const data = context == "dm" ? { uid: id } : { gid: id };
if (pinned) {
unpinChat(data);
} else {
pinChat(data);
}
};
const handleDMSetting = () => { const handleDMSetting = () => {
navigateTo(`/setting/dm/${id}/overview?f=${pathname}`); 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 = const items =
context == "dm" context == "dm"
? [ ? [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{ {
title: t("action.mark_read"), title: t("action.mark_read"),
handler: handleReadAll handler: handleReadAll
@@ -96,6 +116,10 @@ const SessionContextMenu: FC<Props> = ({
} }
] ]
: [ : [
pinVisible && {
title: pinTxt,
handler: handlePinChat
},
{ {
title: t("setting"), title: t("setting"),
underline: true, underline: true,
+5 -2
View File
@@ -21,11 +21,13 @@ interface IProps {
type?: ChatContext; type?: ChatContext;
id: number; id: number;
mid: number; mid: number;
pinned?: boolean;
setDeleteChannelId: (param: number) => void; setDeleteChannelId: (param: number) => void;
setInviteChannelId: (param: number) => void; setInviteChannelId: (param: number) => void;
} }
const Session: FC<IProps> = ({ const Session: FC<IProps> = ({
type = "dm", type = "dm",
pinned = false,
id, id,
mid, mid,
setDeleteChannelId, setDeleteChannelId,
@@ -85,7 +87,7 @@ const Session: FC<IProps> = ({
useEffect(() => { useEffect(() => {
const tmp = type == "dm" ? userData[id] : channelData[id]; const tmp = type == "dm" ? userData[id] : channelData[id];
if (!tmp) return; if (!tmp) return;
if ("avatar" in tmp) { if (type == "dm") {
// user // user
const { name, avatar } = tmp; const { name, avatar } = tmp;
setData({ name, icon: avatar, mid, is_public: true }); setData({ name, icon: avatar, mid, is_public: true });
@@ -110,13 +112,14 @@ const Session: FC<IProps> = ({
console.log("unreads", unreads, isCurrentPath); console.log("unreads", unreads, isCurrentPath);
return ( return (
<li className="session"> <li className={clsx("session")}>
<ContextMenu <ContextMenu
visible={contextMenuVisible} visible={contextMenuVisible}
hide={hideContextMenu} hide={hideContextMenu}
context={type} context={type}
id={id} id={id}
mid={mid} mid={mid}
pinned={pinned}
setInviteChannelId={setInviteChannelId} setInviteChannelId={setInviteChannelId}
deleteChannel={setDeleteChannelId} deleteChannel={setDeleteChannelId}
> >
+62 -15
View File
@@ -6,6 +6,7 @@ import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
import InviteModal from "../../../common/component/InviteModal"; import InviteModal from "../../../common/component/InviteModal";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
import { ChatContext } from "../../../types/common"; import { ChatContext } from "../../../types/common";
import { PinChatTargetChannel, PinChatTargetUser } from "../../../types/sse";
export interface ChatSession { export interface ChatSession {
type: ChatContext; type: ChatContext;
id: number; id: number;
@@ -20,9 +21,11 @@ const SessionList: FC<Props> = ({ tempSession }) => {
const [deleteId, setDeleteId] = useState<number>(); const [deleteId, setDeleteId] = useState<number>();
const [inviteChannelId, setInviteChannelId] = useState<number>(); const [inviteChannelId, setInviteChannelId] = useState<number>();
const [sessions, setSessions] = useState<ChatSession[]>([]); 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) => { useAppSelector((store) => {
return { return {
pins: store.footprint.pinChats,
loginUid: store.authData.user?.uid, loginUid: store.authData.user?.uid,
channelIDs: store.channels.ids, channelIDs: store.channels.ids,
DMs: store.userMessage.ids, DMs: store.userMessage.ids,
@@ -34,24 +37,49 @@ const SessionList: FC<Props> = ({ tempSession }) => {
}); });
useEffect(() => { useEffect(() => {
const cSessions = channelIDs.map((id) => { // const pinDMs=
const mids = channelMessage[id]; const getSessionObj = (id: number, type: "dm" | "channel") => {
const mids = type == "dm" ? userMessage[id] : channelMessage[id];
if (!mids || mids.length == 0) { 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(); 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) => { const uSessions = DMs.filter(id => {
// console.log("adddd", id); return !dmPinIds.includes(id);
const mids = userMessage[id]; }).map((id) => {
if (!mids || mids.length == 0) { return getSessionObj(id, "dm");
return { unreads: 0, id, type: "dm" };
}
// 先转换成数字,再排序
const mid = [...mids].sort((a, b) => +a - +b).pop();
return { type: "dm", id, mid };
}); });
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => { const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => {
const { mid: aMid = 0 } = a; const { mid: aMid = 0 } = a;
@@ -62,6 +90,7 @@ const SessionList: FC<Props> = ({ tempSession }) => {
const newSessions = tempSession ? [tempSession, ...temps] : temps; const newSessions = tempSession ? [tempSession, ...temps] : temps;
// console.log("qqqq", newSessions); // console.log("qqqq", newSessions);
setSessions(newSessions); setSessions(newSessions);
setPinSessions(pinTmps);
}, [ }, [
channelIDs, channelIDs,
DMs, DMs,
@@ -70,11 +99,29 @@ const SessionList: FC<Props> = ({ tempSession }) => {
readUsers, readUsers,
loginUid, loginUid,
userMessage, userMessage,
tempSession tempSession,
pins
]); ]);
return ( 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"> <ul ref={ref} className="flex flex-1 flex-col gap-0.5 p-2 overflow-auto">
<ViewportList <ViewportList
initialPrerender={10} initialPrerender={10}
+1 -4
View File
@@ -72,10 +72,7 @@ function ChatPage() {
)}> )}>
<Server readonly={isGuest} /> <Server readonly={isGuest} />
{isGuest ? <GuestSessionList /> : <SessionList tempSession={tmpSession} />} {isGuest ? <GuestSessionList /> : <SessionList tempSession={tmpSession} />}
<RTCWidget id={+contextId} context={context} />
{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} />}
</div> </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")}> <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} />} {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 store, { useAppSelector } from "../app/store";
import useDeviceToken from "../common/component/Notification/useDeviceToken"; import useDeviceToken from "../common/component/Notification/useDeviceToken";
import { vapidKey } from "../app/config"; import { vapidKey } from "../app/config";
import useTabBroadcast from "../common/hook/useTabBroadcast"; import RequireSingleTab from "../common/component/RequireSingleTab";
import InactiveScreen from "../common/component/InactiveScreen"; import InvitePrivate from "./invitePrivate";
let toastId: string; let toastId: string;
const PageRoutes = () => { const PageRoutes = () => {
const { const {
ui: { online }, ui: { online },
fileMessages fileMessages
@@ -59,6 +58,7 @@ const PageRoutes = () => {
<HashRouter> <HashRouter>
<Routes> <Routes>
<Route path="/guest_login" element={<LazyIt><GuestLogin /></LazyIt>} /> <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="/cb/:type/:payload" element={<LazyIt><CallbackPage /></LazyIt>} />
<Route path="/oauth/:token" element={<LazyIt><OAuthPage /></LazyIt>} /> <Route path="/oauth/:token" element={<LazyIt><OAuthPage /></LazyIt>} />
<Route <Route
@@ -112,7 +112,10 @@ const PageRoutes = () => {
element={ element={
<LazyIt> <LazyIt>
<RequireAuth> <RequireAuth>
<HomePage /> {/* 只允许活跃一个tab标签 */}
<RequireSingleTab>
<HomePage />
</RequireSingleTab>
</RequireAuth> </RequireAuth>
</LazyIt> </LazyIt>
} }
@@ -199,11 +202,11 @@ const PageRoutes = () => {
}; };
export default function ReduxRoutes() { export default function ReduxRoutes() {
const { tabActive } = useTabBroadcast();
return ( return (
<Provider store={store}> <Provider store={store}>
<Meta /> <Meta />
{tabActive ? <PageRoutes /> : <InactiveScreen />} <PageRoutes />
</Provider> </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", name: "version",
component: <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 Tippy from "@tippyjs/react";
import IconSearch from "../../assets/icons/search.svg"; import IconSearch from "../../assets/icons/search.svg";
import IconAdd from "../../assets/icons/add.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 { 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 = { type Props = {
input: string, input: string,
openModal: () => void,
updateInput: (input: string) => 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 { t } = useTranslation();
const handleInput = (evt: ChangeEvent<HTMLInputElement>) => { const handleInput = (evt: ChangeEvent<HTMLInputElement>) => {
updateInput(evt.target.value); updateInput(evt.target.value);
}; };
const enableContact = compareVersion(serverVersion, "0.3.7") >= 0;
return ( 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="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"> <div className="flex items-center gap-1">
<IconSearch className="dark:fill-gray-400 w-6 h-6 shrink-0" /> <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> </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 />}> <Tippy interactive placement="bottom-end" trigger="click" content={<AddEntriesMenu />}>
<IconAdd role="button" className="dark:fill-gray-400" /> <IconAdd role="button" className="dark:fill-gray-400" />
</Tippy> </Tippy>
</Tooltip> </Tooltip>}
</div> </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 { NavLink, useParams, useLocation } from "react-router-dom";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { ViewportList } from 'react-viewport-list'; import { ViewportList } from 'react-viewport-list';
@@ -12,6 +12,7 @@ import BlankPlaceholder from "../../common/component/BlankPlaceholder";
import useFilteredUsers from "../../common/hook/useFilteredUsers"; import useFilteredUsers from "../../common/hook/useFilteredUsers";
import clsx from "clsx"; import clsx from "clsx";
import GoBackNav from "../../common/component/GoBackNav"; import GoBackNav from "../../common/component/GoBackNav";
import SearchUser from "../../common/component/SearchUser";
function UsersPage() { function UsersPage() {
const ref = useRef<HTMLDivElement | null>( const ref = useRef<HTMLDivElement | null>(
@@ -19,6 +20,7 @@ function UsersPage() {
); );
const dispatch = useDispatch(); const dispatch = useDispatch();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [modalVisible, setModalVisible] = useState(false);
const { input, updateInput, users } = useFilteredUsers(); const { input, updateInput, users } = useFilteredUsers();
const { user_id } = useParams(); const { user_id } = useParams();
// 记住路由 // 记住路由
@@ -28,7 +30,9 @@ function UsersPage() {
dispatch(updateRememberedNavs({ key: "user", path: pathname })); dispatch(updateRememberedNavs({ key: "user", path: pathname }));
}; };
}, [pathname]); }, [pathname]);
const toggleModal = () => {
setModalVisible(prev => !prev);
};
if (!users) return null; if (!users) return null;
const isUserDetail = !!user_id; const isUserDetail = !!user_id;
return ( 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)]", <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" 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}> <div className="flex flex-col md:gap-1 px-2 pt-3 pb-20 md:py-3 overflow-scroll" ref={ref}>
<ViewportList <ViewportList
viewportRef={ref} viewportRef={ref}
+1 -1
View File
@@ -2,7 +2,7 @@
import { PriceType } from "./common"; import { PriceType } from "./common";
import { User } from "./user"; import { User } from "./user";
export interface Server { export interface Server extends SystemCommon {
name: string; name: string;
description: 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 { Channel } from "./channel";
import { ContentType } from "./message"; import { ContentType } from "./message";
@@ -52,7 +52,7 @@ export type AutoDeleteMsgForUser = {
export type AutoDeleteMsgForGroup = { export type AutoDeleteMsgForGroup = {
gid: number; expires_in: number | null gid: number; expires_in: number | null
} }
export interface UserSettingsEvent { export type UserSettingsEvent = {
type: "user_settings"; type: "user_settings";
mute_users?: MuteUser[]; mute_users?: MuteUser[];
mute_groups?: MuteChannel[]; mute_groups?: MuteChannel[];
@@ -68,13 +68,17 @@ export type AutoDeleteSettingForChannels = {
burn_after_reading_groups: AutoDeleteMsgForGroup[] burn_after_reading_groups: AutoDeleteMsgForGroup[]
} }
export type AutoDeleteMessageSettingDTO = AutoDeleteSettingForUsers | AutoDeleteSettingForChannels export type AutoDeleteMessageSettingDTO = AutoDeleteSettingForUsers | AutoDeleteSettingForChannels
export interface UserSettingsChangedEvent { export type UserSettingsChangedEvent = {
type: "user_settings_changed"; type: "user_settings_changed";
from_device?: string; from_device?: string;
add_mute_users?: MuteUser[]; add_mute_users?: MuteUser[];
remove_mute_users?: number[]; remove_mute_users?: number[];
add_mute_groups?: MuteChannel[]; add_mute_groups?: MuteChannel[];
add_contacts?: { target_uid: number, info: ContactInfo }[];
remove_contacts?: number[];
remove_mute_groups?: number[]; remove_mute_groups?: number[];
add_pin_chats?: PinChat[];
remove_pin_chats?: PinChatTarget[];
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?: AutoDeleteMsgForUser[]; burn_after_reading_users?: AutoDeleteMsgForUser[];
@@ -85,6 +89,18 @@ export interface RelatedGroupsEvent {
type: "related_groups"; type: "related_groups";
groups: Channel[]; 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 { export interface NormalMessage {
mid: number; mid: number;
@@ -215,4 +231,4 @@ export type ServerEvent =
| PinnedMessageUpdatedEvent | PinnedMessageUpdatedEvent
| HeartbeatEvent | HeartbeatEvent
| GroupClearEvent | GroupClearEvent
; | PinnedChatsEvent;
+15
View File
@@ -19,6 +19,21 @@ export interface User {
is_bot?: boolean; is_bot?: boolean;
log_id?: number; 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 UserStatus = "normal" | "frozen";
export type UserDevice = { export type UserDevice = {
device: string; device: string;
+10 -1
View File
@@ -23,9 +23,18 @@ module.exports = {
} }
}, },
animation: { 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: { keyframes: {
zoomIn: {
from: {
transform: `scale(0.2)`
},
to: {
transform: `scale(1)`
}
},
fadeInUp: { fadeInUp: {
from: { from: {
opacity: 0, opacity: 0,