perf: remove unnecessary rerender when user typing input (#1818)
Co-authored-by: Faisal Amir <urmauur@gmail.com>
This commit is contained in:
parent
edaf6bb5f7
commit
bb47d6869d
@ -22,7 +22,6 @@ import { extensionManager } from '@/extension'
|
|||||||
import {
|
import {
|
||||||
addNewMessageAtom,
|
addNewMessageAtom,
|
||||||
updateMessageAtom,
|
updateMessageAtom,
|
||||||
generateResponseAtom,
|
|
||||||
} from '@/helpers/atoms/ChatMessage.atom'
|
} from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import {
|
import {
|
||||||
updateThreadWaitingForResponseAtom,
|
updateThreadWaitingForResponseAtom,
|
||||||
@ -35,7 +34,6 @@ export default function EventHandler({ children }: { children: ReactNode }) {
|
|||||||
const { downloadedModels } = useGetDownloadedModels()
|
const { downloadedModels } = useGetDownloadedModels()
|
||||||
const setActiveModel = useSetAtom(activeModelAtom)
|
const setActiveModel = useSetAtom(activeModelAtom)
|
||||||
const setStateModel = useSetAtom(stateModelAtom)
|
const setStateModel = useSetAtom(stateModelAtom)
|
||||||
const setGenerateResponse = useSetAtom(generateResponseAtom)
|
|
||||||
|
|
||||||
const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
|
const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
|
||||||
const threads = useAtomValue(threadsAtom)
|
const threads = useAtomValue(threadsAtom)
|
||||||
@ -52,7 +50,6 @@ export default function EventHandler({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const onNewMessageResponse = useCallback(
|
const onNewMessageResponse = useCallback(
|
||||||
(message: ThreadMessage) => {
|
(message: ThreadMessage) => {
|
||||||
setGenerateResponse(false)
|
|
||||||
addNewMessage(message)
|
addNewMessage(message)
|
||||||
},
|
},
|
||||||
[addNewMessage]
|
[addNewMessage]
|
||||||
@ -96,7 +93,6 @@ export default function EventHandler({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const onMessageResponseUpdate = useCallback(
|
const onMessageResponseUpdate = useCallback(
|
||||||
(message: ThreadMessage) => {
|
(message: ThreadMessage) => {
|
||||||
setGenerateResponse(false)
|
|
||||||
updateMessage(
|
updateMessage(
|
||||||
message.id,
|
message.id,
|
||||||
message.thread_id,
|
message.thread_id,
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Stores all chat messages for all threads
|
* Stores all chat messages for all threads
|
||||||
*/
|
*/
|
||||||
export const generateResponseAtom = atom<boolean>(false)
|
|
||||||
|
|
||||||
export const chatMessages = atom<Record<string, ThreadMessage[]>>({})
|
export const chatMessages = atom<Record<string, ThreadMessage[]>>({})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
15
web/hooks/useInference.ts
Normal file
15
web/hooks/useInference.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
import { threadStatesAtom } from '@/helpers/atoms/Thread.atom'
|
||||||
|
|
||||||
|
export default function useInference() {
|
||||||
|
const threadStates = useAtomValue(threadStatesAtom)
|
||||||
|
|
||||||
|
const isGeneratingResponse = Object.values(threadStates).some(
|
||||||
|
(threadState) => threadState.waitingForResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGeneratingResponse,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatCompletionMessage,
|
ChatCompletionMessage,
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
ChatCompletionMessageContentType,
|
ChatCompletionMessageContentType,
|
||||||
AssistantTool,
|
AssistantTool,
|
||||||
} from '@janhq/core'
|
} from '@janhq/core'
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
|
||||||
import { ulid } from 'ulid'
|
import { ulid } from 'ulid'
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ import { useActiveModel } from './useActiveModel'
|
|||||||
import { extensionManager } from '@/extension/ExtensionManager'
|
import { extensionManager } from '@/extension/ExtensionManager'
|
||||||
import {
|
import {
|
||||||
addNewMessageAtom,
|
addNewMessageAtom,
|
||||||
generateResponseAtom,
|
|
||||||
getCurrentChatMessagesAtom,
|
getCurrentChatMessagesAtom,
|
||||||
} from '@/helpers/atoms/ChatMessage.atom'
|
} from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import {
|
import {
|
||||||
@ -48,29 +47,30 @@ import {
|
|||||||
updateThreadWaitingForResponseAtom,
|
updateThreadWaitingForResponseAtom,
|
||||||
} from '@/helpers/atoms/Thread.atom'
|
} from '@/helpers/atoms/Thread.atom'
|
||||||
|
|
||||||
|
export const queuedMessageAtom = atom(false)
|
||||||
|
export const reloadModelAtom = atom(false)
|
||||||
|
|
||||||
export default function useSendChatMessage() {
|
export default function useSendChatMessage() {
|
||||||
const activeThread = useAtomValue(activeThreadAtom)
|
const activeThread = useAtomValue(activeThreadAtom)
|
||||||
const addNewMessage = useSetAtom(addNewMessageAtom)
|
const addNewMessage = useSetAtom(addNewMessageAtom)
|
||||||
const updateThread = useSetAtom(updateThreadAtom)
|
const updateThread = useSetAtom(updateThreadAtom)
|
||||||
const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
|
const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
|
||||||
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom)
|
const setCurrentPrompt = useSetAtom(currentPromptAtom)
|
||||||
const setGenerateResponse = useSetAtom(generateResponseAtom)
|
|
||||||
|
|
||||||
const currentMessages = useAtomValue(getCurrentChatMessagesAtom)
|
const currentMessages = useAtomValue(getCurrentChatMessagesAtom)
|
||||||
const { activeModel } = useActiveModel()
|
const { activeModel } = useActiveModel()
|
||||||
const selectedModel = useAtomValue(selectedModelAtom)
|
const selectedModel = useAtomValue(selectedModelAtom)
|
||||||
const { startModel } = useActiveModel()
|
const { startModel } = useActiveModel()
|
||||||
const [queuedMessage, setQueuedMessage] = useState(false)
|
const setQueuedMessage = useSetAtom(queuedMessageAtom)
|
||||||
|
|
||||||
const modelRef = useRef<Model | undefined>()
|
const modelRef = useRef<Model | undefined>()
|
||||||
const threadStates = useAtomValue(threadStatesAtom)
|
const threadStates = useAtomValue(threadStatesAtom)
|
||||||
const updateThreadInitSuccess = useSetAtom(updateThreadInitSuccessAtom)
|
const updateThreadInitSuccess = useSetAtom(updateThreadInitSuccessAtom)
|
||||||
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
|
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
|
||||||
|
|
||||||
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
|
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
|
||||||
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
|
|
||||||
|
|
||||||
const [reloadModel, setReloadModel] = useState(false)
|
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
|
||||||
|
const setReloadModel = useSetAtom(reloadModelAtom)
|
||||||
const [fileUpload, setFileUpload] = useAtom(fileUploadAtom)
|
const [fileUpload, setFileUpload] = useAtom(fileUploadAtom)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -82,9 +82,7 @@ export default function useSendChatMessage() {
|
|||||||
console.error('No active thread')
|
console.error('No active thread')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateThreadWaiting(activeThread.id, true)
|
updateThreadWaiting(activeThread.id, true)
|
||||||
|
|
||||||
const messages: ChatCompletionMessage[] = [
|
const messages: ChatCompletionMessage[] = [
|
||||||
activeThread.assistants[0]?.instructions,
|
activeThread.assistants[0]?.instructions,
|
||||||
]
|
]
|
||||||
@ -121,19 +119,19 @@ export default function useSendChatMessage() {
|
|||||||
if (activeModel?.id !== modelId) {
|
if (activeModel?.id !== modelId) {
|
||||||
setQueuedMessage(true)
|
setQueuedMessage(true)
|
||||||
startModel(modelId)
|
startModel(modelId)
|
||||||
await WaitForModelStarting(modelId)
|
await waitForModelStarting(modelId)
|
||||||
setQueuedMessage(false)
|
setQueuedMessage(false)
|
||||||
}
|
}
|
||||||
events.emit(MessageEvent.OnMessageSent, messageRequest)
|
events.emit(MessageEvent.OnMessageSent, messageRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Refactor @louis
|
// TODO: Refactor @louis
|
||||||
const WaitForModelStarting = async (modelId: string) => {
|
const waitForModelStarting = async (modelId: string) => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (modelRef.current?.id !== modelId) {
|
if (modelRef.current?.id !== modelId) {
|
||||||
console.debug('waiting for model to start')
|
console.debug('waiting for model to start')
|
||||||
await WaitForModelStarting(modelId)
|
await waitForModelStarting(modelId)
|
||||||
resolve()
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
resolve()
|
resolve()
|
||||||
@ -142,10 +140,8 @@ export default function useSendChatMessage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendChatMessage = async () => {
|
const sendChatMessage = async (message: string) => {
|
||||||
setGenerateResponse(true)
|
if (!message || message.trim().length === 0) return
|
||||||
|
|
||||||
if (!currentPrompt || currentPrompt.trim().length === 0) return
|
|
||||||
|
|
||||||
if (!activeThread) {
|
if (!activeThread) {
|
||||||
console.error('No active thread')
|
console.error('No active thread')
|
||||||
@ -199,7 +195,7 @@ export default function useSendChatMessage() {
|
|||||||
|
|
||||||
updateThreadWaiting(activeThread.id, true)
|
updateThreadWaiting(activeThread.id, true)
|
||||||
|
|
||||||
const prompt = currentPrompt.trim()
|
const prompt = message.trim()
|
||||||
setCurrentPrompt('')
|
setCurrentPrompt('')
|
||||||
|
|
||||||
const base64Blob = fileUpload[0]
|
const base64Blob = fileUpload[0]
|
||||||
@ -335,7 +331,7 @@ export default function useSendChatMessage() {
|
|||||||
if (activeModel?.id !== modelId) {
|
if (activeModel?.id !== modelId) {
|
||||||
setQueuedMessage(true)
|
setQueuedMessage(true)
|
||||||
startModel(modelId)
|
startModel(modelId)
|
||||||
await WaitForModelStarting(modelId)
|
await waitForModelStarting(modelId)
|
||||||
setQueuedMessage(false)
|
setQueuedMessage(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,9 +342,7 @@ export default function useSendChatMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reloadModel,
|
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
resendChatMessage,
|
resendChatMessage,
|
||||||
queuedMessage,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,23 +15,21 @@ import { MainViewState } from '@/constants/screens'
|
|||||||
import { activeModelAtom } from '@/hooks/useActiveModel'
|
import { activeModelAtom } from '@/hooks/useActiveModel'
|
||||||
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
|
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
|
||||||
|
|
||||||
|
import useInference from '@/hooks/useInference'
|
||||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||||
|
|
||||||
import ChatItem from '../ChatItem'
|
import ChatItem from '../ChatItem'
|
||||||
|
|
||||||
import ErrorMessage from '../ErrorMessage'
|
import ErrorMessage from '../ErrorMessage'
|
||||||
|
|
||||||
import {
|
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
generateResponseAtom,
|
|
||||||
getCurrentChatMessagesAtom,
|
|
||||||
} from '@/helpers/atoms/ChatMessage.atom'
|
|
||||||
|
|
||||||
const ChatBody: React.FC = () => {
|
const ChatBody: React.FC = () => {
|
||||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||||
const activeModel = useAtomValue(activeModelAtom)
|
const activeModel = useAtomValue(activeModelAtom)
|
||||||
const { downloadedModels } = useGetDownloadedModels()
|
const { downloadedModels } = useGetDownloadedModels()
|
||||||
const { setMainViewState } = useMainViewState()
|
const { setMainViewState } = useMainViewState()
|
||||||
const generateResponse = useAtomValue(generateResponseAtom)
|
const { isGeneratingResponse } = useInference()
|
||||||
|
|
||||||
if (downloadedModels.length === 0)
|
if (downloadedModels.length === 0)
|
||||||
return (
|
return (
|
||||||
@ -101,7 +99,7 @@ const ChatBody: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{activeModel &&
|
{activeModel &&
|
||||||
(generateResponse ||
|
(isGeneratingResponse ||
|
||||||
(messages.length &&
|
(messages.length &&
|
||||||
messages[messages.length - 1].status ===
|
messages[messages.length - 1].status ===
|
||||||
MessageStatus.Pending &&
|
MessageStatus.Pending &&
|
||||||
|
|||||||
@ -64,13 +64,18 @@ const ChatInput: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWaitingToSend && activeThreadId) {
|
if (isWaitingToSend && activeThreadId) {
|
||||||
setIsWaitingToSend(false)
|
setIsWaitingToSend(false)
|
||||||
sendChatMessage()
|
sendChatMessage(currentPrompt)
|
||||||
}
|
}
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.focus()
|
textareaRef.current.focus()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [
|
||||||
}, [waitingToSendMessage, activeThreadId])
|
activeThreadId,
|
||||||
|
isWaitingToSend,
|
||||||
|
currentPrompt,
|
||||||
|
setIsWaitingToSend,
|
||||||
|
sendChatMessage,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@ -81,13 +86,11 @@ const ChatInput: React.FC = () => {
|
|||||||
}, [currentPrompt])
|
}, [currentPrompt])
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
if (!e.shiftKey) {
|
e.preventDefault()
|
||||||
e.preventDefault()
|
if (messages[messages.length - 1]?.status !== MessageStatus.Pending)
|
||||||
if (messages[messages.length - 1]?.status !== MessageStatus.Pending)
|
sendChatMessage(currentPrompt)
|
||||||
sendChatMessage()
|
else onStopInferenceClick()
|
||||||
else onStopInferenceClick()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +240,7 @@ const ChatInput: React.FC = () => {
|
|||||||
}
|
}
|
||||||
themes="primary"
|
themes="primary"
|
||||||
className="min-w-[100px]"
|
className="min-w-[100px]"
|
||||||
onClick={sendChatMessage}
|
onClick={() => sendChatMessage(currentPrompt)}
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import useSendChatMessage from '@/hooks/useSendChatMessage'
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
import { queuedMessageAtom } from '@/hooks/useSendChatMessage'
|
||||||
|
|
||||||
const MessageQueuedBanner: React.FC = () => {
|
const MessageQueuedBanner: React.FC = () => {
|
||||||
const { queuedMessage } = useSendChatMessage()
|
const queuedMessage = useAtomValue(queuedMessageAtom)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import ModelStart from '@/containers/Loader/ModelStart'
|
|||||||
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
|
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
|
||||||
import { showLeftSideBarAtom } from '@/containers/Providers/KeyListener'
|
import { showLeftSideBarAtom } from '@/containers/Providers/KeyListener'
|
||||||
|
|
||||||
import useSendChatMessage from '@/hooks/useSendChatMessage'
|
import { queuedMessageAtom, reloadModelAtom } from '@/hooks/useSendChatMessage'
|
||||||
|
|
||||||
import ChatBody from '@/screens/Chat/ChatBody'
|
import ChatBody from '@/screens/Chat/ChatBody'
|
||||||
|
|
||||||
@ -30,20 +30,37 @@ import {
|
|||||||
engineParamsUpdateAtom,
|
engineParamsUpdateAtom,
|
||||||
} from '@/helpers/atoms/Thread.atom'
|
} from '@/helpers/atoms/Thread.atom'
|
||||||
|
|
||||||
|
const renderError = (code: string) => {
|
||||||
|
switch (code) {
|
||||||
|
case 'multiple-upload':
|
||||||
|
return 'Currently, we only support 1 attachment at the same time'
|
||||||
|
|
||||||
|
case 'retrieval-off':
|
||||||
|
return 'Turn on Retrieval in Assistant Settings to use this feature'
|
||||||
|
|
||||||
|
case 'file-invalid-type':
|
||||||
|
return 'We do not support this file type'
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'Oops, something error, please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ChatScreen: React.FC = () => {
|
const ChatScreen: React.FC = () => {
|
||||||
const setCurrentPrompt = useSetAtom(currentPromptAtom)
|
const setCurrentPrompt = useSetAtom(currentPromptAtom)
|
||||||
const activeThread = useAtomValue(activeThreadAtom)
|
const activeThread = useAtomValue(activeThreadAtom)
|
||||||
const showLeftSideBar = useAtomValue(showLeftSideBarAtom)
|
const showLeftSideBar = useAtomValue(showLeftSideBarAtom)
|
||||||
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
|
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
|
||||||
const { queuedMessage, reloadModel } = useSendChatMessage()
|
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
const queuedMessage = useAtomValue(queuedMessageAtom)
|
||||||
|
const reloadModel = useAtomValue(reloadModelAtom)
|
||||||
const [dragRejected, setDragRejected] = useState({ code: '' })
|
const [dragRejected, setDragRejected] = useState({ code: '' })
|
||||||
const setFileUpload = useSetAtom(fileUploadAtom)
|
const setFileUpload = useSetAtom(fileUploadAtom)
|
||||||
const { getRootProps, isDragReject } = useDropzone({
|
const { getRootProps, isDragReject } = useDropzone({
|
||||||
noClick: true,
|
noClick: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
accept: {
|
accept: {
|
||||||
// 'image/*': ['.png', '.jpg', '.jpeg'],
|
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -104,22 +121,6 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
}, [dragRejected.code])
|
}, [dragRejected.code])
|
||||||
|
|
||||||
const renderError = (code: string) => {
|
|
||||||
switch (code) {
|
|
||||||
case 'multiple-upload':
|
|
||||||
return 'Currently, we only support 1 attachment at the same time'
|
|
||||||
|
|
||||||
case 'retrieval-off':
|
|
||||||
return 'Turn on Retrieval in Assistant Settings to use this feature'
|
|
||||||
|
|
||||||
case 'file-invalid-type':
|
|
||||||
return 'We do not support this file type'
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 'Oops, something error, please try again.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
{/* Left side bar */}
|
{/* Left side bar */}
|
||||||
@ -216,6 +217,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
<ChatInput />
|
<ChatInput />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side bar */}
|
{/* Right side bar */}
|
||||||
{activeThread && <Sidebar />}
|
{activeThread && <Sidebar />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user