add hub menagment functions
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||||
import AuthPage from "./components/AuthPage.jsx"
|
import AuthPage from "./components/AuthPage.jsx"
|
||||||
import MainApp from "./components/MainApp"
|
import MainApp from "./components/MainApp"
|
||||||
|
import SettingsPage from "./components/SettingsPage"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
@@ -19,6 +20,11 @@ export default function App() {
|
|||||||
<MainApp />
|
<MainApp />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/settings" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export async function apiFetch(method, path, { body, query } = {}) {
|
|||||||
let url = BASE + path
|
let url = BASE + path
|
||||||
if (query) url += '?' + new URLSearchParams(query)
|
if (query) url += '?' + new URLSearchParams(query)
|
||||||
const opts = { method, headers: token ? { token } : {} }
|
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)
|
const res = await fetch(url, opts)
|
||||||
if (!res.ok) { const errText = await res.text(); throw new Error(`${method} ${path} → ${res.status}: ${errText}`) }
|
if (!res.ok) { const errText = await res.text(); throw new Error(`${method} ${path} → ${res.status}: ${errText}`) }
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
import { colorToCss } from '../utils/color'
|
import { colorToCss } from '../utils/color'
|
||||||
|
import { connectionStatus } from '../utils/connection'
|
||||||
import MessageList from './MessageList'
|
import MessageList from './MessageList'
|
||||||
import MessageInput from './MessageInput'
|
import MessageInput from './MessageInput'
|
||||||
|
|
||||||
export default function ChatArea() {
|
export default function ChatArea() {
|
||||||
const { state, userId } = useApp()
|
const { state, userId } = useApp()
|
||||||
const conn = state.connections.find(c => c.id === state.selectedId)
|
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
|
let header = null
|
||||||
if (conn) {
|
if (conn) {
|
||||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||||
const other = state.userMap[otherId]
|
const other = state.userMap[otherId]
|
||||||
|
const status = connectionStatus(conn, userId)
|
||||||
header = (
|
header = (
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-700/60 shrink-0">
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-700/60 shrink-0">
|
||||||
<div
|
<div
|
||||||
@@ -25,26 +51,37 @@ export default function ChatArea() {
|
|||||||
<p className="text-xs text-gray-500">{other.pronouns}</p>
|
<p className="text-xs text-gray-500">{other.pronouns}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{conn.state === 1 && (
|
{status && (
|
||||||
<span className="ml-auto text-xs text-green-400">● friend</span>
|
<span className={`ml-auto text-xs ${status.cls}`}>● {status.label}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{header}
|
||||||
{state.selectedId ? (
|
{state.selectedId ? (
|
||||||
<>
|
<>
|
||||||
<MessageList />
|
<MessageList />
|
||||||
<MessageInput />
|
<MessageInput pendingFile={pendingFile} setPendingFile={setPendingFile} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-gray-600 text-sm">
|
<div className="flex-1 flex items-center justify-center text-gray-600 text-sm">
|
||||||
Select a conversation
|
Select a conversation
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
import { colorToCss } from '../utils/color'
|
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 { state, selectConnection, userId } = useApp()
|
||||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||||
const other = state.userMap[otherId]
|
const other = state.userMap[otherId]
|
||||||
const unread = state.unread[conn.id] || 0
|
const unread = state.unread[conn.id] || 0
|
||||||
const isSelected = state.selectedId === conn.id
|
const isSelected = state.selectedId === conn.id
|
||||||
const isFriend = conn.state === 1
|
const status = connectionStatus(conn, userId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => selectConnection(conn.id)}
|
onClick={() => selectConnection(conn.id)}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors
|
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'}`}
|
${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">
|
<span className="text-sm font-medium text-gray-100 truncate min-w-0">
|
||||||
{other?.name ?? otherId.slice(0, 8)}
|
{other?.name ?? otherId.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
{isFriend && (
|
{status && (
|
||||||
<span className="text-[10px] text-green-400 shrink-0">● friend</span>
|
<span className={`text-[10px] shrink-0 ${status.cls}`}>● {status.label}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{other?.pronouns && (
|
{other?.pronouns && (
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
import ConnectionItem from './ConnectionItem'
|
import ConnectionItem from './ConnectionItem'
|
||||||
|
import ConnectionMenu from './ConnectionMenu'
|
||||||
|
|
||||||
export default function ConnectionList() {
|
export default function ConnectionList() {
|
||||||
const { state, userId } = useApp()
|
const { state, userId } = useApp()
|
||||||
const [search, setSearch] = useState('')
|
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 => {
|
const filtered = state.connections.filter(conn => {
|
||||||
if (!search) return true
|
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>
|
<p className="text-gray-600 text-xs text-center py-4">No contacts</p>
|
||||||
)}
|
)}
|
||||||
{filtered.map(conn => (
|
{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>
|
</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>
|
</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 { useState, useRef } from 'react'
|
||||||
import { useApp } from '../context/AppContext'
|
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 { state, sendMessage } = useApp()
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
if (!trimmed || !state.selectedId) return
|
if ((!trimmed && !pendingFile) || !state.selectedId || busy) return
|
||||||
|
const file = pendingFile
|
||||||
|
setError('')
|
||||||
setText('')
|
setText('')
|
||||||
|
setPendingFile(null)
|
||||||
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await sendMessage(state.selectedId, trimmed)
|
await sendMessage(state.selectedId, trimmed, file)
|
||||||
} catch {
|
} catch (err) {
|
||||||
setText(trimmed)
|
setText(trimmed)
|
||||||
}
|
setPendingFile(file)
|
||||||
|
setError(err.message ?? String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyDown(e) {
|
function onKeyDown(e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -27,27 +44,68 @@ export default function MessageInput() {
|
|||||||
|
|
||||||
if (!state.selectedId) return null
|
if (!state.selectedId) return null
|
||||||
|
|
||||||
|
const canSend = !busy && (text.trim() || pendingFile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3 border-t border-gray-700/60">
|
<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
|
<textarea
|
||||||
ref={textareaRef}
|
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}
|
rows={1}
|
||||||
placeholder="Message…"
|
placeholder={busy ? 'Sending…' : 'Message…'}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={e => setText(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
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"
|
className="text-blue-400 hover:text-blue-300 disabled:text-gray-600 transition-colors pb-0.5 shrink-0"
|
||||||
title="Send (Enter)"
|
title="Send (Enter)"
|
||||||
>
|
>
|
||||||
↵
|
↵
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-xs text-red-400 truncate">{error}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
|
import { apiFetch } from '../api/http'
|
||||||
import { colorToCss } from '../utils/color'
|
import { colorToCss } from '../utils/color'
|
||||||
|
|
||||||
|
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i
|
||||||
|
|
||||||
function formatTime(iso) {
|
function formatTime(iso) {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileNameFromKey(key) {
|
||||||
|
return key.split('/').pop() || key
|
||||||
|
}
|
||||||
|
|
||||||
export default function MessageItem({ msg, showHeader }) {
|
export default function MessageItem({ msg, showHeader }) {
|
||||||
const { state, userId } = useApp()
|
const { state, userId } = useApp()
|
||||||
const isMe = msg.sender === userId
|
const isMe = msg.sender === userId
|
||||||
const sender = isMe ? state.currentUser : state.userMap[msg.sender]
|
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 (
|
return (
|
||||||
<div className={`flex gap-3 px-4 ${showHeader ? 'mt-4' : 'mt-0.5'} group`}>
|
<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>
|
<span className="text-xs text-gray-500">{formatTime(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{msg.content && (
|
||||||
<p className="text-sm text-gray-200 break-words leading-relaxed">{msg.content}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<span className="text-[10px] text-gray-600 opacity-0 group-hover:opacity-100 self-center shrink-0 transition-opacity">
|
<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 { useApp } from '../context/AppContext'
|
||||||
import { colorToCss } from '../utils/color'
|
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">
|
<span className="text-sm font-medium text-gray-200 truncate flex-1">
|
||||||
{user?.name ?? '…'}
|
{user?.name ?? '…'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Link
|
||||||
title="Settings (coming soon)"
|
to="/settings"
|
||||||
disabled
|
title="Settings"
|
||||||
className="text-gray-600 cursor-not-allowed text-base px-1"
|
className="text-gray-400 hover:text-gray-200 transition-colors text-base px-1"
|
||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const WS_CONN_DELETED = 3
|
|||||||
const WS_CONN_ELEVATED = 4
|
const WS_CONN_ELEVATED = 4
|
||||||
const WS_CONN_DEELEVATED = 5
|
const WS_CONN_DEELEVATED = 5
|
||||||
const WS_USER_PROFILE = 6
|
const WS_USER_PROFILE = 6
|
||||||
|
const WS_CONN_ELEVATE_PENDING = 10
|
||||||
|
|
||||||
const initial = {
|
const initial = {
|
||||||
connections: [],
|
connections: [],
|
||||||
@@ -64,6 +65,13 @@ function reducer(state, action) {
|
|||||||
c.id === action.id ? { ...c, state: action.newState } : c
|
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':
|
case 'PATCH_USER':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -91,7 +99,7 @@ export function AppProvider({ children }) {
|
|||||||
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
|
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
|
||||||
dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: 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 })
|
dispatch({ type: 'SET_CONNECTIONS', payload: conns })
|
||||||
|
|
||||||
const otherIds = [...new Set(
|
const otherIds = [...new Set(
|
||||||
@@ -107,7 +115,7 @@ export function AppProvider({ children }) {
|
|||||||
|
|
||||||
if (conns.length > 0) {
|
if (conns.length > 0) {
|
||||||
const ids = conns.map(c => c.id).join(',')
|
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 = {}
|
const unreadMap = {}
|
||||||
conns.forEach((c, i) => { unreadMap[c.id] = counts[i] || 0 })
|
conns.forEach((c, i) => { unreadMap[c.id] = counts[i] || 0 })
|
||||||
dispatch({ type: 'SET_UNREAD', payload: unreadMap })
|
dispatch({ type: 'SET_UNREAD', payload: unreadMap })
|
||||||
@@ -140,6 +148,10 @@ export function AppProvider({ children }) {
|
|||||||
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
|
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? } }
|
// event = { userId, profileChangeList: { pronouns?, description?, color? } }
|
||||||
wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => {
|
wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => {
|
||||||
dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList })
|
dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList })
|
||||||
@@ -157,7 +169,7 @@ export function AppProvider({ children }) {
|
|||||||
dispatch({ type: 'SELECT_CONNECTION', id })
|
dispatch({ type: 'SELECT_CONNECTION', id })
|
||||||
dispatch({ type: 'SET_MESSAGES', payload: [] })
|
dispatch({ type: 'SET_MESSAGES', payload: [] })
|
||||||
try {
|
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) {
|
if (selectedIdRef.current === id) {
|
||||||
dispatch({ type: 'SET_MESSAGES', payload: msgs })
|
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 uid = localStorage.getItem('userId')
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
content,
|
content,
|
||||||
attachedFile: '',
|
attachedFile,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sender: uid,
|
sender: uid,
|
||||||
receiver: connectionId,
|
receiver: connectionId,
|
||||||
}
|
}
|
||||||
dispatch({ type: 'APPEND_MESSAGE', payload: optimistic })
|
dispatch({ type: 'APPEND_MESSAGE', payload: optimistic })
|
||||||
try {
|
try {
|
||||||
await apiFetch('POST', '/connection/message', { body: { connectionid: connectionId, msgContent: content } })
|
await apiFetch('POST', '/connection/message', {
|
||||||
|
body: { connectionid: connectionId, msgContent: content, attachedFile },
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
|
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
|
||||||
throw err
|
throw err
|
||||||
@@ -186,7 +228,11 @@ export function AppProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={{ state, selectConnection, sendMessage, userId }}>
|
<Ctx.Provider value={{
|
||||||
|
state, userId,
|
||||||
|
selectConnection, sendMessage,
|
||||||
|
elevateConnection, deelevateConnection, deleteConnection,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</Ctx.Provider>
|
</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.
|
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 | |
|
| 4 | MainApp two-column layout | ✅ Done | |
|
||||||
| 5 | HubRow + UserBar | ✅ Done | `colorToCss` extracted to `src/utils/color.js` (shared), stub buttons `disabled` |
|
| 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 |
|
| 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 |
|
| 7 | MessageItem + MessageList + MessageInput + ChatArea | ✅ Done | Spec + code-quality review passed 2026-04-29; rollback consistent with `sendMessage` rethrow |
|
||||||
| 8 | End-to-end verification | ⬜ Pending | Run `pnpm dev` in `client/`, manual browser smoke test (see task for steps) |
|
| 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)
|
### 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/http.js`
|
||||||
- Create: `client/src/api/ws.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
|
```js
|
||||||
const BASE = 'http://localhost:8080'
|
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
|
```js
|
||||||
let socket = null
|
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.
|
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
|
```jsx
|
||||||
import { useState } from 'react'
|
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`.
|
Run `pnpm dev` in `client/`. Navigate to `http://localhost:5173/auth`.
|
||||||
- Enter valid credentials → should redirect to `/` (currently shows "Main App")
|
- 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.
|
`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
|
```jsx
|
||||||
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
|
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
|
||||||
@@ -487,7 +487,7 @@ export function useApp() {
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `client/src/components/MainApp.jsx`
|
- Modify: `client/src/components/MainApp.jsx`
|
||||||
|
|
||||||
- [ ] **Step 1: Replace `client/src/components/MainApp.jsx`**
|
- [x] **Step 1: Replace `client/src/components/MainApp.jsx`**
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { AppProvider } from '../context/AppContext'
|
import { AppProvider } from '../context/AppContext'
|
||||||
@@ -514,7 +514,7 @@ export default function MainApp() {
|
|||||||
- Create: `client/src/components/HubRow.jsx`
|
- Create: `client/src/components/HubRow.jsx`
|
||||||
- Create: `client/src/components/UserBar.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
|
```jsx
|
||||||
export default function HubRow() {
|
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
|
```jsx
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
@@ -576,7 +576,7 @@ export default function UserBar() {
|
|||||||
- Create: `client/src/components/ConnectionList.jsx`
|
- Create: `client/src/components/ConnectionList.jsx`
|
||||||
- Create: `client/src/components/Sidebar.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`.
|
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
|
```jsx
|
||||||
import { useState } from 'react'
|
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
|
```jsx
|
||||||
import HubRow from './HubRow'
|
import HubRow from './HubRow'
|
||||||
@@ -703,7 +703,7 @@ export default function Sidebar() {
|
|||||||
- Create: `client/src/components/MessageInput.jsx`
|
- Create: `client/src/components/MessageInput.jsx`
|
||||||
- Create: `client/src/components/ChatArea.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.
|
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).
|
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
|
```jsx
|
||||||
import { useState, useRef } from 'react'
|
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
|
```jsx
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext'
|
||||||
@@ -908,6 +908,8 @@ export default function ChatArea() {
|
|||||||
|
|
||||||
## Task 8: End-to-End Verification
|
## 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**
|
- [ ] **Step 1: Start the dev server**
|
||||||
|
|
||||||
In `client/`:
|
In `client/`:
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ const (
|
|||||||
UserAvatarChange
|
UserAvatarChange
|
||||||
UserProfileBgChange
|
UserProfileBgChange
|
||||||
HubMessage
|
HubMessage
|
||||||
|
ConnectionElevatePending
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"go-socket/packages/cache"
|
"go-socket/packages/cache"
|
||||||
"go-socket/packages/config"
|
"go-socket/packages/config"
|
||||||
"go-socket/packages/convertions"
|
"go-socket/packages/convertions"
|
||||||
|
"go-socket/packages/minio"
|
||||||
"go-socket/packages/postgresql"
|
"go-socket/packages/postgresql"
|
||||||
"go-socket/packages/types"
|
"go-socket/packages/types"
|
||||||
"go-socket/packages/wsServer"
|
"go-socket/packages/wsServer"
|
||||||
@@ -45,7 +46,7 @@ func HandleDm(response http.ResponseWriter, request *http.Request) {
|
|||||||
return
|
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)
|
http.Error(response, "invalid attachedFile", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -346,6 +347,18 @@ func HandleUserElevateConnection(response http.ResponseWriter, request *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn.UserWantingToElevate = user.Id
|
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"))
|
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")
|
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)
|
http.Error(response, "no such file", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpRequest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"go-socket/packages/convertions"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -15,16 +16,37 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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()
|
channel.Mu.RLock()
|
||||||
checkAgainst, ok := channel.UsersCachedPermissions[user.OriginalId]
|
checkAgainst, ok := channel.UsersCachedPermissions[user.OriginalId]
|
||||||
channel.Mu.RUnlock()
|
channel.Mu.RUnlock()
|
||||||
if !ok || (permissions&checkAgainst) != permissions {
|
if !ok || (needed&checkAgainst) != needed {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
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) {
|
func addHubUserToPermissionCache(hub *types.Hub, user *types.HubUser) {
|
||||||
user.Mu.RLock()
|
user.Mu.RLock()
|
||||||
roles := user.Roles
|
roles := user.Roles
|
||||||
@@ -177,6 +199,10 @@ func HandleHubMessage(response http.ResponseWriter, request *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hubUser.IsGlobalMuted {
|
||||||
|
http.Error(response, "muted", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
msgContent := request.FormValue("msgContent")
|
msgContent := request.FormValue("msgContent")
|
||||||
attachedFile := request.FormValue("attachedFile")
|
attachedFile := request.FormValue("attachedFile")
|
||||||
|
|
||||||
@@ -242,3 +268,216 @@ func HandleGetHubs(response http.ResponseWriter, request *http.Request) {
|
|||||||
response.WriteHeader(http.StatusOK)
|
response.WriteHeader(http.StatusOK)
|
||||||
response.Write(converted)
|
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"`
|
NewState ConnectionState.ConnectionState `json:"newState"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConnectionElevatePendingData struct {
|
||||||
|
Id uuid.UUID `json:"id"`
|
||||||
|
UserWantingToElevate uuid.UUID `json:"userWantingToElevate"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Id uuid.UUID `json:"id"`
|
Id uuid.UUID `json:"id"`
|
||||||
AttachedFile string `json:"attachedFile"`
|
AttachedFile string `json:"attachedFile"`
|
||||||
@@ -277,7 +282,7 @@ type HubUser struct {
|
|||||||
Roles HubBoundRoles `json:"-"`
|
Roles HubBoundRoles `json:"-"`
|
||||||
Name string `json:"name"` // Name empty = original name
|
Name string `json:"name"` // Name empty = original name
|
||||||
OriginalId uuid.UUID `json:"originalId"`
|
OriginalId uuid.UUID `json:"originalId"`
|
||||||
IsMuted bool `json:"isMuted"`
|
IsGlobalMuted bool `json:"isGlobalMuted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHubUser() *HubUser {
|
func NewHubUser() *HubUser {
|
||||||
@@ -290,7 +295,8 @@ type HubGroup struct {
|
|||||||
Color Rgba `json:"color"`
|
Color Rgba `json:"color"`
|
||||||
RolesCanView HubBoundRoles `json:"rolesCanView"`
|
RolesCanView HubBoundRoles `json:"rolesCanView"`
|
||||||
UsersCachedPermissions map[uuid.UUID]CachedUserPermissions `json:"-"`
|
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
|
Id uint8 `json:"id"` // Id 0 for unused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user