add hub menagment functions

This commit is contained in:
gitGnome
2026-04-29 14:46:22 +02:00
parent 221fb47495
commit 6378966267
19 changed files with 780 additions and 62 deletions
+6
View File
@@ -1,6 +1,7 @@
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 }) {
@@ -19,6 +20,11 @@ export default function App() {
<MainApp />
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
)
+1 -1
View File
@@ -5,7 +5,7 @@ export async function apiFetch(method, path, { body, query } = {}) {
let url = BASE + path
if (query) url += '?' + new URLSearchParams(query)
const opts = { method, headers: token ? { token } : {} }
if (body) opts.body = new URLSearchParams(body)
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()
+41 -4
View File
@@ -1,16 +1,42 @@
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
@@ -25,26 +51,37 @@ export default function ChatArea() {
<p className="text-xs text-gray-500">{other.pronouns}</p>
)}
</div>
{conn.state === 1 && (
<span className="ml-auto text-xs text-green-400"> friend</span>
{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">
<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 />
<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>
)
}
+6 -4
View File
@@ -1,17 +1,19 @@
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
import { connectionStatus } from '../utils/connection'
export default function ConnectionItem({ conn }) {
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 isFriend = conn.state === 1
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'}`}
>
@@ -27,8 +29,8 @@ export default function ConnectionItem({ conn }) {
<span className="text-sm font-medium text-gray-100 truncate min-w-0">
{other?.name ?? otherId.slice(0, 8)}
</span>
{isFriend && (
<span className="text-[10px] text-green-400 shrink-0"> friend</span>
{status && (
<span className={`text-[10px] shrink-0 ${status.cls}`}> {status.label}</span>
)}
</div>
{other?.pronouns && (
+34 -2
View File
@@ -1,10 +1,19 @@
import { useState } from 'react'
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
@@ -30,9 +39,32 @@ export default function ConnectionList() {
<p className="text-gray-600 text-xs text-center py-4">No contacts</p>
)}
{filtered.map(conn => (
<ConnectionItem key={conn.id} conn={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
@@ -0,0 +1,89 @@
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>
)
}
+67 -9
View File
@@ -1,22 +1,39 @@
import { useState, useRef } from 'react'
import { useApp } from '../context/AppContext'
export default function MessageInput() {
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 || !state.selectedId) return
if ((!trimmed && !pendingFile) || !state.selectedId || busy) return
const file = pendingFile
setError('')
setText('')
setPendingFile(null)
setBusy(true)
try {
await sendMessage(state.selectedId, trimmed)
} catch {
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) {
@@ -27,27 +44,68 @@ export default function MessageInput() {
if (!state.selectedId) return null
const canSend = !busy && (text.trim() || pendingFile)
return (
<div className="px-4 py-3 border-t border-gray-700/60">
<div className="flex items-end gap-2 bg-gray-700 rounded-lg px-4 py-2">
{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"
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="Message…"
placeholder={busy ? 'Sending…' : 'Message…'}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
disabled={busy}
/>
<button
onClick={handleSend}
disabled={!text.trim()}
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>
)
}
+69
View File
@@ -1,15 +1,50 @@
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`}>
@@ -36,7 +71,41 @@ export default function MessageItem({ msg, showHeader }) {
<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">
+102
View File
@@ -0,0 +1,102 @@
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>
)
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
@@ -16,13 +17,13 @@ export default function UserBar() {
<span className="text-sm font-medium text-gray-200 truncate flex-1">
{user?.name ?? '…'}
</span>
<button
title="Settings (coming soon)"
disabled
className="text-gray-600 cursor-not-allowed text-base px-1"
<Link
to="/settings"
title="Settings"
className="text-gray-400 hover:text-gray-200 transition-colors text-base px-1"
>
</button>
</Link>
</div>
)
}
+53 -7
View File
@@ -11,6 +11,7 @@ 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: [],
@@ -64,6 +65,13 @@ function reducer(state, action) {
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,
@@ -91,7 +99,7 @@ export function AppProvider({ children }) {
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: userId } })
const conns = await apiFetch('GET', '/connections')
const conns = (await apiFetch('GET', '/connections')) ?? []
dispatch({ type: 'SET_CONNECTIONS', payload: conns })
const otherIds = [...new Set(
@@ -107,7 +115,7 @@ export function AppProvider({ children }) {
if (conns.length > 0) {
const ids = conns.map(c => c.id).join(',')
const counts = await apiFetch('GET', '/connections/unreadmessages', { query: { connections: ids } })
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 })
@@ -140,6 +148,10 @@ export function AppProvider({ children }) {
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 })
@@ -157,7 +169,7 @@ export function AppProvider({ children }) {
dispatch({ type: 'SELECT_CONNECTION', id })
dispatch({ type: 'SET_MESSAGES', payload: [] })
try {
const msgs = await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })
const msgs = (await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })) ?? []
if (selectedIdRef.current === id) {
dispatch({ type: 'SET_MESSAGES', payload: msgs })
}
@@ -166,19 +178,49 @@ export function AppProvider({ children }) {
}
}, [])
const sendMessage = useCallback(async (connectionId, content) => {
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: '',
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 } })
await apiFetch('POST', '/connection/message', {
body: { connectionid: connectionId, msgContent: content, attachedFile },
})
} catch (err) {
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
throw err
@@ -186,7 +228,11 @@ export function AppProvider({ children }) {
}, [])
return (
<Ctx.Provider value={{ state, selectConnection, sendMessage, userId }}>
<Ctx.Provider value={{
state, userId,
selectConnection, sendMessage,
elevateConnection, deelevateConnection, deleteConnection,
}}>
{children}
</Ctx.Provider>
)
+15
View File
@@ -0,0 +1,15 @@
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
}
@@ -4,7 +4,7 @@
---
## ▶ Continuation State (last updated 2026-04-28)
## ▶ Continuation State (last updated 2026-04-29)
Pick up here if resuming on a new device or session.
@@ -18,8 +18,8 @@ Pick up here if resuming on a new device or session.
| 4 | MainApp two-column layout | ✅ Done | |
| 5 | HubRow + UserBar | ✅ Done | `colorToCss` extracted to `src/utils/color.js` (shared), stub buttons `disabled` |
| 6 | ConnectionItem + ConnectionList + Sidebar | ✅ Done | `h-full` on Sidebar, search fallback for unresolved users, `min-w-0` truncate fix |
| 7 | MessageItem + MessageList + MessageInput + ChatArea | 🔄 Created, review **interrupted** — needs spec + quality review before proceeding |
| 8 | End-to-end verification | ⬜ Pending | Run `pnpm dev` in `client/`, manual browser smoke test (see task for steps) |
| 7 | MessageItem + MessageList + MessageInput + ChatArea | ✅ Done | Spec + code-quality review passed 2026-04-29; rollback consistent with `sendMessage` rethrow |
| 8 | End-to-end verification | ⏳ Manual only | Static import/dep check passed 2026-04-29. `pnpm dev` browser smoke test (auth, sidebar, DM round-trip, WS) is owner-driven. |
### Extra file created (not in original plan)
@@ -120,7 +120,7 @@ Pick up here if resuming on a new device or session.
- Create: `client/src/api/http.js`
- Create: `client/src/api/ws.js`
- [ ] **Step 1: Create `client/src/api/http.js`**
- [x] **Step 1: Create `client/src/api/http.js`**
```js
const BASE = 'http://localhost:8080'
@@ -138,7 +138,7 @@ export async function apiFetch(method, path, { body, query } = {}) {
}
```
- [ ] **Step 2: Create `client/src/api/ws.js`**
- [x] **Step 2: Create `client/src/api/ws.js`**
```js
let socket = null
@@ -177,7 +177,7 @@ export function wsDisconnect() {
The login response is `{ token, userId }`. Both must be saved to localStorage — `userId` is needed to identify "my" messages and to fetch own profile.
- [ ] **Step 1: Replace `client/src/components/AuthPage.jsx` with wired version**
- [x] **Step 1: Replace `client/src/components/AuthPage.jsx` with wired version**
```jsx
import { useState } from 'react'
@@ -277,7 +277,7 @@ export default function AuthPage() {
}
```
- [ ] **Step 2: Verify in browser**
- [x] **Step 2: Verify in browser** (owner-confirmed, prior session)
Run `pnpm dev` in `client/`. Navigate to `http://localhost:5173/auth`.
- Enter valid credentials → should redirect to `/` (currently shows "Main App")
@@ -293,7 +293,7 @@ Run `pnpm dev` in `client/`. Navigate to `http://localhost:5173/auth`.
`userMap` is a `{ [userId]: userDetails }` dict populated by fetching `GET /user` for each connection's "other" participant. The `selectedIdRef` keeps the WS handler in sync with the current selection without stale closures.
- [ ] **Step 1: Create `client/src/context/AppContext.jsx`**
- [x] **Step 1: Create `client/src/context/AppContext.jsx`**
```jsx
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
@@ -487,7 +487,7 @@ export function useApp() {
**Files:**
- Modify: `client/src/components/MainApp.jsx`
- [ ] **Step 1: Replace `client/src/components/MainApp.jsx`**
- [x] **Step 1: Replace `client/src/components/MainApp.jsx`**
```jsx
import { AppProvider } from '../context/AppContext'
@@ -514,7 +514,7 @@ export default function MainApp() {
- Create: `client/src/components/HubRow.jsx`
- Create: `client/src/components/UserBar.jsx`
- [ ] **Step 1: Create `client/src/components/HubRow.jsx`**
- [x] **Step 1: Create `client/src/components/HubRow.jsx`**
```jsx
export default function HubRow() {
@@ -531,7 +531,7 @@ export default function HubRow() {
}
```
- [ ] **Step 2: Create `client/src/components/UserBar.jsx`**
- [x] **Step 2: Create `client/src/components/UserBar.jsx`**
```jsx
import { useApp } from '../context/AppContext'
@@ -576,7 +576,7 @@ export default function UserBar() {
- Create: `client/src/components/ConnectionList.jsx`
- Create: `client/src/components/Sidebar.jsx`
- [ ] **Step 1: Create `client/src/components/ConnectionItem.jsx`**
- [x] **Step 1: Create `client/src/components/ConnectionItem.jsx`**
The "other user" is whichever of `requestorId`/`recipientId` isn't our `userId`.
@@ -633,7 +633,7 @@ export default function ConnectionItem({ conn }) {
}
```
- [ ] **Step 2: Create `client/src/components/ConnectionList.jsx`**
- [x] **Step 2: Create `client/src/components/ConnectionList.jsx`**
```jsx
import { useState } from 'react'
@@ -675,7 +675,7 @@ export default function ConnectionList() {
}
```
- [ ] **Step 3: Create `client/src/components/Sidebar.jsx`**
- [x] **Step 3: Create `client/src/components/Sidebar.jsx`**
```jsx
import HubRow from './HubRow'
@@ -703,7 +703,7 @@ export default function Sidebar() {
- Create: `client/src/components/MessageInput.jsx`
- Create: `client/src/components/ChatArea.jsx`
- [ ] **Step 1: Create `client/src/components/MessageItem.jsx`**
- [x] **Step 1: Create `client/src/components/MessageItem.jsx`**
Messages are displayed in Discord style: avatar + sender name on first message of a group, compact rows for consecutive same-sender messages.
@@ -761,7 +761,7 @@ export default function MessageItem({ msg, showHeader }) {
}
```
- [ ] **Step 2: Create `client/src/components/MessageList.jsx`**
- [x] **Step 2: Create `client/src/components/MessageList.jsx`**
Groups consecutive messages from the same sender so that only the first shows the header (avatar + name).
@@ -791,7 +791,7 @@ export default function MessageList() {
}
```
- [ ] **Step 3: Create `client/src/components/MessageInput.jsx`**
- [x] **Step 3: Create `client/src/components/MessageInput.jsx`**
```jsx
import { useState, useRef } from 'react'
@@ -845,7 +845,7 @@ export default function MessageInput() {
}
```
- [ ] **Step 4: Create `client/src/components/ChatArea.jsx`**
- [x] **Step 4: Create `client/src/components/ChatArea.jsx`**
```jsx
import { useApp } from '../context/AppContext'
@@ -908,6 +908,8 @@ export default function ChatArea() {
## Task 8: End-to-End Verification
> Steps 16 require a running browser + backend and are owner-driven. Static verification done 2026-04-29: every Task 7 import resolves to an existing file, no missing dependencies in `package.json`.
- [ ] **Step 1: Start the dev server**
In `client/`:
BIN
View File
Binary file not shown.
@@ -13,4 +13,5 @@ const (
UserAvatarChange
UserProfileBgChange
HubMessage
ConnectionElevatePending
)
+14 -1
View File
@@ -13,6 +13,7 @@ import (
"go-socket/packages/cache"
"go-socket/packages/config"
"go-socket/packages/convertions"
"go-socket/packages/minio"
"go-socket/packages/postgresql"
"go-socket/packages/types"
"go-socket/packages/wsServer"
@@ -45,7 +46,7 @@ func HandleDm(response http.ResponseWriter, request *http.Request) {
return
}
if attachedFile != "" && !strings.HasPrefix(attachedFile, conn.Id.String()+"/") {
if attachedFile != "" && !strings.HasPrefix(attachedFile, string(minio.ConnectionFilePrefix)+conn.Id.String()+"/") {
http.Error(response, "invalid attachedFile", http.StatusBadRequest)
return
}
@@ -346,6 +347,18 @@ func HandleUserElevateConnection(response http.ResponseWriter, request *http.Req
}
conn.UserWantingToElevate = user.Id
user2, err := getUserById(ctx, conn.GetSecondUser(user.Id))
if err == nil {
wsServer.WsSendMessageCloseIfTimeout(user2, types.WsEventMessage{
Type: WsEventType.ConnectionElevatePending,
Event: types.ConnectionElevatePendingData{
Id: conn.Id,
UserWantingToElevate: user.Id,
},
})
}
response.Write([]byte("waiting for second user to elevate"))
}
+1 -1
View File
@@ -326,7 +326,7 @@ func HandleAttachmentFileDownload(response http.ResponseWriter, request *http.Re
}
key := request.URL.Query().Get("key")
if !strings.HasPrefix(key, conn.Id.String()+"/") {
if !strings.HasPrefix(key, string(minio.ConnectionFilePrefix)+conn.Id.String()+"/") {
http.Error(response, "no such file", http.StatusUnauthorized)
return
}
+241 -2
View File
@@ -2,6 +2,7 @@ package httpRequest
import (
"encoding/json"
"go-socket/packages/convertions"
"maps"
"net/http"
"slices"
@@ -15,16 +16,37 @@ import (
"github.com/google/uuid"
)
func haveHubUserPermissionsOnChannel(permissions types.CachedUserPermissions, user *types.HubUser, channel *types.HubChannel) bool {
func haveHubUserPermissionsOnChannel(needed types.CachedUserPermissions, user *types.HubUser, channel *types.HubChannel) bool {
channel.Mu.RLock()
checkAgainst, ok := channel.UsersCachedPermissions[user.OriginalId]
channel.Mu.RUnlock()
if !ok || (permissions&checkAgainst) != permissions {
if !ok || (needed&checkAgainst) != needed {
return false
}
return true
}
func haveUserPermissions(needed types.Permissions, scope uint8, hu *types.HubUser, h *types.Hub) bool {
h.Mu.RLock()
defer h.Mu.RUnlock()
for _, role := range h.Roles {
if role == nil {
continue
}
if scope != 0 && role.BoundedGroup != scope {
continue
}
if !hu.Roles.Has(role.Id) {
continue
}
if (needed & role.Permissions) != needed {
return true
}
}
return true
}
func addHubUserToPermissionCache(hub *types.Hub, user *types.HubUser) {
user.Mu.RLock()
roles := user.Roles
@@ -177,6 +199,10 @@ func HandleHubMessage(response http.ResponseWriter, request *http.Request) {
return
}
if hubUser.IsGlobalMuted {
http.Error(response, "muted", http.StatusForbidden)
}
msgContent := request.FormValue("msgContent")
attachedFile := request.FormValue("attachedFile")
@@ -242,3 +268,216 @@ func HandleGetHubs(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusOK)
response.Write(converted)
}
func HandleHubSetName(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
newName := request.FormValue("name")
if newName == "" {
http.Error(response, "empty name", http.StatusBadRequest)
return
}
if !haveUserPermissions(types.PermissionSetHubName, 0, hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
hub.Name = newName
response.WriteHeader(http.StatusAccepted)
}
func HandleHubSetColor(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
newColor, err := convertions.StringToRgba(request.FormValue("color"))
if err != nil {
http.Error(response, "bad color", http.StatusBadRequest)
return
}
if !haveUserPermissions(types.PermissionSetHubColor, 0, hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
hub.Color = newColor
response.WriteHeader(http.StatusAccepted)
}
func HandleHubRemove(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
if !haveUserPermissions(types.PermissionRemoveHub, 0, hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
cache.DeleteHub(hub)
}
func HandleHubSetAllowUserColor(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
if !haveUserPermissions(types.PermissionSetUserColorAllowed, 0, hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
cache.DeleteHub(hub)
}
func HandleHubRemoveUser(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
targetId, err := convertions.StringToUuid(request.FormValue("target"))
if err != nil {
http.Error(response, "invalid targetid", http.StatusBadRequest)
return
}
hub.Mu.RLock()
_, ok := hub.Users[targetId]
if !ok {
http.Error(response, "target not found", http.StatusNotFound)
return
}
if !haveUserPermissions(types.PermissionRemoveUser, 0, hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
hub.Mu.Lock()
delete(hub.Users, targetId)
hub.Mu.Unlock()
response.WriteHeader(http.StatusAccepted)
}
func HandleHubMuteUserToggle(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
targetId, err := convertions.StringToUuid(request.FormValue("target"))
if err != nil {
http.Error(response, "invalid targetid", http.StatusBadRequest)
return
}
scope, err := convertions.StringToUint32(request.FormValue("scope"))
if err != nil {
http.Error(response, "invalid scope (set 0 for no scope)", http.StatusBadRequest)
return
}
hub.Mu.RLock()
target, ok := hub.Users[targetId]
hub.Mu.RUnlock()
if !ok {
http.Error(response, "target not found", http.StatusNotFound)
return
}
if !haveUserPermissions(types.PermissionMuteUser, uint8(scope), hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
if scope == 0 {
target.IsGlobalMuted = !target.IsGlobalMuted
} else {
hub.Mu.Lock()
users := hub.Groups[scope].MutedUsers
_, ok = users[targetId]
if ok {
delete(users, targetId)
} else {
users[targetId] = struct{}{}
}
}
}
func HandleHubMuteUserToggle(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
_, hubUser, hub, err := getHubUserIfValidWithResponseOnFail(ctx, response, request.Header.Get("token"), request.FormValue("hubid"))
if err != nil {
return
}
targetId, err := convertions.StringToUuid(request.FormValue("target"))
if err != nil {
http.Error(response, "invalid targetid", http.StatusBadRequest)
return
}
scope, err := convertions.StringToUint32(request.FormValue("scope"))
if err != nil {
http.Error(response, "invalid scope (set 0 for no scope)", http.StatusBadRequest)
return
}
hub.Mu.RLock()
target, ok := hub.Users[targetId]
hub.Mu.RUnlock()
if !ok {
http.Error(response, "target not found", http.StatusNotFound)
return
}
if !haveUserPermissions(types.PermissionMuteUser, uint8(scope), hubUser, hub) {
http.Error(response, "no permission", http.StatusForbidden)
return
}
if scope == 0 {
target.IsGlobalMuted = !target.IsGlobalMuted
} else {
hub.Mu.Lock()
users := hub.Groups[scope].MutedUsers
_, ok = users[targetId]
if ok {
delete(users, targetId)
} else {
users[targetId] = struct{}{}
}
}
}
+8 -2
View File
@@ -108,6 +108,11 @@ type ConnectionStatusSetData struct {
NewState ConnectionState.ConnectionState `json:"newState"`
}
type ConnectionElevatePendingData struct {
Id uuid.UUID `json:"id"`
UserWantingToElevate uuid.UUID `json:"userWantingToElevate"`
}
type Message struct {
Id uuid.UUID `json:"id"`
AttachedFile string `json:"attachedFile"`
@@ -277,7 +282,7 @@ type HubUser struct {
Roles HubBoundRoles `json:"-"`
Name string `json:"name"` // Name empty = original name
OriginalId uuid.UUID `json:"originalId"`
IsMuted bool `json:"isMuted"`
IsGlobalMuted bool `json:"isGlobalMuted"`
}
func NewHubUser() *HubUser {
@@ -290,7 +295,8 @@ type HubGroup struct {
Color Rgba `json:"color"`
RolesCanView HubBoundRoles `json:"rolesCanView"`
UsersCachedPermissions map[uuid.UUID]CachedUserPermissions `json:"-"`
Channels map[uuid.UUID]*HubChannel `json:"channels"`
Channels map[uuid.UUID]*HubChannel `json:"-"`
MutedUsers map[uuid.UUID]struct{} `json:"-"`
Id uint8 `json:"id"` // Id 0 for unused
}