fix channel cache not updating

This commit is contained in:
cos
2026-05-07 18:19:53 +02:00
parent 916463234f
commit 72bc839bf1
34 changed files with 7 additions and 3113 deletions
-24
View File
@@ -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?
-18
View File
@@ -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.
-21
View File
@@ -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 } },
},
},
])
-13
View File
@@ -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>
-33
View File
@@ -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"
}
}
-1800
View File
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

-24
View File
@@ -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

-31
View File
@@ -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>
)
}
-13
View File
@@ -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 }
}
-26
View File
@@ -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
}
-101
View File
@@ -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>
)
}
-87
View File
@@ -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>
)
}
-48
View File
@@ -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>
)
}
-70
View File
@@ -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>
)
}
-89
View File
@@ -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>
)
}
-13
View File
@@ -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>
)
}
-14
View File
@@ -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>
)
}
-111
View File
@@ -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>
)
}
-116
View File
@@ -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>
)
}
-23
View File
@@ -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>
)
}
-102
View File
@@ -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>
)
}
-13
View File
@@ -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>
)
}
-29
View File
@@ -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>
)
}
-243
View File
@@ -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)
}
-5
View File
@@ -1,5 +0,0 @@
@import "tailwindcss";
body {
@apply bg-gray-900 text-white;
}
-9
View File
@@ -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>
)
-6
View File
@@ -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})`
}
-15
View File
@@ -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
}
-12
View File
@@ -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()] })
],
})
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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))
+4
View File
@@ -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
+2 -2
View File
@@ -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'}] },