mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-09-01 19:51:52 +02:00
fix: service switching error (#539)
* fix: service switching error * build: build error * chore: chat content can be copied * docs: update notes * fix: service switching error * chore: change to send cancel event to ws_cancel * chore: add ws-cancel --------- Co-authored-by: medcl <m@medcl.net>
This commit is contained in:
@@ -59,6 +59,8 @@ Information about release notes of Coco Server is provided here.
|
||||
- fix: server image loading failure #534
|
||||
- chore: skip register server that not logged in #536
|
||||
- refactor: service info related components #537
|
||||
- fix: service switching error #539
|
||||
- chore: chat content can be copied #539
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ pub async fn new_chat<R: Runtime>(
|
||||
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
log::debug!("New chat response: {}", &body_text);
|
||||
|
||||
let chat_response: GetResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
@@ -248,8 +250,8 @@ pub async fn assistant_search<R: Runtime>(
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
|
||||
@@ -95,8 +95,8 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
true, // disable_nagle
|
||||
Some(connector), // Connector
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
@@ -125,6 +125,7 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||
},
|
||||
Some(Err(_)) | None => {
|
||||
log::debug!("WebSocket connection closed or error");
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
@@ -132,7 +133,8 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.recv() => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
log::debug!("WebSocket connection cancelled");
|
||||
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isNil } from "lodash-es";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
useAsyncEffect,
|
||||
useDebounce,
|
||||
useKeyPress,
|
||||
usePagination,
|
||||
useReactive,
|
||||
} from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
@@ -17,18 +27,8 @@ import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { Post } from "@/api/axiosRequest";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
useAsyncEffect,
|
||||
useDebounce,
|
||||
useKeyPress,
|
||||
usePagination,
|
||||
useReactive,
|
||||
} from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import NoDataImage from "../Common/NoDataImage";
|
||||
import PopoverInput from "../Common/PopoverInput";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
interface AssistantListProps {
|
||||
assistantIDs?: string[];
|
||||
@@ -41,6 +41,7 @@ interface State {
|
||||
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { connected } = useChatStore();
|
||||
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const setAssistantList = useConnectStore((state) => state.setAssistantList);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
@@ -134,7 +134,6 @@ const ChatAI = memo(
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
@@ -165,7 +164,8 @@ const ChatAI = memo(
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
websocketSessionId,
|
||||
showChatHistory
|
||||
showChatHistory,
|
||||
isLogin
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
@@ -330,7 +330,6 @@ const ChatAI = memo(
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
// onNewChat={clearChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
fetchChatHistory={getChatHistory}
|
||||
@@ -340,7 +339,7 @@ const ChatAI = memo(
|
||||
)}
|
||||
|
||||
<ChatHeader
|
||||
onCreateNewChat={clearChat}
|
||||
clearChat={clearChat}
|
||||
onOpenChatAI={openChatAI}
|
||||
setIsSidebarOpen={toggleSidebar}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const ChatContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative">
|
||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Server } from "@/types/server"
|
||||
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onCreateNewChat: () => void;
|
||||
clearChat: () => void;
|
||||
onOpenChatAI: () => void;
|
||||
setIsSidebarOpen: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
@@ -31,7 +31,7 @@ interface ChatHeaderProps {
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
onCreateNewChat,
|
||||
clearChat,
|
||||
onOpenChatAI,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
@@ -99,10 +99,10 @@ export function ChatHeader({
|
||||
|
||||
{showChatHistory ? (
|
||||
<button
|
||||
onClick={onCreateNewChat}
|
||||
onClick={clearChat}
|
||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
|
||||
<VisibleKey shortcut={newSession} onKeyPress={clearChat}>
|
||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
@@ -131,7 +131,7 @@ export function ChatHeader({
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
reconnect={reconnect}
|
||||
onCreateNewChat={onCreateNewChat}
|
||||
clearChat={clearChat}
|
||||
/>
|
||||
|
||||
{isChatPage ? null : (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
// import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import type { Chat } from "@/types/chat";
|
||||
import HistoryList from "../Common/HistoryList";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
@@ -9,7 +8,6 @@ interface ChatSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
chats: Chat[];
|
||||
activeChat?: Chat;
|
||||
// onNewChat: () => void;
|
||||
onSelectChat: (chat: any) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
fetchChatHistory: () => void;
|
||||
@@ -21,7 +19,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
isSidebarOpen,
|
||||
chats,
|
||||
activeChat,
|
||||
// onNewChat,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
fetchChatHistory,
|
||||
@@ -52,14 +49,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
onRemove={onDeleteChat}
|
||||
/>
|
||||
)}
|
||||
{/* <Sidebar
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={onNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,14 +20,14 @@ interface ServerListProps {
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
reconnect: (server?: IServer) => void;
|
||||
onCreateNewChat: () => void;
|
||||
clearChat: () => void;
|
||||
}
|
||||
|
||||
export function ServerList({
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
reconnect,
|
||||
onCreateNewChat,
|
||||
clearChat,
|
||||
}: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -110,12 +110,13 @@ export function ServerList({
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
onCreateNewChat();
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsLogin(false);
|
||||
return;
|
||||
}
|
||||
//
|
||||
setIsLogin(true);
|
||||
// The Rust backend will automatically disconnect,
|
||||
// so we don't need to handle disconnection on the frontend
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import type { Chat } from "@/types/chat";
|
||||
|
||||
interface SidebarProps {
|
||||
chats: Chat[];
|
||||
activeChat: Chat | undefined;
|
||||
onNewChat: () => void;
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
className?: string;
|
||||
fetchChatHistory: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
chats,
|
||||
activeChat,
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
className = "",
|
||||
fetchChatHistory,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className="flex justify-between gap-1 p-4">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className={`flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
>
|
||||
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
|
||||
{t("assistant.sidebar.newChat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
fetchChatHistory();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat._id}
|
||||
className={`group relative rounded-xl transition-all ${
|
||||
activeChat?._id === chat._id
|
||||
? "bg-gray-100/80 dark:bg-gray-700/50"
|
||||
: "hover:bg-gray-50/80 dark:hover:bg-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left"
|
||||
onClick={() => onSelectChat(chat)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
activeChat?._id === chat._id
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`truncate ${
|
||||
activeChat?._id === chat._id
|
||||
? "text-gray-900 dark:text-white font-medium"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{chat?._source?.title || chat?._id}
|
||||
</span>
|
||||
</button>
|
||||
{activeChat?._id === chat._id && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[#0072FF]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export function useChatActions(
|
||||
isMCPActive?: boolean,
|
||||
changeInput?: (val: string) => void,
|
||||
websocketSessionId?: string,
|
||||
showChatHistory?: boolean
|
||||
showChatHistory?: boolean,
|
||||
isLogin?: boolean,
|
||||
) {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
@@ -315,8 +316,11 @@ export function useChatActions(
|
||||
const getChatHistory = useCallback(async () => {
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (!currentServiceId || !isLogin) {
|
||||
setChats([]);
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentServiceId) return [];
|
||||
response = await platformAdapter.commands("chat_history", {
|
||||
serverId: currentServiceId,
|
||||
from: 0,
|
||||
@@ -334,7 +338,6 @@ export function useChatActions(
|
||||
}
|
||||
console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
|
||||
setChats(hits);
|
||||
}, [currentServiceId, keyword, isTauri]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useWebSocket as useWebSocketAHook } from "ahooks";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Server } from "@/types/server";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
enum ReadyState {
|
||||
Connecting = 0,
|
||||
@@ -16,7 +17,6 @@ interface WebSocketProps {
|
||||
clientId: string;
|
||||
connected: boolean;
|
||||
setConnected: (connected: boolean) => void;
|
||||
currentService: Server | null;
|
||||
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
|
||||
onWebsocketSessionId?: (sessionId: string) => void;
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
}: WebSocketProps) {
|
||||
@@ -33,6 +32,8 @@ export default function useWebSocket({
|
||||
const endpoint_websocket = useAppStore((state) => state.endpoint_websocket);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const websocketIdRef = useRef<string>("");
|
||||
const messageQueue = useRef<string[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
@@ -58,6 +59,7 @@ export default function useWebSocket({
|
||||
connect(); // web
|
||||
}
|
||||
}, [isTauri, connect]);
|
||||
|
||||
const processMessage = useCallback(
|
||||
(msg: string) => {
|
||||
try {
|
||||
@@ -103,6 +105,7 @@ export default function useWebSocket({
|
||||
// src/components/Search/InputBox.tsx
|
||||
const reconnect = useCallback(
|
||||
async (server?: Server) => {
|
||||
setConnected(false); // Disconnect before attempting to reconnect
|
||||
if (isTauri) {
|
||||
const targetServer = server || currentService;
|
||||
if (!targetServer?.id) return;
|
||||
@@ -117,7 +120,7 @@ export default function useWebSocket({
|
||||
connect();
|
||||
}
|
||||
},
|
||||
[currentService]
|
||||
[currentService, clientId]
|
||||
);
|
||||
const disconnectWS = useCallback(async () => {
|
||||
if (!connected) return;
|
||||
@@ -140,23 +143,13 @@ export default function useWebSocket({
|
||||
},
|
||||
[dealMsgRef]
|
||||
);
|
||||
|
||||
const unlistenErrorRef = useRef<Promise<() => void> | null>(null);
|
||||
const unlistenCancelRef = useRef<Promise<() => void> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentService?.id) return;
|
||||
if (!isTauri || !currentService?.id) return;
|
||||
|
||||
let unlisten_error = null;
|
||||
let unlisten_message = null;
|
||||
|
||||
if (!isTauri) return;
|
||||
|
||||
unlisten_error = platformAdapter.listenEvent(`ws-error-${clientId}`, (event) => {
|
||||
console.error(`ws-error-${clientId}`, event, connected);
|
||||
if (connected) {
|
||||
addError("WebSocket connection failed.");
|
||||
}
|
||||
setConnected(false); // error
|
||||
});
|
||||
|
||||
unlisten_message = platformAdapter.listenEvent(`ws-message-${clientId}`, (event) => {
|
||||
const unlisten_message = platformAdapter.listenEvent(`ws-message-${clientId}`, (event) => {
|
||||
const msg = event.payload as string;
|
||||
// console.log(`ws-message-${clientId}`, msg);
|
||||
if (msg.includes("websocket-session-id")) {
|
||||
@@ -167,16 +160,37 @@ export default function useWebSocket({
|
||||
if (onWebsocketSessionId) {
|
||||
onWebsocketSessionId(sessionId);
|
||||
}
|
||||
// Listen for errors
|
||||
unlistenErrorRef.current = platformAdapter.listenEvent(`ws-error-${clientId}`, (event) => {
|
||||
if (connected) {
|
||||
const id = event.payload as string;
|
||||
console.error(`ws-error-${clientId}`, id === currentService?.id, connected);
|
||||
if (id === currentService?.id) {
|
||||
addError("WebSocket connection failed.");
|
||||
}
|
||||
}
|
||||
setConnected(false); // error
|
||||
});
|
||||
// Listen for cancel
|
||||
unlistenCancelRef.current = platformAdapter.listenEvent(`ws-cancel-${clientId}`, () => {
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
dealMsgRef.current && dealMsgRef.current(msg);
|
||||
dealMsgRef.current?.(msg);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten_error?.then((fn: any) => fn());
|
||||
unlisten_message?.then((fn: any) => fn());
|
||||
unlisten_message.then((fn) => fn());
|
||||
if (unlistenErrorRef.current) {
|
||||
unlistenErrorRef.current.then((fn) => fn());
|
||||
}
|
||||
if (unlistenCancelRef.current) {
|
||||
unlistenCancelRef.current.then((fn) => fn());
|
||||
}
|
||||
};
|
||||
}, [dealMsgRef]);
|
||||
}, [currentService?.id, dealMsgRef, connected, clientId]);
|
||||
|
||||
return { reconnect, disconnectWS, updateDealMsg };
|
||||
}
|
||||
@@ -241,4 +241,13 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-select-text {
|
||||
-webkit-touch-callout: text;
|
||||
-webkit-user-select: text;
|
||||
-khtml-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,9 +348,7 @@ export default function Chat({}: ChatProps) {
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
className={`border-t p-4 pb-0 border-gray-200 dark:border-gray-800`}
|
||||
>
|
||||
<div className={`border-t p-2 border-gray-200 dark:border-gray-800`}>
|
||||
<InputBox
|
||||
isChatMode={true}
|
||||
inputValue={input}
|
||||
|
||||
@@ -29,13 +29,9 @@ export interface EventPayloads {
|
||||
datasourceData_change: void;
|
||||
"ws-error": void;
|
||||
"ws-message": void;
|
||||
[key: `ws-error-${string}`]: {
|
||||
error: {
|
||||
reason: string;
|
||||
};
|
||||
status: number;
|
||||
};
|
||||
[key: `ws-error-${string}`]: string;
|
||||
[key: `ws-message-${string}`]: string;
|
||||
[key: `ws-cancel-${string}`]: string;
|
||||
"change-startup-store": IStartupStore;
|
||||
"change-shortcuts-store": IShortcutsStore;
|
||||
"change-connect-store": IConnectStore;
|
||||
|
||||
@@ -729,4 +729,13 @@ body,
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-select-text {
|
||||
-webkit-touch-callout: text;
|
||||
-webkit-user-select: text;
|
||||
-khtml-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user