fix channel cache not updating
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,18 +0,0 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -1,21 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"tailwindcss": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
Generated
-1800
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,31 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||
import AuthPage from "./components/AuthPage.jsx"
|
||||
import MainApp from "./components/MainApp"
|
||||
import SettingsPage from "./components/SettingsPage"
|
||||
import "./index.css"
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return <Navigate to="/auth" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<MainApp />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
const BASE = 'http://localhost:8080'
|
||||
|
||||
export async function apiFetch(method, path, { body, query } = {}) {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
let url = BASE + path
|
||||
if (query) url += '?' + new URLSearchParams(query)
|
||||
const opts = { method, headers: token ? { token } : {} }
|
||||
if (body) opts.body = body instanceof FormData ? body : new URLSearchParams(body)
|
||||
const res = await fetch(url, opts)
|
||||
if (!res.ok) { const errText = await res.text(); throw new Error(`${method} ${path} → ${res.status}: ${errText}`) }
|
||||
const text = await res.text()
|
||||
try { return JSON.parse(text) } catch { return text }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
let socket = null
|
||||
const handlers = {}
|
||||
|
||||
export function wsOnEvent(type, fn) {
|
||||
handlers[type] = fn
|
||||
}
|
||||
|
||||
export function wsConnect() {
|
||||
if (socket) return
|
||||
const token = localStorage.getItem('token') || ''
|
||||
socket = new WebSocket('ws://localhost:8080/ws')
|
||||
socket.onopen = () => socket.send(JSON.stringify({ token }))
|
||||
socket.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
handlers[msg.type]?.(msg.event)
|
||||
} catch (err) { console.error('[ws] message error', err) }
|
||||
}
|
||||
socket.onclose = () => { socket = null }
|
||||
socket.onerror = (err) => console.error('[ws] error', err)
|
||||
}
|
||||
|
||||
export function wsDisconnect() {
|
||||
socket?.close()
|
||||
socket = null
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const BASE = 'http://localhost:8080'
|
||||
|
||||
export default function AuthPage() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function doLogin() {
|
||||
const res = await fetch(`${BASE}/token`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ username, password }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Invalid credentials')
|
||||
const { token, userId } = await res.json()
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('userId', userId)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await doLogin()
|
||||
} catch {
|
||||
setError('Invalid credentials')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/user`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ username, password }),
|
||||
})
|
||||
if (!res.ok) { setError('Registration failed'); return }
|
||||
await doLogin()
|
||||
} catch {
|
||||
setError('Could not reach server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Enter' && !loading) handleLogin()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="flex flex-col gap-4 p-8 bg-gray-800 rounded-lg w-80">
|
||||
<h1 className="text-white text-xl font-semibold text-center">go-socket</h1>
|
||||
<input
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 text-white disabled:opacity-50"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
import { connectionStatus } from '../utils/connection'
|
||||
import MessageList from './MessageList'
|
||||
import MessageInput from './MessageInput'
|
||||
|
||||
export default function ChatArea() {
|
||||
const { state, userId } = useApp()
|
||||
const conn = state.connections.find(c => c.id === state.selectedId)
|
||||
const [pendingFile, setPendingFile] = useState(null)
|
||||
const [dragCount, setDragCount] = useState(0)
|
||||
const isDragging = dragCount > 0
|
||||
|
||||
function onDragEnter(e) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) {
|
||||
setDragCount(c => c + 1)
|
||||
}
|
||||
}
|
||||
function onDragLeave() {
|
||||
setDragCount(c => Math.max(0, c - 1))
|
||||
}
|
||||
function onDragOver(e) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
function onDrop(e) {
|
||||
e.preventDefault()
|
||||
setDragCount(0)
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && state.selectedId) setPendingFile(file)
|
||||
}
|
||||
|
||||
let header = null
|
||||
if (conn) {
|
||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||
const other = state.userMap[otherId]
|
||||
const status = connectionStatus(conn, userId)
|
||||
header = (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-700/60 shrink-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(other?.color) }}
|
||||
>
|
||||
{other?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{other?.name ?? '…'}</p>
|
||||
{other?.pronouns && (
|
||||
<p className="text-xs text-gray-500">{other.pronouns}</p>
|
||||
)}
|
||||
</div>
|
||||
{status && (
|
||||
<span className={`ml-auto text-xs ${status.cls}`}>● {status.label}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col bg-gray-800 min-w-0 relative"
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{header}
|
||||
{state.selectedId ? (
|
||||
<>
|
||||
<MessageList />
|
||||
<MessageInput pendingFile={pendingFile} setPendingFile={setPendingFile} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-600 text-sm">
|
||||
Select a conversation
|
||||
</div>
|
||||
)}
|
||||
{isDragging && state.selectedId && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-blue-500/20 border-2 border-dashed border-blue-400 pointer-events-none">
|
||||
<p className="text-blue-200 text-sm font-semibold">Drop file to attach</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
import { connectionStatus } from '../utils/connection'
|
||||
|
||||
export default function ConnectionItem({ conn, onContextMenu }) {
|
||||
const { state, selectConnection, userId } = useApp()
|
||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||
const other = state.userMap[otherId]
|
||||
const unread = state.unread[conn.id] || 0
|
||||
const isSelected = state.selectedId === conn.id
|
||||
const status = connectionStatus(conn, userId)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => selectConnection(conn.id)}
|
||||
onContextMenu={onContextMenu}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors
|
||||
${isSelected ? 'bg-gray-700' : 'hover:bg-gray-700/50'}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(other?.color) }}
|
||||
>
|
||||
{other?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-100 truncate min-w-0">
|
||||
{other?.name ?? otherId.slice(0, 8)}
|
||||
</span>
|
||||
{status && (
|
||||
<span className={`text-[10px] shrink-0 ${status.cls}`}>● {status.label}</span>
|
||||
)}
|
||||
</div>
|
||||
{other?.pronouns && (
|
||||
<span className="text-xs text-gray-500 truncate block">{other.pronouns}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unread > 0 && (
|
||||
<span className="shrink-0 bg-red-500 text-white text-xs font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import ConnectionItem from './ConnectionItem'
|
||||
import ConnectionMenu from './ConnectionMenu'
|
||||
|
||||
export default function ConnectionList() {
|
||||
const { state, userId } = useApp()
|
||||
const [search, setSearch] = useState('')
|
||||
const [menu, setMenu] = useState(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return
|
||||
const t = setTimeout(() => setNotice(''), 2500)
|
||||
return () => clearTimeout(t)
|
||||
}, [notice])
|
||||
|
||||
const filtered = state.connections.filter(conn => {
|
||||
if (!search) return true
|
||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||
const other = state.userMap[otherId]
|
||||
const haystack = (other?.name ?? otherId.slice(0, 8)).toLowerCase()
|
||||
return haystack.includes(search.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
className="w-full bg-gray-700 text-white text-sm px-3 py-1.5 rounded-md outline-none placeholder-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-0.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-gray-600 text-xs text-center py-4">No contacts</p>
|
||||
)}
|
||||
{filtered.map(conn => (
|
||||
<ConnectionItem
|
||||
key={conn.id}
|
||||
conn={conn}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setMenu({ x: e.clientX, y: e.clientY, conn })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p className="px-3 py-1.5 text-xs text-gray-300 bg-gray-800/80 border-t border-gray-700/60 truncate">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<ConnectionMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
conn={menu.conn}
|
||||
onClose={() => setMenu(null)}
|
||||
onNotice={setNotice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { pendingActor } from '../utils/connection'
|
||||
|
||||
export default function ConnectionMenu({ x, y, conn, onClose, onNotice }) {
|
||||
const { userId, elevateConnection, deelevateConnection, deleteConnection } = useApp()
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleDown(e) {
|
||||
if (!ref.current?.contains(e.target)) onClose()
|
||||
}
|
||||
function handleEsc(e) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleDown)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleDown)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
function run(label, fn) {
|
||||
onClose()
|
||||
fn().then(
|
||||
result => {
|
||||
if (label === 'elevate' && result === 'waiting') {
|
||||
onNotice('Friend request sent — waiting for the other user')
|
||||
} else if (label === 'elevate' && result === 'elevated') {
|
||||
onNotice('Now friends')
|
||||
} else if (label === 'deelevate') {
|
||||
onNotice('Unfriended')
|
||||
} else if (label === 'delete') {
|
||||
onNotice('Connection removed')
|
||||
}
|
||||
},
|
||||
err => onNotice(err.message ?? String(err)),
|
||||
)
|
||||
}
|
||||
|
||||
const isFriend = conn.state === 1
|
||||
const pending = pendingActor(conn)
|
||||
const iAmPending = pending === userId
|
||||
const otherIsPending = pending && !iAmPending
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: 'fixed', top: y, left: x }}
|
||||
className="bg-gray-800 border border-gray-700 rounded shadow-lg text-sm py-1 min-w-[180px] z-50"
|
||||
>
|
||||
{isFriend && (
|
||||
<button
|
||||
onClick={() => run('deelevate', () => deelevateConnection(conn.id))}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-gray-700"
|
||||
>
|
||||
Unfriend
|
||||
</button>
|
||||
)}
|
||||
{!isFriend && otherIsPending && (
|
||||
<button
|
||||
onClick={() => run('elevate', () => elevateConnection(conn.id))}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-gray-700 text-blue-300"
|
||||
>
|
||||
Accept friend request
|
||||
</button>
|
||||
)}
|
||||
{!isFriend && !pending && (
|
||||
<button
|
||||
onClick={() => run('elevate', () => elevateConnection(conn.id))}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-gray-700"
|
||||
>
|
||||
Send friend request
|
||||
</button>
|
||||
)}
|
||||
{!isFriend && iAmPending && (
|
||||
<p className="px-3 py-1.5 text-xs text-yellow-400 italic">Friend request pending…</p>
|
||||
)}
|
||||
<div className="border-t border-gray-700 my-1" />
|
||||
<button
|
||||
onClick={() => run('delete', () => deleteConnection(conn.id))}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-gray-700 text-red-400"
|
||||
>
|
||||
Delete connection
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export default function HubRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-700/60">
|
||||
<button
|
||||
title="Add hub (coming soon)"
|
||||
disabled
|
||||
className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-500 text-lg leading-none cursor-not-allowed"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { AppProvider } from '../context/AppContext'
|
||||
import Sidebar from './Sidebar'
|
||||
import ChatArea from './ChatArea'
|
||||
|
||||
export default function MainApp() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<div className="flex h-screen bg-gray-900 text-white overflow-hidden">
|
||||
<Sidebar />
|
||||
<ChatArea />
|
||||
</div>
|
||||
</AppProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function MessageInput({ pendingFile, setPendingFile }) {
|
||||
const { state, sendMessage } = useApp()
|
||||
const [text, setText] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const textareaRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = text.trim()
|
||||
if ((!trimmed && !pendingFile) || !state.selectedId || busy) return
|
||||
const file = pendingFile
|
||||
setError('')
|
||||
setText('')
|
||||
setPendingFile(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await sendMessage(state.selectedId, trimmed, file)
|
||||
} catch (err) {
|
||||
setText(trimmed)
|
||||
setPendingFile(file)
|
||||
setError(err.message ?? String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.selectedId) return null
|
||||
|
||||
const canSend = !busy && (text.trim() || pendingFile)
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-gray-700/60">
|
||||
{pendingFile && (
|
||||
<div className="mb-2 flex items-center gap-2 bg-gray-700/60 rounded px-3 py-1.5 text-sm">
|
||||
<span className="text-gray-300">📎</span>
|
||||
<span className="flex-1 truncate text-gray-200">{pendingFile.name}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">{fmtSize(pendingFile.size)}</span>
|
||||
<button
|
||||
onClick={() => setPendingFile(null)}
|
||||
disabled={busy}
|
||||
className="text-gray-400 hover:text-red-400 text-base leading-none px-1"
|
||||
title="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2 bg-gray-700 rounded-lg px-3 py-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) setPendingFile(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="text-gray-400 hover:text-gray-200 disabled:text-gray-600 transition-colors pb-0.5 shrink-0 text-base"
|
||||
title="Attach file"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="flex-1 bg-transparent text-white text-sm resize-none outline-none placeholder-gray-500 max-h-36 leading-relaxed disabled:opacity-50"
|
||||
rows={1}
|
||||
placeholder={busy ? 'Sending…' : 'Message…'}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className="text-blue-400 hover:text-blue-300 disabled:text-gray-600 transition-colors pb-0.5 shrink-0"
|
||||
title="Send (Enter)"
|
||||
>
|
||||
↵
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-400 truncate">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { apiFetch } from '../api/http'
|
||||
import { colorToCss } from '../utils/color'
|
||||
|
||||
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i
|
||||
|
||||
function formatTime(iso) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fileNameFromKey(key) {
|
||||
return key.split('/').pop() || key
|
||||
}
|
||||
|
||||
export default function MessageItem({ msg, showHeader }) {
|
||||
const { state, userId } = useApp()
|
||||
const isMe = msg.sender === userId
|
||||
const sender = isMe ? state.currentUser : state.userMap[msg.sender]
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const isImage = !!msg.attachedFile && IMAGE_EXT.test(msg.attachedFile)
|
||||
const [imgUrl, setImgUrl] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
let cancelled = false
|
||||
apiFetch('GET', '/file', { query: { key: msg.attachedFile, connectionid: msg.receiver } })
|
||||
.then(meta => { if (!cancelled && meta?.url) setImgUrl(meta.url) })
|
||||
.catch(err => console.error('[MessageItem] preview failed', err))
|
||||
return () => { cancelled = true }
|
||||
}, [isImage, msg.attachedFile, msg.receiver])
|
||||
|
||||
async function openAttachment() {
|
||||
if (downloading) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const meta = await apiFetch('GET', '/file', {
|
||||
query: { key: msg.attachedFile, connectionid: msg.receiver },
|
||||
})
|
||||
if (meta?.url) window.open(meta.url, '_blank', 'noopener')
|
||||
} catch (err) {
|
||||
console.error('[MessageItem] download failed', err)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 px-4 ${showHeader ? 'mt-4' : 'mt-0.5'} group`}>
|
||||
{showHeader ? (
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: colorToCss(sender?.color) }}
|
||||
>
|
||||
{sender?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{showHeader && (
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: colorToCss(sender?.color) }}
|
||||
>
|
||||
{sender?.name ?? msg.sender.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.content && (
|
||||
<p className="text-sm text-gray-200 break-words leading-relaxed">{msg.content}</p>
|
||||
)}
|
||||
{msg.attachedFile && isImage && (
|
||||
imgUrl ? (
|
||||
<a
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-1"
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={fileNameFromKey(msg.attachedFile)}
|
||||
className="rounded max-w-sm max-h-80 object-contain border border-gray-700/40"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="mt-1 inline-block bg-gray-700/60 rounded px-3 py-1.5 text-xs text-gray-400">
|
||||
Loading image…
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{msg.attachedFile && !isImage && (
|
||||
<button
|
||||
onClick={openAttachment}
|
||||
disabled={downloading}
|
||||
className="mt-1 inline-flex items-center gap-2 bg-gray-700/60 hover:bg-gray-700 rounded px-3 py-1.5 text-xs text-gray-200 max-w-full disabled:opacity-50"
|
||||
title="Open attachment"
|
||||
>
|
||||
<span>📎</span>
|
||||
<span className="truncate">{fileNameFromKey(msg.attachedFile)}</span>
|
||||
{downloading && <span className="text-gray-500">…</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-gray-600 opacity-0 group-hover:opacity-100 self-center shrink-0 transition-opacity">
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import MessageItem from './MessageItem'
|
||||
|
||||
export default function MessageList() {
|
||||
const { state } = useApp()
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [state.messages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{state.messages.map((msg, i) => {
|
||||
const prev = state.messages[i - 1]
|
||||
const showHeader = !prev || prev.sender !== msg.sender
|
||||
return <MessageItem key={msg.id} msg={msg} showHeader={showHeader} />
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { apiFetch } from '../api/http'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const userId = localStorage.getItem('userId') ?? ''
|
||||
const [recipient, setRecipient] = useState('')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function copyId() {
|
||||
if (!userId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(userId)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
setStatus({ kind: 'err', text: 'Clipboard unavailable' })
|
||||
}
|
||||
}
|
||||
|
||||
async function addConnection() {
|
||||
const id = recipient.trim()
|
||||
if (!id) return
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
try {
|
||||
await apiFetch('GET', '/connection', { query: { recipient: id } })
|
||||
setStatus({ kind: 'ok', text: 'Connection created' })
|
||||
setRecipient('')
|
||||
} catch (err) {
|
||||
setStatus({ kind: 'err', text: err.message ?? String(err) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userId')
|
||||
navigate('/auth')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex justify-center py-10 px-4">
|
||||
<div className="w-full max-w-md flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="text-blue-400 hover:text-blue-300 text-sm">← Back</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-400 hover:text-red-400 text-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Your user ID</h2>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code className="flex-1 bg-gray-800 text-gray-200 text-xs px-3 py-2 rounded break-all">
|
||||
{userId || '(missing)'}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyId}
|
||||
disabled={!userId}
|
||||
className="px-3 py-2 rounded bg-gray-700 hover:bg-gray-600 text-sm disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Share this with someone so they can add you.</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Add connection</h2>
|
||||
<input
|
||||
className="bg-gray-800 text-white text-sm px-3 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Recipient user UUID"
|
||||
value={recipient}
|
||||
onChange={e => setRecipient(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !busy) addConnection() }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
onClick={addConnection}
|
||||
disabled={busy || !recipient.trim()}
|
||||
className="self-end px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
{status && (
|
||||
<p className={`text-sm ${status.kind === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import HubRow from './HubRow'
|
||||
import ConnectionList from './ConnectionList'
|
||||
import UserBar from './UserBar'
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<div className="w-[280px] shrink-0 flex flex-col h-full bg-gray-900 border-r border-gray-700/60">
|
||||
<HubRow />
|
||||
<ConnectionList />
|
||||
<UserBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
|
||||
export default function UserBar() {
|
||||
const { state } = useApp()
|
||||
const user = state.currentUser
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-t border-gray-700/60 bg-gray-900/80">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(user?.color) }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-200 truncate flex-1">
|
||||
{user?.name ?? '…'}
|
||||
</span>
|
||||
<Link
|
||||
to="/settings"
|
||||
title="Settings"
|
||||
className="text-gray-400 hover:text-gray-200 transition-colors text-base px-1"
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
|
||||
import { apiFetch } from '../api/http'
|
||||
import { wsConnect, wsDisconnect, wsOnEvent } from '../api/ws'
|
||||
|
||||
const Ctx = createContext(null)
|
||||
|
||||
const WS_AUTH = 0
|
||||
const WS_DM = 1
|
||||
const WS_CONN_CREATED = 2
|
||||
const WS_CONN_DELETED = 3
|
||||
const WS_CONN_ELEVATED = 4
|
||||
const WS_CONN_DEELEVATED = 5
|
||||
const WS_USER_PROFILE = 6
|
||||
const WS_CONN_ELEVATE_PENDING = 10
|
||||
|
||||
const initial = {
|
||||
connections: [],
|
||||
userMap: {},
|
||||
unread: {},
|
||||
selectedId: null,
|
||||
messages: [],
|
||||
currentUser: null,
|
||||
}
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'SET_CONNECTIONS':
|
||||
return { ...state, connections: action.payload }
|
||||
case 'SET_USER_MAP':
|
||||
return { ...state, userMap: { ...state.userMap, ...action.payload } }
|
||||
case 'SET_UNREAD':
|
||||
return { ...state, unread: action.payload }
|
||||
case 'SET_CURRENT_USER':
|
||||
return { ...state, currentUser: action.payload }
|
||||
case 'SELECT_CONNECTION':
|
||||
return {
|
||||
...state,
|
||||
selectedId: action.id,
|
||||
unread: { ...state.unread, [action.id]: 0 },
|
||||
}
|
||||
case 'SET_MESSAGES':
|
||||
return { ...state, messages: action.payload }
|
||||
case 'APPEND_MESSAGE':
|
||||
return { ...state, messages: [...state.messages, action.payload] }
|
||||
case 'REMOVE_MESSAGE':
|
||||
return { ...state, messages: state.messages.filter(m => m.id !== action.id) }
|
||||
case 'BUMP_UNREAD':
|
||||
return {
|
||||
...state,
|
||||
unread: { ...state.unread, [action.id]: (state.unread[action.id] || 0) + 1 },
|
||||
}
|
||||
case 'ADD_CONNECTION':
|
||||
return { ...state, connections: [action.payload, ...state.connections] }
|
||||
case 'REMOVE_CONNECTION':
|
||||
return {
|
||||
...state,
|
||||
connections: state.connections.filter(c => c.id !== action.id),
|
||||
selectedId: state.selectedId === action.id ? null : state.selectedId,
|
||||
messages: state.selectedId === action.id ? [] : state.messages,
|
||||
}
|
||||
case 'UPDATE_CONN_STATE':
|
||||
return {
|
||||
...state,
|
||||
connections: state.connections.map(c =>
|
||||
c.id === action.id ? { ...c, state: action.newState } : c
|
||||
),
|
||||
}
|
||||
case 'PATCH_CONNECTION':
|
||||
return {
|
||||
...state,
|
||||
connections: state.connections.map(c =>
|
||||
c.id === action.id ? { ...c, ...action.changes } : c
|
||||
),
|
||||
}
|
||||
case 'PATCH_USER':
|
||||
return {
|
||||
...state,
|
||||
userMap: {
|
||||
...state.userMap,
|
||||
[action.userId]: { ...state.userMap[action.userId], ...action.changes },
|
||||
},
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function AppProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(reducer, initial)
|
||||
const selectedIdRef = useRef(null)
|
||||
const userId = localStorage.getItem('userId')
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = state.selectedId
|
||||
}, [state.selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
|
||||
dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: userId } })
|
||||
|
||||
const conns = (await apiFetch('GET', '/connections')) ?? []
|
||||
dispatch({ type: 'SET_CONNECTIONS', payload: conns })
|
||||
|
||||
const otherIds = [...new Set(
|
||||
conns.map(c => c.requestorId === userId ? c.recipientId : c.requestorId)
|
||||
)]
|
||||
const entries = await Promise.all(
|
||||
otherIds.map(async id => {
|
||||
const u = await apiFetch('GET', '/user', { query: { targetid: id } })
|
||||
return [id, u]
|
||||
})
|
||||
)
|
||||
dispatch({ type: 'SET_USER_MAP', payload: Object.fromEntries(entries) })
|
||||
|
||||
if (conns.length > 0) {
|
||||
const ids = conns.map(c => c.id).join(',')
|
||||
const counts = (await apiFetch('GET', '/connections/unreadmessages', { query: { connections: ids } })) ?? []
|
||||
const unreadMap = {}
|
||||
conns.forEach((c, i) => { unreadMap[c.id] = counts[i] || 0 })
|
||||
dispatch({ type: 'SET_UNREAD', payload: unreadMap })
|
||||
}
|
||||
|
||||
wsOnEvent(WS_DM, (event) => {
|
||||
if (event.receiver === selectedIdRef.current) {
|
||||
dispatch({ type: 'APPEND_MESSAGE', payload: event })
|
||||
} else {
|
||||
dispatch({ type: 'BUMP_UNREAD', id: event.receiver })
|
||||
}
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_CREATED, async (event) => {
|
||||
dispatch({ type: 'ADD_CONNECTION', payload: event })
|
||||
const otherId = event.requestorId === userId ? event.recipientId : event.requestorId
|
||||
const u = await apiFetch('GET', '/user', { query: { targetid: otherId } })
|
||||
dispatch({ type: 'SET_USER_MAP', payload: { [otherId]: u } })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_DELETED, (id) => {
|
||||
dispatch({ type: 'REMOVE_CONNECTION', id })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_ELEVATED, ({ id, newState }) => {
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_DEELEVATED, ({ id, newState }) => {
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_ELEVATE_PENDING, ({ id, userWantingToElevate }) => {
|
||||
dispatch({ type: 'PATCH_CONNECTION', id, changes: { userWantingToElevate } })
|
||||
})
|
||||
|
||||
// event = { userId, profileChangeList: { pronouns?, description?, color? } }
|
||||
wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => {
|
||||
dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList })
|
||||
})
|
||||
// types 7 (avatar) and 8 (profilebg) don't affect our color-circle display — ignore
|
||||
|
||||
wsConnect()
|
||||
}
|
||||
|
||||
init().catch(err => console.error('[AppContext] init failed', err))
|
||||
return () => wsDisconnect()
|
||||
}, [])
|
||||
|
||||
const selectConnection = useCallback(async (id) => {
|
||||
dispatch({ type: 'SELECT_CONNECTION', id })
|
||||
dispatch({ type: 'SET_MESSAGES', payload: [] })
|
||||
try {
|
||||
const msgs = (await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })) ?? []
|
||||
if (selectedIdRef.current === id) {
|
||||
dispatch({ type: 'SET_MESSAGES', payload: msgs })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AppContext] failed to load messages for', id, err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const elevateConnection = useCallback(async (connectionId) => {
|
||||
const text = await apiFetch('POST', '/connection/elevate', { body: { connectionid: connectionId } })
|
||||
if (text === 'elevated') {
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id: connectionId, newState: 1 })
|
||||
return 'elevated'
|
||||
}
|
||||
dispatch({ type: 'PATCH_CONNECTION', id: connectionId, changes: { userWantingToElevate: userId } })
|
||||
return 'waiting'
|
||||
}, [userId])
|
||||
|
||||
const deelevateConnection = useCallback(async (connectionId) => {
|
||||
await apiFetch('POST', '/connection/deelevate', { body: { connectionid: connectionId } })
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id: connectionId, newState: 0 })
|
||||
}, [])
|
||||
|
||||
const deleteConnection = useCallback(async (connectionId) => {
|
||||
await apiFetch('DELETE', '/connection', { query: { connectionid: connectionId } })
|
||||
dispatch({ type: 'REMOVE_CONNECTION', id: connectionId })
|
||||
}, [])
|
||||
|
||||
const sendMessage = useCallback(async (connectionId, content, file) => {
|
||||
let attachedFile = ''
|
||||
if (file) {
|
||||
const fd = new FormData()
|
||||
fd.append('connectionid', connectionId)
|
||||
fd.append('file', file)
|
||||
attachedFile = await apiFetch('POST', '/file', { body: fd })
|
||||
}
|
||||
|
||||
const uid = localStorage.getItem('userId')
|
||||
const optimistic = {
|
||||
id: crypto.randomUUID(),
|
||||
content,
|
||||
attachedFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
sender: uid,
|
||||
receiver: connectionId,
|
||||
}
|
||||
dispatch({ type: 'APPEND_MESSAGE', payload: optimistic })
|
||||
try {
|
||||
await apiFetch('POST', '/connection/message', {
|
||||
body: { connectionid: connectionId, msgContent: content, attachedFile },
|
||||
})
|
||||
} catch (err) {
|
||||
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{
|
||||
state, userId,
|
||||
selectConnection, sendMessage,
|
||||
elevateConnection, deelevateConnection, deleteConnection,
|
||||
}}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
return useContext(Ctx)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "./App"
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
const FALLBACK_COLOR = '#4b5563'
|
||||
|
||||
export function colorToCss(color) {
|
||||
if (!color) return FALLBACK_COLOR
|
||||
return `rgba(${color[0]},${color[1]},${color[2]},${(color[3] ?? 255) / 255})`
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const ZERO_UUID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
export function pendingActor(conn) {
|
||||
const u = conn?.userWantingToElevate
|
||||
return u && u !== ZERO_UUID ? u : null
|
||||
}
|
||||
|
||||
export function connectionStatus(conn, currentUserId) {
|
||||
if (!conn) return null
|
||||
if (conn.state === 1) return { label: 'friend', cls: 'text-green-400' }
|
||||
const pending = pendingActor(conn)
|
||||
if (pending === currentUserId) return { label: 'request sent', cls: 'text-yellow-400' }
|
||||
if (pending) return { label: 'request pending', cls: 'text-blue-400' }
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
})
|
||||
@@ -64,7 +64,7 @@ func main() {
|
||||
http.HandleFunc("GET /hub/channel/messages", withCORS(httpRequest.HandleHubChannelGetMessages))
|
||||
http.HandleFunc("GET /hub/channel", withCORS(httpRequest.GetChannelData))
|
||||
http.HandleFunc("GET /hubs", withCORS(httpRequest.HandleGetHubs))
|
||||
http.HandleFunc("GET /hubs/channels", withCORS(httpRequest.HandleGetChannels))
|
||||
http.HandleFunc("GET /hub/channels", withCORS(httpRequest.HandleGetChannels))
|
||||
http.HandleFunc("GET /hubs/users", withCORS(httpRequest.HandleGetHubUsers))
|
||||
http.HandleFunc("GET /hubs/roles", withCORS(httpRequest.GetRoles))
|
||||
http.HandleFunc("PUT /hub/join", withCORS(httpRequest.HandleHubJoin))
|
||||
|
||||
@@ -102,6 +102,10 @@ func getHubByIdStr(ctx context.Context, hubId string) (*types.Hub, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range hub.Users {
|
||||
updateChannelCacheForSpecUser(u, hub)
|
||||
}
|
||||
cache.SaveHub(hub)
|
||||
}
|
||||
|
||||
return hub, nil
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<button data-form="get-hubs" onclick="showForm('get-hubs')">GET /hubs</button>
|
||||
<button data-form="get-hub-data" onclick="showForm('get-hub-data')">GET /hub</button>
|
||||
<button data-form="get-hub-channel-data" onclick="showForm('get-hub-channel-data')">GET /hub/channel</button>
|
||||
<button data-form="get-hub-channels" onclick="showForm('get-hub-channels')">GET /hubs/channels</button>
|
||||
<button data-form="get-hub-channels" onclick="showForm('get-hub-channels')">GET /hub/channels</button>
|
||||
<button data-form="get-hub-users" onclick="showForm('get-hub-users')">GET /hubs/users</button>
|
||||
<button data-form="get-hub-roles" onclick="showForm('get-hub-roles')">GET /hubs/roles</button>
|
||||
<button data-form="get-users" onclick="showForm('get-users')">GET /users</button>
|
||||
@@ -609,7 +609,7 @@
|
||||
'get-hubs': { method:'GET', path:'/hubs', title:'GET /hubs — get own hubs', fields:[{id:'gh-token',dest:'header',name:'token'}] },
|
||||
'get-hub-data': { method:'GET', path:'/hub', title:'GET /hub — get hub data', fields:[{id:'ghd-token',dest:'header',name:'token'},{id:'ghd-hubid',dest:'header',name:'hub_id'}] },
|
||||
'get-hub-channel-data': { method:'GET', path:'/hub/channel', title:'GET /hub/channel — get channel data', fields:[{id:'ghcd-token',dest:'header',name:'token'},{id:'ghcd-hubid',dest:'header',name:'hub_id'},{id:'ghcd-channelid',dest:'query',name:'channel_id'}] },
|
||||
'get-hub-channels': { method:'GET', path:'/hubs/channels', title:'GET /hubs/channels — get hub channels', fields:[{id:'ghc-token',dest:'header',name:'token'},{id:'ghc-hubid',dest:'header',name:'hub_id'}] },
|
||||
'get-hub-channels': { method:'GET', path:'/hub/channels', title:'GET /hub/channels — get hub channels', fields:[{id:'ghc-token',dest:'header',name:'token'},{id:'ghc-hubid',dest:'header',name:'hub_id'}] },
|
||||
'get-hub-users': { method:'GET', path:'/hubs/users', title:'GET /hubs/users — get hub users (excludes self)', fields:[{id:'ghu-token',dest:'header',name:'token'},{id:'ghu-hubid',dest:'header',name:'hub_id'}] },
|
||||
'get-hub-roles': { method:'GET', path:'/hubs/roles', title:'GET /hubs/roles — get hub roles', fields:[{id:'ghr-token',dest:'header',name:'token'},{id:'ghr-hubid',dest:'header',name:'hub_id'}] },
|
||||
'get-users': { method:'GET', path:'/users', title:'GET /users — get multiple users by IDs', fields:[{id:'gus-token',dest:'header',name:'token'},{id:'gus-targetids',dest:'query',name:'target_ids'}] },
|
||||
|
||||
Reference in New Issue
Block a user