feat: add topic list items
This commit is contained in:
parent
e7a676975b
commit
2b4c4f46e6
@ -12,6 +12,7 @@ module.exports = {
|
|||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'unused-imports/no-unused-imports': 'error',
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
|
||||||
'sort-imports': [
|
'sort-imports': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -25,8 +25,8 @@
|
|||||||
--color-icon: #ffffff99;
|
--color-icon: #ffffff99;
|
||||||
--color-icon-white: #ffffff;
|
--color-icon-white: #ffffff;
|
||||||
|
|
||||||
--navbar-height: 40px;
|
--navbar-height: 45px;
|
||||||
--sidebar-width: 65px;
|
--sidebar-width: 68px;
|
||||||
--agents-width: 250px;
|
--agents-width: 250px;
|
||||||
--topic-list-width: var(--agents-width);
|
--topic-list-width: var(--agents-width);
|
||||||
--settings-width: 280px;
|
--settings-width: 280px;
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
color: #ccc;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const NavbarContainer = styled.div`
|
|||||||
min-height: var(--navbar-height);
|
min-height: var(--navbar-height);
|
||||||
max-height: var(--navbar-height);
|
max-height: var(--navbar-height);
|
||||||
background-color: #111;
|
background-color: #111;
|
||||||
border-bottom: 1px solid #ffffff20;
|
border-bottom: 0.5px solid #ffffff20;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
|
addConversation as _addConversation,
|
||||||
|
removeConversation as _removeConversation,
|
||||||
addAgent,
|
addAgent,
|
||||||
addConversationToAgent,
|
|
||||||
removeAgent,
|
removeAgent,
|
||||||
removeConversationFromAgent,
|
|
||||||
updateAgent
|
updateAgent
|
||||||
} from '@renderer/store/agents'
|
} from '@renderer/store/agents'
|
||||||
import { Agent } from '@renderer/types'
|
import { Agent, Conversation } from '@renderer/types'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
|
||||||
export default function useAgents() {
|
export default function useAgents() {
|
||||||
@ -23,12 +23,21 @@ export default function useAgents() {
|
|||||||
agent.conversations.forEach((id) => localforage.removeItem(`conversation:${id}`))
|
agent.conversations.forEach((id) => localforage.removeItem(`conversation:${id}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
|
updateAgent: (agent: Agent) => dispatch(updateAgent(agent))
|
||||||
addConversation: (agentId: string, conversationId: string) => {
|
}
|
||||||
dispatch(addConversationToAgent({ agentId, conversationId }))
|
}
|
||||||
|
|
||||||
|
export function useAgent(id: string) {
|
||||||
|
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
addConversation: (conversation: Conversation) => {
|
||||||
|
dispatch(_addConversation({ agentId: agent?.id!, conversation }))
|
||||||
},
|
},
|
||||||
removeConversation: (agentId: string, conversationId: string) => {
|
removeConversation: (conversation: Conversation) => {
|
||||||
dispatch(removeConversationFromAgent({ agentId, conversationId }))
|
dispatch(_removeConversation({ agentId: agent?.id!, conversation }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,8 +39,6 @@ const Agents: FC<Props> = ({ activeAgent, onActive }) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
console.debug('activeAgent', activeAgent)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import { Agent } from '@renderer/types'
|
import { Agent } from '@renderer/types'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import Inputbar from './Inputbar'
|
import Inputbar from './Inputbar'
|
||||||
import Conversations from './Conversations'
|
import Conversations from './Conversations'
|
||||||
import { uuid } from '@renderer/utils'
|
|
||||||
import { Flex } from 'antd'
|
import { Flex } from 'antd'
|
||||||
import TopicList from './TopicList'
|
import TopicList from './TopicList'
|
||||||
|
import { useAgent } from '@renderer/hooks/useAgents'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
const Chat: FC<Props> = ({ agent }) => {
|
const Chat: FC<Props> = (props) => {
|
||||||
const [conversationId, setConversationId] = useState<string>(agent?.conversations[0] || uuid())
|
const { agent } = useAgent(props.agent.id)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setConversationId(agent?.conversations[0] || uuid())
|
|
||||||
}, [agent])
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return null
|
return null
|
||||||
@ -25,10 +21,10 @@ const Chat: FC<Props> = ({ agent }) => {
|
|||||||
return (
|
return (
|
||||||
<Container id="chat">
|
<Container id="chat">
|
||||||
<Flex vertical flex={1} justify="space-between">
|
<Flex vertical flex={1} justify="space-between">
|
||||||
<Conversations agent={agent} conversationId={conversationId} />
|
<Conversations agent={agent} />
|
||||||
<Inputbar agent={agent} />
|
<Inputbar agent={agent} />
|
||||||
</Flex>
|
</Flex>
|
||||||
<TopicList />
|
<TopicList agent={agent} />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import useAgents from '@renderer/hooks/useAgents'
|
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||||
import { openaiProvider } from '@renderer/services/provider'
|
import { openaiProvider } from '@renderer/services/provider'
|
||||||
import { Agent, Conversation, Message } from '@renderer/types'
|
import { Agent, Conversation, Message } from '@renderer/types'
|
||||||
@ -8,28 +7,32 @@ import { FC, useCallback, useEffect, useState } from 'react'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import MessageItem from './Message'
|
import MessageItem from './Message'
|
||||||
import { reverse } from 'lodash'
|
import { reverse } from 'lodash'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
conversationId: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Conversations: FC<Props> = ({ agent, conversationId }) => {
|
const Conversations: FC<Props> = ({ agent }) => {
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [lastMessage, setLastMessage] = useState<Message | null>(null)
|
const [lastMessage, setLastMessage] = useState<Message | null>(null)
|
||||||
const { addConversation } = useAgents()
|
|
||||||
|
const { id: conversationId } = agent.conversations[0]
|
||||||
|
|
||||||
const onSendMessage = useCallback(
|
const onSendMessage = useCallback(
|
||||||
(message: Message) => {
|
(message: Message) => {
|
||||||
const _messages = [...messages, message]
|
const _messages = [...messages, message]
|
||||||
setMessages(_messages)
|
setMessages(_messages)
|
||||||
addConversation(agent.id, conversationId)
|
|
||||||
localforage.setItem<Conversation>(`conversation:${conversationId}`, {
|
const conversation = {
|
||||||
id: conversationId,
|
id: conversationId,
|
||||||
|
name: 'Default Topic',
|
||||||
messages: _messages
|
messages: _messages
|
||||||
})
|
}
|
||||||
|
|
||||||
|
localforage.setItem<Conversation>(`conversation:${conversationId}`, conversation)
|
||||||
},
|
},
|
||||||
[addConversation, agent.id, conversationId, messages]
|
[conversationId, messages]
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchChatCompletion = useCallback(
|
const fetchChatCompletion = useCallback(
|
||||||
@ -86,6 +89,8 @@ const Conversations: FC<Props> = ({ agent, conversationId }) => {
|
|||||||
})
|
})
|
||||||
}, [conversationId])
|
}, [conversationId])
|
||||||
|
|
||||||
|
useEffect(() => hljs.highlightAll())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container id="conversations">
|
<Container id="conversations">
|
||||||
{lastMessage && <MessageItem message={lastMessage} />}
|
{lastMessage && <MessageItem message={lastMessage} />}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||||
import { Agent, Message } from '@renderer/types'
|
import { Agent, Conversation, Message } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { MoreOutlined } from '@ant-design/icons'
|
import { MoreOutlined } from '@ant-design/icons'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { useShowRightSidebar } from '@renderer/hooks/useStore'
|
import { useShowRightSidebar } from '@renderer/hooks/useStore'
|
||||||
|
import { useAgent } from '@renderer/hooks/useAgents'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
@ -14,6 +15,7 @@ interface Props {
|
|||||||
const Inputbar: FC<Props> = ({ agent }) => {
|
const Inputbar: FC<Props> = ({ agent }) => {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const { setShowRightSidebar } = useShowRightSidebar()
|
const { setShowRightSidebar } = useShowRightSidebar()
|
||||||
|
const { addConversation } = useAgent(agent.id)
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
@ -35,18 +37,27 @@ const Inputbar: FC<Props> = ({ agent }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addNewConversation = () => {
|
||||||
|
const conversation: Conversation = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Default Topic',
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
addConversation(conversation)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarMenu>
|
<ToolbarMenu>
|
||||||
<Tooltip placement="top" title=" New Chat " arrow>
|
<Tooltip placement="top" title=" New Chat " arrow>
|
||||||
<ToolbarItem>
|
<ToolbarItem onClick={addNewConversation}>
|
||||||
<i className="iconfont icon-a-new-chat"></i>
|
<i className="iconfont icon-a-new-chat"></i>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip placement="top" title=" Topics " arrow>
|
<Tooltip placement="top" title=" Topics " arrow>
|
||||||
<ToolbarItem onClick={setShowRightSidebar}>
|
<ToolbarItem onClick={setShowRightSidebar}>
|
||||||
<i className="iconfont icon-textedit_text_topic"></i>
|
<i className="iconfont icon-textedit_text_topic" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ToolbarMenu>
|
</ToolbarMenu>
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import { Message } from '@renderer/types'
|
import { Message } from '@renderer/types'
|
||||||
import { Avatar } from 'antd'
|
import { Avatar } from 'antd'
|
||||||
import hljs from 'highlight.js'
|
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { FC, useEffect } from 'react'
|
import { FC } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
import Logo from '@renderer/assets/images/logo.png'
|
||||||
|
|
||||||
const MessageItem: FC<{ message: Message }> = ({ message }) => {
|
const MessageItem: FC<{ message: Message }> = ({ message }) => {
|
||||||
useEffect(() => {
|
|
||||||
hljs.highlightAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContainer key={message.id}>
|
<MessageContainer key={message.id}>
|
||||||
<AvatarWrapper>
|
<AvatarWrapper>
|
||||||
<Avatar alt="Alice Swift">Y</Avatar>
|
{message.role === 'agent' ? <Avatar src={Logo} /> : <Avatar alt="Alice Swift">Y</Avatar>}
|
||||||
</AvatarWrapper>
|
</AvatarWrapper>
|
||||||
<div className="markdown" dangerouslySetInnerHTML={{ __html: marked(message.content) }}></div>
|
<div className="markdown" dangerouslySetInnerHTML={{ __html: marked(message.content) }}></div>
|
||||||
</MessageContainer>
|
</MessageContainer>
|
||||||
|
|||||||
@ -1,21 +1,61 @@
|
|||||||
import { useShowRightSidebar } from '@renderer/hooks/useStore'
|
import { useShowRightSidebar } from '@renderer/hooks/useStore'
|
||||||
import { FC } from 'react'
|
import { Agent } from '@renderer/types'
|
||||||
|
import { FC, useEffect, useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
const TopicList: FC = () => {
|
interface Props {
|
||||||
const { showRightSidebar } = useShowRightSidebar()
|
agent: Agent
|
||||||
|
}
|
||||||
|
|
||||||
return <Container className={showRightSidebar ? '' : 'collapsed'}></Container>
|
const TopicList: FC<Props> = ({ agent }) => {
|
||||||
|
const { showRightSidebar } = useShowRightSidebar()
|
||||||
|
const [activeTopic, setActiveTopic] = useState(agent.conversations[0])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTopic(agent.conversations[0])
|
||||||
|
}, [agent.conversations, agent.id])
|
||||||
|
|
||||||
|
if (!showRightSidebar) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className={showRightSidebar ? '' : 'collapsed'}>
|
||||||
|
{agent.conversations.map((topic) => (
|
||||||
|
<TopicListItem
|
||||||
|
key={topic.id}
|
||||||
|
className={topic.id === activeTopic?.id ? 'active' : ''}
|
||||||
|
onClick={() => setActiveTopic(topic)}>
|
||||||
|
{topic.name}
|
||||||
|
</TopicListItem>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
width: var(--topic-list-width);
|
width: var(--topic-list-width);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-left: 0.5px solid #ffffff20;
|
border-left: 0.5px solid #ffffff20;
|
||||||
|
padding: 10px;
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 0;
|
width: 0;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const TopicListItem = styled.div`
|
||||||
|
padding: 8px 15px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export default TopicList
|
export default TopicList
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
export function getDefaultAgent() {
|
import { Agent } from '@renderer/types'
|
||||||
|
import { uuid } from '@renderer/utils'
|
||||||
|
|
||||||
|
export function getDefaultAgent(): Agent {
|
||||||
return {
|
return {
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'Default Agent',
|
name: 'Default Agent',
|
||||||
description: "Hello, I'm Default Agent.",
|
description: "Hello, I'm Default Agent.",
|
||||||
conversations: []
|
conversations: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Default Topic',
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { getDefaultAgent } from '@renderer/services/agent'
|
import { getDefaultAgent } from '@renderer/services/agent'
|
||||||
import { Agent } from '@renderer/types'
|
import { Agent, Conversation } from '@renderer/types'
|
||||||
|
import { uniqBy } from 'lodash'
|
||||||
|
|
||||||
export interface AgentsState {
|
export interface AgentsState {
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
@ -23,22 +24,23 @@ const agentsSlice = createSlice({
|
|||||||
updateAgent: (state, action: PayloadAction<Agent>) => {
|
updateAgent: (state, action: PayloadAction<Agent>) => {
|
||||||
state.agents = state.agents.map((c) => (c.id === action.payload.id ? action.payload : c))
|
state.agents = state.agents.map((c) => (c.id === action.payload.id ? action.payload : c))
|
||||||
},
|
},
|
||||||
addConversationToAgent: (state, action: PayloadAction<{ agentId: string; conversationId: string }>) => {
|
addConversation: (state, action: PayloadAction<{ agentId: string; conversation: Conversation }>) => {
|
||||||
|
console.debug(action.payload)
|
||||||
state.agents = state.agents.map((agent) =>
|
state.agents = state.agents.map((agent) =>
|
||||||
agent.id === action.payload.agentId
|
agent.id === action.payload.agentId
|
||||||
? {
|
? {
|
||||||
...agent,
|
...agent,
|
||||||
conversations: [...new Set([...agent.conversations, action.payload.conversationId])]
|
conversations: uniqBy([action.payload.conversation, ...agent.conversations], 'id')
|
||||||
}
|
}
|
||||||
: agent
|
: agent
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
removeConversationFromAgent: (state, action: PayloadAction<{ agentId: string; conversationId: string }>) => {
|
removeConversation: (state, action: PayloadAction<{ agentId: string; conversation: Conversation }>) => {
|
||||||
state.agents = state.agents.map((agent) =>
|
state.agents = state.agents.map((agent) =>
|
||||||
agent.id === action.payload.agentId
|
agent.id === action.payload.agentId
|
||||||
? {
|
? {
|
||||||
...agent,
|
...agent,
|
||||||
conversations: agent.conversations.filter((id) => id !== action.payload.conversationId)
|
conversations: agent.conversations.filter(({ id }) => id !== action.payload.conversation.id)
|
||||||
}
|
}
|
||||||
: agent
|
: agent
|
||||||
)
|
)
|
||||||
@ -46,7 +48,6 @@ const agentsSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { addAgent, removeAgent, updateAgent, addConversationToAgent, removeConversationFromAgent } =
|
export const { addAgent, removeAgent, updateAgent, addConversation, removeConversation } = agentsSlice.actions
|
||||||
agentsSlice.actions
|
|
||||||
|
|
||||||
export default agentsSlice.reducer
|
export default agentsSlice.reducer
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export interface SettingsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SettingsState = {
|
const initialState: SettingsState = {
|
||||||
showRightSidebar: true
|
showRightSidebar: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsSlice = createSlice({
|
const settingsSlice = createSlice({
|
||||||
|
|||||||
@ -2,7 +2,7 @@ export type Agent = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
conversations: string[]
|
conversations: Conversation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
@ -16,6 +16,7 @@ export type Message = {
|
|||||||
|
|
||||||
export type Conversation = {
|
export type Conversation = {
|
||||||
id: string
|
id: string
|
||||||
|
name: string
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user