feat: add localforage and conversations hook
This commit is contained in:
parent
78c13d7586
commit
aa4ede4427
@ -28,6 +28,7 @@
|
|||||||
"@fontsource/inter": "^5.0.18",
|
"@fontsource/inter": "^5.0.18",
|
||||||
"electron-updater": "^6.1.7",
|
"electron-updater": "^6.1.7",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
"react-router": "6",
|
"react-router": "6",
|
||||||
"react-router-dom": "6",
|
"react-router-dom": "6",
|
||||||
"styled-components": "^6.1.11"
|
"styled-components": "^6.1.11"
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import styled from 'styled-components'
|
|
||||||
import Sidebar from './components/app/Sidebar'
|
import Sidebar from './components/app/Sidebar'
|
||||||
import Statusbar from './components/app/Statusbar'
|
import Statusbar from './components/app/Statusbar'
|
||||||
import HomePage from './pages/home/HomePage'
|
import HomePage from './pages/home/HomePage'
|
||||||
@ -10,7 +9,6 @@ import '@fontsource/inter'
|
|||||||
function App(): JSX.Element {
|
function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MainContainer>
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
@ -18,15 +16,8 @@ function App(): JSX.Element {
|
|||||||
<Route path="/settings/*" element={<SettingsPage />} />
|
<Route path="/settings/*" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Statusbar />
|
<Statusbar />
|
||||||
</MainContainer>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContainer = styled.main`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex: 1;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
@import 'https://at.alicdn.com/t/c/font_4563475_m52ypmvg0jc.css';
|
@import 'https://at.alicdn.com/t/c/font_4563475_65n90fm9f93.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ev-c-white: #ffffff;
|
--ev-c-white: #ffffff;
|
||||||
@ -101,8 +101,17 @@ code {
|
|||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
#root {
|
#root {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
margin: 0;
|
||||||
width: 100%;
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,8 @@ const NavbarContainer = styled.div`
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: var(--navbar-height);
|
min-height: var(--navbar-height);
|
||||||
|
max-height: var(--navbar-height);
|
||||||
border-bottom: 1px solid #ffffff20;
|
border-bottom: 1px solid #ffffff20;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
`
|
`
|
||||||
|
|||||||
51
src/renderer/src/hooks/useConversactions.ts
Normal file
51
src/renderer/src/hooks/useConversactions.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { runAsyncFunction } from '@renderer/utils'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export type Conversation = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
lastMessage: string
|
||||||
|
lastMessageAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useConversations() {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||||
|
const [activeConversation, setActiveConversation] = useState<Conversation>()
|
||||||
|
|
||||||
|
// Use localforage to initialize conversations
|
||||||
|
useEffect(() => {
|
||||||
|
runAsyncFunction(async () => {
|
||||||
|
const conversations = await localforage.getItem<Conversation[]>('conversations')
|
||||||
|
conversations && setConversations(conversations)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update localforage
|
||||||
|
useEffect(() => {
|
||||||
|
localforage.setItem('conversations', conversations)
|
||||||
|
}, [conversations])
|
||||||
|
|
||||||
|
const addConversation = (conversation) => {
|
||||||
|
setConversations([...conversations, conversation])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeConversation = (conversationId) => {
|
||||||
|
setConversations(conversations.filter((c) => c.id !== conversationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConversation = (conversation) => {
|
||||||
|
setConversations(conversations.map((c) => (c.id === conversation.id ? conversation : c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
activeConversation,
|
||||||
|
setConversations,
|
||||||
|
addConversation,
|
||||||
|
removeConversation,
|
||||||
|
updateConversation,
|
||||||
|
setActiveConversation
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/renderer/src/init.ts
Normal file
13
src/renderer/src/init.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import localforage from 'localforage'
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
localforage.config({
|
||||||
|
driver: localforage.INDEXEDDB,
|
||||||
|
name: 'CherryAI',
|
||||||
|
version: 1.0,
|
||||||
|
storeName: 'cherryai',
|
||||||
|
description: 'Cherry AI storage'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
@ -3,6 +3,7 @@ import './assets/css/base.css'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import './init'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@ -1,67 +1,90 @@
|
|||||||
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
||||||
import { FC } from 'react'
|
import useConversations from '@renderer/hooks/useConversactions'
|
||||||
|
import { FC, useEffect } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
import Conversations from './components/Conversations'
|
||||||
|
import Chat from './components/Chat'
|
||||||
|
|
||||||
const HomePage: FC = () => {
|
const HomePage: FC = () => {
|
||||||
|
const { conversations, activeConversation, setActiveConversation, addConversation } = useConversations()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeConversation) {
|
||||||
|
setActiveConversation(conversations[0])
|
||||||
|
}
|
||||||
|
}, [activeConversation, conversations])
|
||||||
|
|
||||||
const onCreateConversation = () => {
|
const onCreateConversation = () => {
|
||||||
window.electron.ipcRenderer.send('storage.set', { key: 'conversations', value: [] })
|
const _conversation = {
|
||||||
|
// ID auto increment
|
||||||
|
id: Math.random().toString(),
|
||||||
|
name: 'New conversation',
|
||||||
|
// placeholder url
|
||||||
|
avatar: 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y',
|
||||||
|
lastMessage: 'message',
|
||||||
|
lastMessageAt: 'now'
|
||||||
|
}
|
||||||
|
addConversation(_conversation)
|
||||||
|
setActiveConversation(_conversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainContainer>
|
<Container>
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<NavbarLeft style={{ justifyContent: 'space-between' }}>
|
<NavbarLeft style={{ justifyContent: 'flex-end' }}>
|
||||||
<NewButton onClick={onCreateConversation}>new</NewButton>
|
<NewButton onClick={onCreateConversation}>
|
||||||
<NewButton onClick={onCreateConversation}>new</NewButton>
|
<i className="iconfont icon-a-addchat"></i>
|
||||||
|
</NewButton>
|
||||||
</NavbarLeft>
|
</NavbarLeft>
|
||||||
<NavbarCenter>Cherry AI</NavbarCenter>
|
<NavbarCenter>Cherry AI</NavbarCenter>
|
||||||
<NavbarRight />
|
<NavbarRight />
|
||||||
</Navbar>
|
</Navbar>
|
||||||
<ContentContainer>
|
<ContentContainer>
|
||||||
<Conversations />
|
<Conversations
|
||||||
<Chat />
|
conversations={conversations}
|
||||||
|
activeConversation={activeConversation}
|
||||||
|
onSelectConversation={setActiveConversation}
|
||||||
|
/>
|
||||||
|
<Chat activeConversation={activeConversation} />
|
||||||
<Settings />
|
<Settings />
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
</MainContainer>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContainer = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`
|
height: 100%;
|
||||||
|
|
||||||
const NewButton = styled.button`
|
|
||||||
-webkit-app-region: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--color-text-1);
|
|
||||||
background-color: var(--color-background-soft);
|
|
||||||
border: 1px solid var(--color-background-soft);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-background-soft-hover);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const ContentContainer = styled.div`
|
const ContentContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
`
|
|
||||||
|
|
||||||
const Conversations = styled.div`
|
|
||||||
display: flex;
|
|
||||||
min-width: var(--conversations-width);
|
|
||||||
border-right: 1px solid #ffffff20;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`
|
`
|
||||||
|
|
||||||
const Chat = styled.div`
|
const NewButton = styled.div`
|
||||||
|
-webkit-app-region: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex-direction: row;
|
||||||
flex: 1;
|
justify-content: center;
|
||||||
border-right: 1px solid #ffffff20;
|
align-items: center;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
color: var(--color-icon);
|
||||||
|
.iconfont {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-icon-white);
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const Settings = styled.div`
|
const Settings = styled.div`
|
||||||
|
|||||||
20
src/renderer/src/pages/home/components/Chat.tsx
Normal file
20
src/renderer/src/pages/home/components/Chat.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Conversation } from '@renderer/hooks/useConversactions'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeConversation?: Conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
const Chat: FC<Props> = ({ activeConversation }) => {
|
||||||
|
return <Container>{activeConversation?.lastMessage}</Container>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
border-right: 1px solid #ffffff20;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default Chat
|
||||||
70
src/renderer/src/pages/home/components/Conversations.tsx
Normal file
70
src/renderer/src/pages/home/components/Conversations.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { Conversation } from '@renderer/hooks/useConversactions'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversations: Conversation[]
|
||||||
|
activeConversation?: Conversation
|
||||||
|
onSelectConversation: (conversation: Conversation) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Conversations: FC<Props> = ({ conversations, activeConversation, onSelectConversation }) => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{conversations.map((conversation) => (
|
||||||
|
<Conversation
|
||||||
|
key={conversation.id}
|
||||||
|
onClick={() => onSelectConversation(conversation)}
|
||||||
|
className={conversation.id === activeConversation?.id ? 'active' : ''}>
|
||||||
|
<ConversationTime>{conversation.lastMessageAt}</ConversationTime>
|
||||||
|
<ConversationName>{conversation.name}</ConversationName>
|
||||||
|
<ConversationLastMessage>{conversation.lastMessage}</ConversationLastMessage>
|
||||||
|
</Conversation>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: var(--conversations-width);
|
||||||
|
border-right: 1px solid #ffffff20;
|
||||||
|
height: calc(100vh - var(--navbar-height) - var(--status-bar-height));
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Conversation = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ConversationTime = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
`
|
||||||
|
|
||||||
|
const ConversationName = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-weight: bold;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ConversationLastMessage = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
`
|
||||||
|
|
||||||
|
export default Conversations
|
||||||
46
src/renderer/src/utils/index.ts
Normal file
46
src/renderer/src/utils/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export const runAsyncFunction = async (fn: () => void) => {
|
||||||
|
await fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否是 json 字符串
|
||||||
|
* @param str 字符串
|
||||||
|
*/
|
||||||
|
export function isJSON(str: any): boolean {
|
||||||
|
if (typeof str !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return typeof JSON.parse(str) === 'object'
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delay = (seconds: number) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true)
|
||||||
|
}, seconds * 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waiting fn return true
|
||||||
|
**/
|
||||||
|
export const waitAsyncFunction = (fn: () => Promise<any>, interval = 200, stopTimeout = 60000) => {
|
||||||
|
let timeout = false
|
||||||
|
const timer = setTimeout(() => (timeout = true), stopTimeout)
|
||||||
|
|
||||||
|
return (async function check(): Promise<any> {
|
||||||
|
if (await fn()) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
return Promise.resolve()
|
||||||
|
} else if (!timeout) {
|
||||||
|
return delay(interval / 1000).then(check)
|
||||||
|
} else {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
19
yarn.lock
19
yarn.lock
@ -2889,6 +2889,11 @@ ignore@^5.2.0, ignore@^5.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
||||||
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
|
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
|
||||||
|
|
||||||
|
immediate@~3.0.5:
|
||||||
|
version "3.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||||
|
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
|
||||||
|
|
||||||
immutable@^4.0.0:
|
immutable@^4.0.0:
|
||||||
version "4.3.6"
|
version "4.3.6"
|
||||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
|
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
|
||||||
@ -3266,11 +3271,25 @@ levn@^0.4.1:
|
|||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
|
lie@3.1.1:
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||||
|
integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==
|
||||||
|
dependencies:
|
||||||
|
immediate "~3.0.5"
|
||||||
|
|
||||||
lines-and-columns@^1.1.6:
|
lines-and-columns@^1.1.6:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||||
|
|
||||||
|
localforage@^1.10.0:
|
||||||
|
version "1.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
|
||||||
|
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
|
||||||
|
dependencies:
|
||||||
|
lie "3.1.1"
|
||||||
|
|
||||||
locate-path@^6.0.0:
|
locate-path@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user