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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 1–6 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/`:
|
||||
|
||||
@@ -13,4 +13,5 @@ const (
|
||||
UserAvatarChange
|
||||
UserProfileBgChange
|
||||
HubMessage
|
||||
ConnectionElevatePending
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user