add hub get logic and part of client
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
const BASE = 'http://localhost:8080'
|
||||
|
||||
export async function apiFetch(method, path, { body, query } = {}) {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
let url = BASE + path
|
||||
if (query) url += '?' + new URLSearchParams(query)
|
||||
const opts = { method, headers: token ? { token } : {} }
|
||||
if (body) opts.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()
|
||||
try { return JSON.parse(text) } catch { return text }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
let socket = null
|
||||
const handlers = {}
|
||||
|
||||
export function wsOnEvent(type, fn) {
|
||||
handlers[type] = fn
|
||||
}
|
||||
|
||||
export function wsConnect() {
|
||||
if (socket) return
|
||||
const token = localStorage.getItem('token') || ''
|
||||
socket = new WebSocket('ws://localhost:8080/ws')
|
||||
socket.onopen = () => socket.send(JSON.stringify({ token }))
|
||||
socket.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
handlers[msg.type]?.(msg.event)
|
||||
} catch (err) { console.error('[ws] message error', err) }
|
||||
}
|
||||
socket.onclose = () => { socket = null }
|
||||
socket.onerror = (err) => console.error('[ws] error', err)
|
||||
}
|
||||
|
||||
export function wsDisconnect() {
|
||||
socket?.close()
|
||||
socket = null
|
||||
}
|
||||
@@ -1,24 +1,101 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const BASE = 'http://localhost:8080'
|
||||
|
||||
export default function AuthPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function handleLogin(token) {
|
||||
localStorage.setItem("token", token)
|
||||
navigate("/")
|
||||
async function doLogin() {
|
||||
const res = await fetch(`${BASE}/token`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ username, password }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Invalid credentials')
|
||||
const { token, userId } = await res.json()
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('userId', userId)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await doLogin()
|
||||
} catch {
|
||||
setError('Invalid credentials')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="flex flex-col gap-4 p-8 bg-gray-800 rounded-lg">
|
||||
<input className="bg-gray-700 text-white px-4 py-2 rounded" placeholder="Username" />
|
||||
<input className="bg-gray-700 text-white px-4 py-2 rounded" placeholder="Password" type="password" />
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button className="px-4 py-2 rounded bg-gray-700 hover:bg-gray-600">Register</button>
|
||||
<button className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
async function handleRegister() {
|
||||
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/user`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ username, password }),
|
||||
})
|
||||
if (!res.ok) { setError('Registration failed'); return }
|
||||
await doLogin()
|
||||
} catch {
|
||||
setError('Could not reach server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Enter' && !loading) handleLogin()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="flex flex-col gap-4 p-8 bg-gray-800 rounded-lg w-80">
|
||||
<h1 className="text-white text-xl font-semibold text-center">go-socket</h1>
|
||||
<input
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 text-white disabled:opacity-50"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
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)
|
||||
|
||||
let header = null
|
||||
if (conn) {
|
||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||
const other = state.userMap[otherId]
|
||||
header = (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-700/60 shrink-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(other?.color) }}
|
||||
>
|
||||
{other?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{other?.name ?? '…'}</p>
|
||||
{other?.pronouns && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-gray-800 min-w-0">
|
||||
{header}
|
||||
{state.selectedId ? (
|
||||
<>
|
||||
<MessageList />
|
||||
<MessageInput />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-600 text-sm">
|
||||
Select a conversation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
|
||||
export default function ConnectionItem({ conn }) {
|
||||
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
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => selectConnection(conn.id)}
|
||||
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'}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(other?.color) }}
|
||||
>
|
||||
{other?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{other?.pronouns && (
|
||||
<span className="text-xs text-gray-500 truncate block">{other.pronouns}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unread > 0 && (
|
||||
<span className="shrink-0 bg-red-500 text-white text-xs font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import ConnectionItem from './ConnectionItem'
|
||||
|
||||
export default function ConnectionList() {
|
||||
const { state, userId } = useApp()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = state.connections.filter(conn => {
|
||||
if (!search) return true
|
||||
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
|
||||
const other = state.userMap[otherId]
|
||||
const haystack = (other?.name ?? otherId.slice(0, 8)).toLowerCase()
|
||||
return haystack.includes(search.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
className="w-full bg-gray-700 text-white text-sm px-3 py-1.5 rounded-md outline-none placeholder-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-0.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-gray-600 text-xs text-center py-4">No contacts</p>
|
||||
)}
|
||||
{filtered.map(conn => (
|
||||
<ConnectionItem key={conn.id} conn={conn} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function HubRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-700/60">
|
||||
<button
|
||||
title="Add hub (coming soon)"
|
||||
disabled
|
||||
className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-500 text-lg leading-none cursor-not-allowed"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
export default function MainApp({ token }) {
|
||||
return <div>Main App</div>
|
||||
import { AppProvider } from '../context/AppContext'
|
||||
import Sidebar from './Sidebar'
|
||||
import ChatArea from './ChatArea'
|
||||
|
||||
export default function MainApp() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<div className="flex h-screen bg-gray-900 text-white overflow-hidden">
|
||||
<Sidebar />
|
||||
<ChatArea />
|
||||
</div>
|
||||
</AppProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
|
||||
export default function MessageInput() {
|
||||
const { state, sendMessage } = useApp()
|
||||
const [text, setText] = useState('')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !state.selectedId) return
|
||||
setText('')
|
||||
try {
|
||||
await sendMessage(state.selectedId, trimmed)
|
||||
} catch {
|
||||
setText(trimmed)
|
||||
}
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.selectedId) return null
|
||||
|
||||
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">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="flex-1 bg-transparent text-white text-sm resize-none outline-none placeholder-gray-500 max-h-36 leading-relaxed"
|
||||
rows={1}
|
||||
placeholder="Message…"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim()}
|
||||
className="text-blue-400 hover:text-blue-300 disabled:text-gray-600 transition-colors pb-0.5 shrink-0"
|
||||
title="Send (Enter)"
|
||||
>
|
||||
↵
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
|
||||
function formatTime(iso) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function MessageItem({ msg, showHeader }) {
|
||||
const { state, userId } = useApp()
|
||||
const isMe = msg.sender === userId
|
||||
const sender = isMe ? state.currentUser : state.userMap[msg.sender]
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 px-4 ${showHeader ? 'mt-4' : 'mt-0.5'} group`}>
|
||||
{showHeader ? (
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: colorToCss(sender?.color) }}
|
||||
>
|
||||
{sender?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{showHeader && (
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: colorToCss(sender?.color) }}
|
||||
>
|
||||
{sender?.name ?? msg.sender.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-200 break-words leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-gray-600 opacity-0 group-hover:opacity-100 self-center shrink-0 transition-opacity">
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import MessageItem from './MessageItem'
|
||||
|
||||
export default function MessageList() {
|
||||
const { state } = useApp()
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [state.messages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{state.messages.map((msg, i) => {
|
||||
const prev = state.messages[i - 1]
|
||||
const showHeader = !prev || prev.sender !== msg.sender
|
||||
return <MessageItem key={msg.id} msg={msg} showHeader={showHeader} />
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import HubRow from './HubRow'
|
||||
import ConnectionList from './ConnectionList'
|
||||
import UserBar from './UserBar'
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<div className="w-[280px] shrink-0 flex flex-col h-full bg-gray-900 border-r border-gray-700/60">
|
||||
<HubRow />
|
||||
<ConnectionList />
|
||||
<UserBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { colorToCss } from '../utils/color'
|
||||
|
||||
export default function UserBar() {
|
||||
const { state } = useApp()
|
||||
const user = state.currentUser
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-t border-gray-700/60 bg-gray-900/80">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
style={{ backgroundColor: colorToCss(user?.color) }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
|
||||
import { apiFetch } from '../api/http'
|
||||
import { wsConnect, wsDisconnect, wsOnEvent } from '../api/ws'
|
||||
|
||||
const Ctx = createContext(null)
|
||||
|
||||
const WS_AUTH = 0
|
||||
const WS_DM = 1
|
||||
const WS_CONN_CREATED = 2
|
||||
const WS_CONN_DELETED = 3
|
||||
const WS_CONN_ELEVATED = 4
|
||||
const WS_CONN_DEELEVATED = 5
|
||||
const WS_USER_PROFILE = 6
|
||||
|
||||
const initial = {
|
||||
connections: [],
|
||||
userMap: {},
|
||||
unread: {},
|
||||
selectedId: null,
|
||||
messages: [],
|
||||
currentUser: null,
|
||||
}
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'SET_CONNECTIONS':
|
||||
return { ...state, connections: action.payload }
|
||||
case 'SET_USER_MAP':
|
||||
return { ...state, userMap: { ...state.userMap, ...action.payload } }
|
||||
case 'SET_UNREAD':
|
||||
return { ...state, unread: action.payload }
|
||||
case 'SET_CURRENT_USER':
|
||||
return { ...state, currentUser: action.payload }
|
||||
case 'SELECT_CONNECTION':
|
||||
return {
|
||||
...state,
|
||||
selectedId: action.id,
|
||||
unread: { ...state.unread, [action.id]: 0 },
|
||||
}
|
||||
case 'SET_MESSAGES':
|
||||
return { ...state, messages: action.payload }
|
||||
case 'APPEND_MESSAGE':
|
||||
return { ...state, messages: [...state.messages, action.payload] }
|
||||
case 'REMOVE_MESSAGE':
|
||||
return { ...state, messages: state.messages.filter(m => m.id !== action.id) }
|
||||
case 'BUMP_UNREAD':
|
||||
return {
|
||||
...state,
|
||||
unread: { ...state.unread, [action.id]: (state.unread[action.id] || 0) + 1 },
|
||||
}
|
||||
case 'ADD_CONNECTION':
|
||||
return { ...state, connections: [action.payload, ...state.connections] }
|
||||
case 'REMOVE_CONNECTION':
|
||||
return {
|
||||
...state,
|
||||
connections: state.connections.filter(c => c.id !== action.id),
|
||||
selectedId: state.selectedId === action.id ? null : state.selectedId,
|
||||
messages: state.selectedId === action.id ? [] : state.messages,
|
||||
}
|
||||
case 'UPDATE_CONN_STATE':
|
||||
return {
|
||||
...state,
|
||||
connections: state.connections.map(c =>
|
||||
c.id === action.id ? { ...c, state: action.newState } : c
|
||||
),
|
||||
}
|
||||
case 'PATCH_USER':
|
||||
return {
|
||||
...state,
|
||||
userMap: {
|
||||
...state.userMap,
|
||||
[action.userId]: { ...state.userMap[action.userId], ...action.changes },
|
||||
},
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function AppProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(reducer, initial)
|
||||
const selectedIdRef = useRef(null)
|
||||
const userId = localStorage.getItem('userId')
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = state.selectedId
|
||||
}, [state.selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
|
||||
dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: userId } })
|
||||
|
||||
const conns = await apiFetch('GET', '/connections')
|
||||
dispatch({ type: 'SET_CONNECTIONS', payload: conns })
|
||||
|
||||
const otherIds = [...new Set(
|
||||
conns.map(c => c.requestorId === userId ? c.recipientId : c.requestorId)
|
||||
)]
|
||||
const entries = await Promise.all(
|
||||
otherIds.map(async id => {
|
||||
const u = await apiFetch('GET', '/user', { query: { targetid: id } })
|
||||
return [id, u]
|
||||
})
|
||||
)
|
||||
dispatch({ type: 'SET_USER_MAP', payload: Object.fromEntries(entries) })
|
||||
|
||||
if (conns.length > 0) {
|
||||
const ids = conns.map(c => c.id).join(',')
|
||||
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 })
|
||||
}
|
||||
|
||||
wsOnEvent(WS_DM, (event) => {
|
||||
if (event.receiver === selectedIdRef.current) {
|
||||
dispatch({ type: 'APPEND_MESSAGE', payload: event })
|
||||
} else {
|
||||
dispatch({ type: 'BUMP_UNREAD', id: event.receiver })
|
||||
}
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_CREATED, async (event) => {
|
||||
dispatch({ type: 'ADD_CONNECTION', payload: event })
|
||||
const otherId = event.requestorId === userId ? event.recipientId : event.requestorId
|
||||
const u = await apiFetch('GET', '/user', { query: { targetid: otherId } })
|
||||
dispatch({ type: 'SET_USER_MAP', payload: { [otherId]: u } })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_DELETED, (id) => {
|
||||
dispatch({ type: 'REMOVE_CONNECTION', id })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_ELEVATED, ({ id, newState }) => {
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
|
||||
})
|
||||
|
||||
wsOnEvent(WS_CONN_DEELEVATED, ({ id, newState }) => {
|
||||
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
|
||||
})
|
||||
|
||||
// event = { userId, profileChangeList: { pronouns?, description?, color? } }
|
||||
wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => {
|
||||
dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList })
|
||||
})
|
||||
// types 7 (avatar) and 8 (profilebg) don't affect our color-circle display — ignore
|
||||
|
||||
wsConnect()
|
||||
}
|
||||
|
||||
init().catch(err => console.error('[AppContext] init failed', err))
|
||||
return () => wsDisconnect()
|
||||
}, [])
|
||||
|
||||
const selectConnection = useCallback(async (id) => {
|
||||
dispatch({ type: 'SELECT_CONNECTION', id })
|
||||
dispatch({ type: 'SET_MESSAGES', payload: [] })
|
||||
try {
|
||||
const msgs = await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })
|
||||
if (selectedIdRef.current === id) {
|
||||
dispatch({ type: 'SET_MESSAGES', payload: msgs })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AppContext] failed to load messages for', id, err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendMessage = useCallback(async (connectionId, content) => {
|
||||
const uid = localStorage.getItem('userId')
|
||||
const optimistic = {
|
||||
id: crypto.randomUUID(),
|
||||
content,
|
||||
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 } })
|
||||
} catch (err) {
|
||||
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ state, selectConnection, sendMessage, userId }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
return useContext(Ctx)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const FALLBACK_COLOR = '#4b5563'
|
||||
|
||||
export function colorToCss(color) {
|
||||
if (!color) return FALLBACK_COLOR
|
||||
return `rgba(${color[0]},${color[1]},${color[2]},${(color[3] ?? 255) / 255})`
|
||||
}
|
||||
Reference in New Issue
Block a user