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