From 63789662672e1c845b97185746c3ddd74dbca8fc Mon Sep 17 00:00:00 2001 From: gitGnome Date: Wed, 29 Apr 2026 14:46:22 +0200 Subject: [PATCH] add hub menagment functions --- client/src/App.jsx | 6 + client/src/api/http.js | 2 +- client/src/components/ChatArea.jsx | 45 +++- client/src/components/ConnectionItem.jsx | 10 +- client/src/components/ConnectionList.jsx | 36 ++- client/src/components/ConnectionMenu.jsx | 89 +++++++ client/src/components/MessageInput.jsx | 76 +++++- client/src/components/MessageItem.jsx | 71 ++++- client/src/components/SettingsPage.jsx | 102 ++++++++ client/src/components/UserBar.jsx | 11 +- client/src/context/AppContext.jsx | 60 ++++- client/src/utils/connection.js | 15 ++ .../plans/2026-04-28-discord-client.md | 38 +-- go-socket | Bin 18541854 -> 18539917 bytes packages/Enums/WsEventType/WsMessageFrom.go | 1 + packages/httpRequest/connectionsAndDms.go | 15 +- packages/httpRequest/files.go | 2 +- packages/httpRequest/hubs.go | 243 +++++++++++++++++- packages/types/types.go | 20 +- 19 files changed, 780 insertions(+), 62 deletions(-) create mode 100644 client/src/components/ConnectionMenu.jsx create mode 100644 client/src/components/SettingsPage.jsx create mode 100644 client/src/utils/connection.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 691da2b..150a00f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> + + + + } /> ) diff --git a/client/src/api/http.js b/client/src/api/http.js index 368f48d..6883e46 100644 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -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() diff --git a/client/src/components/ChatArea.jsx b/client/src/components/ChatArea.jsx index a4c2a53..22ff3de 100644 --- a/client/src/components/ChatArea.jsx +++ b/client/src/components/ChatArea.jsx @@ -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 = (
{other.pronouns}

)}
- {conn.state === 1 && ( - ● friend + {status && ( + ● {status.label} )}
) } return ( -
+
{header} {state.selectedId ? ( <> - + ) : (
Select a conversation
)} + {isDragging && state.selectedId && ( +
+

Drop file to attach

+
+ )}
) } diff --git a/client/src/components/ConnectionItem.jsx b/client/src/components/ConnectionItem.jsx index 2b8b160..48887ca 100644 --- a/client/src/components/ConnectionItem.jsx +++ b/client/src/components/ConnectionItem.jsx @@ -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 (
{other?.pronouns && ( diff --git a/client/src/components/ConnectionList.jsx b/client/src/components/ConnectionList.jsx index 11da4d3..9f69e57 100644 --- a/client/src/components/ConnectionList.jsx +++ b/client/src/components/ConnectionList.jsx @@ -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() {

No contacts

)} {filtered.map(conn => ( - + { + e.preventDefault() + setMenu({ x: e.clientX, y: e.clientY, conn }) + }} + /> ))} + + {notice && ( +

+ {notice} +

+ )} + + {menu && ( + setMenu(null)} + onNotice={setNotice} + /> + )} ) } diff --git a/client/src/components/ConnectionMenu.jsx b/client/src/components/ConnectionMenu.jsx new file mode 100644 index 0000000..0c55c3c --- /dev/null +++ b/client/src/components/ConnectionMenu.jsx @@ -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 ( +
+ {isFriend && ( + + )} + {!isFriend && otherIsPending && ( + + )} + {!isFriend && !pending && ( + + )} + {!isFriend && iAmPending && ( +

Friend request pending…

+ )} +
+ +
+ ) +} diff --git a/client/src/components/MessageInput.jsx b/client/src/components/MessageInput.jsx index 24e8dc5..0eb2703 100644 --- a/client/src/components/MessageInput.jsx +++ b/client/src/components/MessageInput.jsx @@ -1,21 +1,38 @@ 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() } - textareaRef.current?.focus() } function onKeyDown(e) { @@ -27,27 +44,68 @@ export default function MessageInput() { if (!state.selectedId) return null + const canSend = !busy && (text.trim() || pendingFile) + return (
-
+ {pendingFile && ( +
+ 📎 + {pendingFile.name} + {fmtSize(pendingFile.size)} + +
+ )} + +
+ { + const f = e.target.files?.[0] + if (f) setPendingFile(f) + e.target.value = '' + }} + /> +