add hub menagment functions
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<p className="text-sm text-gray-200 break-words leading-relaxed">{msg.content}</p>
|
||||
{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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user