Initial commit: Telegram Web Client with bot chat sync
- Backend: FastAPI + Telethon v2 WebSocket server - Frontend: React + TypeScript + Vite + Zustand - Features: Phone auth, 2FA, real-time bot chat - Fix: Use chats= instead of from_users= to sync messages from all devices - Config: BOT_USERNAME=AnvilForgeBot Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Telegram Bot Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "telegram-web-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"typescript": "^5.8.4",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
323
frontend/src/App.css
Normal file
323
frontend/src/App.css
Normal file
@@ -0,0 +1,323 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0e1621;
|
||||
--bg-secondary: #17212b;
|
||||
--bg-message-out: #2b5278;
|
||||
--bg-message-in: #182533;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #8b9399;
|
||||
--accent: #5288c1;
|
||||
--accent-hover: #6ba0d5;
|
||||
--error: #e53935;
|
||||
--border: #0e1621;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app.connecting {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--bg-secondary);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auth Container */
|
||||
.auth-container {
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-container h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-container p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-container form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-container input {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-container input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-container input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-container button {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.auth-container button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auth-container button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-container button.secondary {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.auth-container button.secondary:hover:not(:disabled) {
|
||||
background: var(--bg-message-in);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Chat Container */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status.online::before {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.status.offline::before {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Message List */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message.outgoing {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message.incoming {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message.outgoing .message-content {
|
||||
background: var(--bg-message-out);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.incoming .message-content {
|
||||
background: var(--bg-message-in);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.sender {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.message.outgoing .sender {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Input */
|
||||
.message-input {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.message-input textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.message-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.message-input textarea::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-input button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.message-input button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.message-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
37
frontend/src/App.tsx
Normal file
37
frontend/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useChatStore } from './stores/chatStore';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { PhoneInput } from './components/Auth/PhoneInput';
|
||||
import { CodeInput } from './components/Auth/CodeInput';
|
||||
import { PasswordInput } from './components/Auth/PasswordInput';
|
||||
import { ChatContainer } from './components/Chat/ChatContainer';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const { authStep, connected, setAuthStep, setError } = useChatStore();
|
||||
const { sendCode, verifyCode, verifyPassword, sendMessage } = useWebSocket();
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
setAuthStep('phone');
|
||||
};
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="app connecting">
|
||||
<div className="loader" />
|
||||
<p>Connecting to server...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{authStep === 'phone' && <PhoneInput onSubmit={sendCode} />}
|
||||
{authStep === 'code' && <CodeInput onSubmit={verifyCode} onBack={handleBack} />}
|
||||
{authStep === 'password' && <PasswordInput onSubmit={verifyPassword} />}
|
||||
{authStep === 'authenticated' && <ChatContainer onSendMessage={sendMessage} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
48
frontend/src/components/Auth/CodeInput.tsx
Normal file
48
frontend/src/components/Auth/CodeInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (code: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function CodeInput({ onSubmit, onBack }: Props) {
|
||||
const [code, setCode] = useState('');
|
||||
const { phone, loading, error } = useChatStore();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (code.trim()) {
|
||||
onSubmit(code.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<h2>Verification Code</h2>
|
||||
<p>
|
||||
Enter the code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="12345"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !code.trim()}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
<button type="button" onClick={onBack} disabled={loading} className="secondary">
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/Auth/PasswordInput.tsx
Normal file
41
frontend/src/components/Auth/PasswordInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (password: string) => void;
|
||||
}
|
||||
|
||||
export function PasswordInput({ onSubmit }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const { loading, error } = useChatStore();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password) {
|
||||
onSubmit(password);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
<p>Enter your 2FA password</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? 'Verifying...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/Auth/PhoneInput.tsx
Normal file
41
frontend/src/components/Auth/PhoneInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (phone: string) => void;
|
||||
}
|
||||
|
||||
export function PhoneInput({ onSubmit }: Props) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const { loading, error } = useChatStore();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (phone.trim()) {
|
||||
onSubmit(phone.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<h2>Telegram Login</h2>
|
||||
<p>Enter your phone number to start chatting with the bot</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+82 10 1234 5678"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={loading || !phone.trim()}>
|
||||
{loading ? 'Sending...' : 'Send Code'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/Chat/ChatContainer.tsx
Normal file
32
frontend/src/components/Chat/ChatContainer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
|
||||
interface Props {
|
||||
onSendMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export function ChatContainer({ onSendMessage }: Props) {
|
||||
const { user, connected } = useChatStore();
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<header className="chat-header">
|
||||
<h1>Telegram Bot Chat</h1>
|
||||
<div className="user-info">
|
||||
<span className={`status ${connected ? 'online' : 'offline'}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
{user && (
|
||||
<span className="username">
|
||||
{user.firstName || user.username || `User ${user.id}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MessageList />
|
||||
<MessageInput onSend={onSendMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/Chat/MessageInput.tsx
Normal file
38
frontend/src/components/Chat/MessageInput.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, KeyboardEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string) => void;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
if (text.trim()) {
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-input">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
/>
|
||||
<button onClick={handleSend} disabled={!text.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/Chat/MessageList.tsx
Normal file
43
frontend/src/components/Chat/MessageList.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
|
||||
export function MessageList() {
|
||||
const { messages } = useChatStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No messages yet. Start chatting!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`message ${msg.is_outgoing ? 'outgoing' : 'incoming'}`}
|
||||
>
|
||||
<div className="message-content">
|
||||
<span className="sender">{msg.sender_name}</span>
|
||||
<p className="text">{msg.text}</p>
|
||||
<span className="time">{formatTime(msg.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/src/hooks/useWebSocket.ts
Normal file
201
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const WS_URL = import.meta.env.DEV
|
||||
? 'ws://localhost:8000/ws'
|
||||
: `wss://${window.location.host}/ws`;
|
||||
|
||||
export function useWebSocket() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const {
|
||||
setSessionId,
|
||||
setConnected,
|
||||
setAuthStep,
|
||||
setPhone,
|
||||
setPhoneCodeHash,
|
||||
setUser,
|
||||
addMessage,
|
||||
setMessages,
|
||||
setLoading,
|
||||
setError,
|
||||
} = useChatStore();
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setConnected(false);
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setError('Connection error');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: { type: string; data: Record<string, unknown> }) => {
|
||||
const { type, data } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
setSessionId(data.session_id as string);
|
||||
// Check auth status
|
||||
send({ type: 'check_auth', data: {} });
|
||||
break;
|
||||
|
||||
case 'auth_status':
|
||||
if (data.authenticated) {
|
||||
setUser({
|
||||
id: data.user_id as number,
|
||||
username: data.username as string,
|
||||
firstName: data.first_name as string,
|
||||
});
|
||||
setAuthStep('authenticated');
|
||||
// Get chat history
|
||||
send({ type: 'get_history', data: {} });
|
||||
} else {
|
||||
setAuthStep('phone');
|
||||
}
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'code_sent':
|
||||
setPhone(data.phone as string);
|
||||
setPhoneCodeHash(data.phone_code_hash as string);
|
||||
setAuthStep('code');
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'need_password':
|
||||
setAuthStep('password');
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'auth_success':
|
||||
setUser({
|
||||
id: data.user_id as number,
|
||||
username: data.username as string,
|
||||
firstName: data.first_name as string,
|
||||
});
|
||||
setAuthStep('authenticated');
|
||||
setLoading(false);
|
||||
// Get chat history
|
||||
send({ type: 'get_history', data: {} });
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
setError(data.message as string);
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'history':
|
||||
setMessages((data.messages as Message[]) || []);
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'message_sent':
|
||||
addMessage(data as unknown as Message);
|
||||
break;
|
||||
|
||||
case 'new_message':
|
||||
addMessage(data as unknown as Message);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setError(data.message as string);
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', type);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const send = useCallback((message: { type: string; data: Record<string, unknown> }) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendCode = useCallback((phone: string) => {
|
||||
setLoading(true);
|
||||
send({ type: 'send_code', data: { phone } });
|
||||
}, [send]);
|
||||
|
||||
const verifyCode = useCallback(
|
||||
(code: string) => {
|
||||
const { phone, phoneCodeHash } = useChatStore.getState();
|
||||
setLoading(true);
|
||||
send({
|
||||
type: 'verify_code',
|
||||
data: { phone, code, phone_code_hash: phoneCodeHash },
|
||||
});
|
||||
},
|
||||
[send]
|
||||
);
|
||||
|
||||
const verifyPassword = useCallback(
|
||||
(password: string) => {
|
||||
setLoading(true);
|
||||
send({ type: 'verify_password', data: { password } });
|
||||
},
|
||||
[send]
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text: string) => {
|
||||
send({ type: 'send_message', data: { text } });
|
||||
},
|
||||
[send]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
sendCode,
|
||||
verifyCode,
|
||||
verifyPassword,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
65
frontend/src/stores/chatStore.ts
Normal file
65
frontend/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Message, AuthStep } from '../types';
|
||||
|
||||
interface ChatState {
|
||||
// Connection
|
||||
sessionId: string | null;
|
||||
connected: boolean;
|
||||
|
||||
// Auth
|
||||
authStep: AuthStep;
|
||||
phone: string;
|
||||
phoneCodeHash: string;
|
||||
user: {
|
||||
id?: number;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
} | null;
|
||||
|
||||
// Messages
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
setSessionId: (id: string) => void;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setAuthStep: (step: AuthStep) => void;
|
||||
setPhone: (phone: string) => void;
|
||||
setPhoneCodeHash: (hash: string) => void;
|
||||
setUser: (user: ChatState['user']) => void;
|
||||
addMessage: (message: Message) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
sessionId: null,
|
||||
connected: false,
|
||||
authStep: 'phone' as AuthStep,
|
||||
phone: '',
|
||||
phoneCodeHash: '',
|
||||
user: null,
|
||||
messages: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setSessionId: (id) => set({ sessionId: id }),
|
||||
setConnected: (connected) => set({ connected }),
|
||||
setAuthStep: (step) => set({ authStep: step }),
|
||||
setPhone: (phone) => set({ phone }),
|
||||
setPhoneCodeHash: (hash) => set({ phoneCodeHash: hash }),
|
||||
setUser: (user) => set({ user }),
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
setMessages: (messages) => set({ messages }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
21
frontend/src/types/index.ts
Normal file
21
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Message {
|
||||
id: number;
|
||||
text: string | null;
|
||||
date: string;
|
||||
is_outgoing: boolean;
|
||||
sender_name: string | null;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
authenticated: boolean;
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AuthStep = 'phone' | 'code' | 'password' | 'authenticated';
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user