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,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>
)
}
+70 -1
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>
)}
<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">
+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
}