Add Ping frontend
React 19 + TypeScript client built with Vite. Zustand for local state, react-oidc-context for the login flow, a SignalR connection for chat and presence events, and LiveKit's React components for voice/video calls.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to .env.local and adjust to match deploy/.env (backend). Not versioned.
|
||||||
|
VITE_API_BASE_URL=http://localhost:5211
|
||||||
|
# Must match the backend's PUBLIC_AUTH_URL exactly — it's baked into every token's issuer claim.
|
||||||
|
VITE_AUTH_AUTHORITY=http://host.docker.internal:8080
|
||||||
|
VITE_AUTH_CLIENT_ID=
|
||||||
|
VITE_AUTH_REDIRECT_URI=http://localhost:3100/auth/callback
|
||||||
|
VITE_LIVEKIT_URL=ws://localhost:7880
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Build context: frontend/ (docker compose -f deploy/compose.yml --profile apps build)
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite inlines VITE_* variables at build time, so they have to be build args, not
|
||||||
|
# runtime environment variables — see deploy/compose.yml's web service.
|
||||||
|
ARG VITE_API_BASE_URL
|
||||||
|
ARG VITE_AUTH_AUTHORITY
|
||||||
|
ARG VITE_AUTH_CLIENT_ID
|
||||||
|
ARG VITE_AUTH_REDIRECT_URI
|
||||||
|
ARG VITE_LIVEKIT_URL
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||||
|
VITE_AUTH_AUTHORITY=$VITE_AUTH_AUTHORITY \
|
||||||
|
VITE_AUTH_CLIENT_ID=$VITE_AUTH_CLIENT_ID \
|
||||||
|
VITE_AUTH_REDIRECT_URI=$VITE_AUTH_REDIRECT_URI \
|
||||||
|
VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 3100
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ping</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 3100;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback: client-side routing (react-router) owns everything under /.
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1879
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/components-react": "^2.9.24",
|
||||||
|
"@livekit/components-styles": "^1.2.0",
|
||||||
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
"livekit-client": "^2.22.3",
|
||||||
|
"oidc-client-ts": "^3.5.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-oidc-context": "^3.3.1",
|
||||||
|
"react-router-dom": "^7.18.4",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,127 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
import { ChatHubProvider, useChatHub } from './hub/ChatHubProvider'
|
||||||
|
import { ServerRail } from './components/ServerRail'
|
||||||
|
import { ChannelSidebar } from './components/ChannelSidebar'
|
||||||
|
import { ChatPanel } from './components/ChatPanel'
|
||||||
|
import { FriendsPanel } from './components/FriendsPanel'
|
||||||
|
import { CallDock } from './components/CallDock'
|
||||||
|
import { CallSignaling } from './components/CallSignaling'
|
||||||
|
import { PresenceListener } from './components/PresenceListener'
|
||||||
|
import { PresenceDot } from './components/PresenceDot'
|
||||||
|
import { Avatar } from './components/Avatar'
|
||||||
|
import { ToastHost } from './components/ToastHost'
|
||||||
|
import { Login } from './pages/Login'
|
||||||
|
import { AuthCallback } from './pages/AuthCallback'
|
||||||
|
import { useUiStore } from './store/uiStore'
|
||||||
|
import { useCallStore } from './store/callStore'
|
||||||
|
import { useCallInviteStore } from './store/callInviteStore'
|
||||||
|
import { useToastStore } from './store/toastStore'
|
||||||
|
|
||||||
|
function AppShell() {
|
||||||
|
const view = useUiStore((s) => s.view)
|
||||||
|
const activeChannelId = useCallStore((s) => s.activeChannelId)
|
||||||
|
const join = useCallStore((s) => s.join)
|
||||||
|
const connection = useChatHub()
|
||||||
|
const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall)
|
||||||
|
const pushToast = useToastStore((s) => s.push)
|
||||||
|
|
||||||
|
async function startCall(channelId: string, calleeId: string, label: string) {
|
||||||
|
join(channelId, label)
|
||||||
|
if (!connection) return
|
||||||
|
try {
|
||||||
|
await connection.invoke('RingUser', channelId, calleeId)
|
||||||
|
setOutgoingCall({ channelId, calleeId, calleeName: label })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ring user', error)
|
||||||
|
pushToast('Could not ring the other user', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-root">
|
||||||
|
<div className="app-shell">
|
||||||
|
<ServerRail />
|
||||||
|
|
||||||
|
{view.kind === 'friends' && (
|
||||||
|
<div className="main-column">
|
||||||
|
<FriendsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.kind === 'channel' && (
|
||||||
|
<>
|
||||||
|
<ChannelSidebar serverId={view.serverId} />
|
||||||
|
<div className="main-column">
|
||||||
|
<ChatPanel key={view.channelId} channelId={view.channelId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.kind === 'dm' && (
|
||||||
|
<div className="main-column">
|
||||||
|
<div className="dm-header">
|
||||||
|
<span className="dm-header-user">
|
||||||
|
<Avatar
|
||||||
|
className="dm-header-avatar"
|
||||||
|
src={view.otherUser.avatarUrl}
|
||||||
|
fallbackText={(view.otherUser.displayName ?? view.otherUser.username).slice(0, 1).toUpperCase()}
|
||||||
|
alt={view.otherUser.displayName ?? view.otherUser.username}
|
||||||
|
/>
|
||||||
|
{view.otherUser.displayName ?? view.otherUser.username} <PresenceDot userId={view.otherUser.id} showLabel />
|
||||||
|
</span>
|
||||||
|
{activeChannelId !== view.channelId && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void startCall(view.channelId, view.otherUser.id, view.otherUser.displayName ?? view.otherUser.username)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
📞 Call
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChatPanel key={view.channelId} channelId={view.channelId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CallDock />
|
||||||
|
<CallSignaling />
|
||||||
|
<PresenceListener />
|
||||||
|
<ToastHost />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainLayout() {
|
||||||
|
return (
|
||||||
|
<ChatHubProvider>
|
||||||
|
<AppShell />
|
||||||
|
</ChatHubProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
auth.isLoading ? (
|
||||||
|
<div className="centered-screen">
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
) : auth.isAuthenticated ? (
|
||||||
|
<MainLayout />
|
||||||
|
) : (
|
||||||
|
<Login />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type {
|
||||||
|
AttachmentKind,
|
||||||
|
AttachmentUploadResponse,
|
||||||
|
CallTokenResponse,
|
||||||
|
ChannelResponse,
|
||||||
|
ChannelType,
|
||||||
|
FriendshipResponse,
|
||||||
|
MessageResponse,
|
||||||
|
ServerResponse,
|
||||||
|
UserResponse,
|
||||||
|
} from '../types/api'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageAttachmentInput {
|
||||||
|
key: string
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
kind: AttachmentKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiClient(getAccessToken: () => string | undefined) {
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = getAccessToken()
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '')
|
||||||
|
throw new ApiError(response.status, body || response.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentUser: () => request<UserResponse>('/users/me'),
|
||||||
|
getUserById: (id: string) => request<UserResponse>(`/users/${id}`),
|
||||||
|
searchUsers: (query: string) => request<UserResponse[]>(`/users/search?query=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
getFriends: () => request<FriendshipResponse[]>('/friends'),
|
||||||
|
getPendingFriendRequests: () => request<FriendshipResponse[]>('/friends/requests'),
|
||||||
|
sendFriendRequest: (addresseeId: string) =>
|
||||||
|
request<FriendshipResponse>('/friends/requests', { method: 'POST', body: JSON.stringify({ addresseeId }) }),
|
||||||
|
acceptFriendRequest: (id: string) => request<FriendshipResponse>(`/friends/requests/${id}/accept`, { method: 'POST' }),
|
||||||
|
rejectFriendRequest: (id: string) => request<void>(`/friends/requests/${id}/reject`, { method: 'POST' }),
|
||||||
|
removeFriend: (id: string) => request<void>(`/friends/${id}`, { method: 'DELETE' }),
|
||||||
|
blockUser: (userId: string) => request<void>(`/friends/block/${userId}`, { method: 'POST' }),
|
||||||
|
|
||||||
|
getServers: () => request<ServerResponse[]>('/servers'),
|
||||||
|
createServer: (name: string) => request<ServerResponse>('/servers', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
|
||||||
|
getServerChannels: (serverId: string) => request<ChannelResponse[]>(`/servers/${serverId}/channels`),
|
||||||
|
createChannel: (serverId: string, name: string, type: ChannelType) =>
|
||||||
|
request<ChannelResponse>(`/servers/${serverId}/channels`, { method: 'POST', body: JSON.stringify({ name, type }) }),
|
||||||
|
getOrCreateDirectMessageChannel: (otherUserId: string) =>
|
||||||
|
request<ChannelResponse>(`/channels/dm/${otherUserId}`, { method: 'POST' }),
|
||||||
|
|
||||||
|
getChannelMessages: (channelId: string, before?: string, take = 50) =>
|
||||||
|
request<MessageResponse[]>(
|
||||||
|
`/channels/${channelId}/messages?take=${take}${before ? `&before=${before}` : ''}`,
|
||||||
|
),
|
||||||
|
sendMessage: (channelId: string, content: string, attachments: MessageAttachmentInput[] = [], replyToMessageId?: string) =>
|
||||||
|
request<MessageResponse>(`/channels/${channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content, replyToMessageId, attachments }),
|
||||||
|
}),
|
||||||
|
editMessage: (id: string, content: string) =>
|
||||||
|
request<MessageResponse>(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ content }) }),
|
||||||
|
deleteMessage: (id: string) => request<void>(`/messages/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
requestAttachmentUploadUrl: (fileName: string, contentType: string) =>
|
||||||
|
request<AttachmentUploadResponse>('/attachments/upload-url', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ fileName, contentType }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
requestCallToken: (channelId: string) =>
|
||||||
|
request<CallTokenResponse>(`/channels/${channelId}/call-token`, { method: 'POST' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileToPresignedUrl(uploadUrl: string, file: File | Blob, contentType: string): Promise<void> {
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': contentType },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, `Failed to upload file: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiClient = ReturnType<typeof createApiClient>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
import { createApiClient } from './client'
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
return useMemo(() => createApiClient(() => auth.user?.access_token), [auth.user?.access_token])
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
import type { AuthProviderProps } from 'react-oidc-context'
|
||||||
|
import { WebStorageStateStore } from 'oidc-client-ts'
|
||||||
|
|
||||||
|
export const oidcConfig: AuthProviderProps = {
|
||||||
|
authority: import.meta.env.VITE_AUTH_AUTHORITY as string,
|
||||||
|
client_id: import.meta.env.VITE_AUTH_CLIENT_ID as string,
|
||||||
|
redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI as string,
|
||||||
|
post_logout_redirect_uri: window.location.origin,
|
||||||
|
scope: 'openid profile email offline_access',
|
||||||
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
onSigninCallback: () => {
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname.replace(/\/auth\/callback\/?$/, '/'))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src?: string | null
|
||||||
|
fallbackText: string
|
||||||
|
alt: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ src, fallbackText, alt, className = '' }: AvatarProps) {
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
if (src && !failed) {
|
||||||
|
return <img className={`avatar-img ${className}`} src={src} alt={alt} onError={() => setFailed(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`avatar-fallback ${className}`} title={alt}>
|
||||||
|
{fallbackText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { LiveKitRoom, VideoConference, useRemoteParticipants } from '@livekit/components-react'
|
||||||
|
import { AudioPresets, ScreenSharePresets, type RoomOptions } from 'livekit-client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import { useChatHub } from '../hub/ChatHubProvider'
|
||||||
|
import { useCallStore } from '../store/callStore'
|
||||||
|
import { useCallInviteStore } from '../store/callInviteStore'
|
||||||
|
import { useToastStore } from '../store/toastStore'
|
||||||
|
|
||||||
|
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL
|
||||||
|
|
||||||
|
const roomOptions: RoomOptions = {
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
publishDefaults: {
|
||||||
|
videoCodec: 'vp9',
|
||||||
|
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
|
||||||
|
audioPreset: AudioPresets.musicHighQualityStereo,
|
||||||
|
dtx: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function RingingWatcher() {
|
||||||
|
const remoteParticipants = useRemoteParticipants()
|
||||||
|
const outgoingCall = useCallInviteStore((s) => s.outgoingCall)
|
||||||
|
const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (outgoingCall && remoteParticipants.length > 0) {
|
||||||
|
setOutgoingCall(null)
|
||||||
|
}
|
||||||
|
}, [remoteParticipants.length, outgoingCall, setOutgoingCall])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallDock() {
|
||||||
|
const api = useApi()
|
||||||
|
const connection = useChatHub()
|
||||||
|
const activeChannelId = useCallStore((s) => s.activeChannelId)
|
||||||
|
const activeChannelLabel = useCallStore((s) => s.activeChannelLabel)
|
||||||
|
const leave = useCallStore((s) => s.leave)
|
||||||
|
const outgoingCall = useCallInviteStore((s) => s.outgoingCall)
|
||||||
|
const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall)
|
||||||
|
const pushToast = useToastStore((s) => s.push)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChannelId) {
|
||||||
|
setToken(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setToken(null)
|
||||||
|
void api
|
||||||
|
.requestCallToken(activeChannelId)
|
||||||
|
.then((response) => {
|
||||||
|
if (!cancelled) setToken(response.token)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return
|
||||||
|
pushToast(error instanceof ApiError ? error.message : 'Failed to join the call', 'error')
|
||||||
|
leave()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [api, activeChannelId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChannelId || !connection) return
|
||||||
|
void connection.invoke('SetBusy', true).catch(() => undefined)
|
||||||
|
return () => {
|
||||||
|
void connection.invoke('SetBusy', false).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, [activeChannelId, connection])
|
||||||
|
|
||||||
|
if (!activeChannelId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRinging = outgoingCall?.channelId === activeChannelId
|
||||||
|
|
||||||
|
function handleLeave() {
|
||||||
|
if (outgoingCall?.channelId === activeChannelId) {
|
||||||
|
void connection?.invoke('CancelCall', outgoingCall.channelId, outgoingCall.calleeId).catch(() => undefined)
|
||||||
|
setOutgoingCall(null)
|
||||||
|
}
|
||||||
|
leave()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="call-dock">
|
||||||
|
<div className="call-dock-header">
|
||||||
|
<span>
|
||||||
|
🔊 {activeChannelLabel}
|
||||||
|
{isRinging && <span className="call-ringing-badge">Ringing…</span>}
|
||||||
|
</span>
|
||||||
|
<button onClick={handleLeave}>Leave call</button>
|
||||||
|
</div>
|
||||||
|
{token ? (
|
||||||
|
<LiveKitRoom
|
||||||
|
serverUrl={LIVEKIT_URL}
|
||||||
|
token={token}
|
||||||
|
connect
|
||||||
|
audio
|
||||||
|
video={false}
|
||||||
|
options={roomOptions}
|
||||||
|
onDisconnected={handleLeave}
|
||||||
|
className="call-dock-room"
|
||||||
|
>
|
||||||
|
<RingingWatcher />
|
||||||
|
<VideoConference />
|
||||||
|
</LiveKitRoom>
|
||||||
|
) : (
|
||||||
|
<p className="call-dock-connecting">Connecting…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import { useChatHub } from '../hub/ChatHubProvider'
|
||||||
|
import { useHubEvent } from '../hub/useHubEvent'
|
||||||
|
import { useCallStore } from '../store/callStore'
|
||||||
|
import { useCallInviteStore } from '../store/callInviteStore'
|
||||||
|
import { useToastStore } from '../store/toastStore'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
export function CallSignaling() {
|
||||||
|
const api = useApi()
|
||||||
|
const connection = useChatHub()
|
||||||
|
const activeChannelId = useCallStore((s) => s.activeChannelId)
|
||||||
|
const join = useCallStore((s) => s.join)
|
||||||
|
const leave = useCallStore((s) => s.leave)
|
||||||
|
const incomingCall = useCallInviteStore((s) => s.incomingCall)
|
||||||
|
const outgoingCall = useCallInviteStore((s) => s.outgoingCall)
|
||||||
|
const setIncomingCall = useCallInviteStore((s) => s.setIncomingCall)
|
||||||
|
const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall)
|
||||||
|
const push = useToastStore((s) => s.push)
|
||||||
|
|
||||||
|
const onIncomingCall = useCallback(
|
||||||
|
(channelId: string, callerId: string) => {
|
||||||
|
void api.getUserById(callerId).then((caller) => {
|
||||||
|
setIncomingCall({
|
||||||
|
channelId,
|
||||||
|
callerId,
|
||||||
|
callerName: caller.displayName ?? caller.username,
|
||||||
|
callerAvatarUrl: caller.avatarUrl,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[api, setIncomingCall],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onCallDeclined = useCallback(
|
||||||
|
(channelId: string) => {
|
||||||
|
if (outgoingCall?.channelId !== channelId) return
|
||||||
|
push(`${outgoingCall.calleeName} declined the call`, 'info')
|
||||||
|
setOutgoingCall(null)
|
||||||
|
if (activeChannelId === channelId) leave()
|
||||||
|
},
|
||||||
|
[outgoingCall, push, setOutgoingCall, activeChannelId, leave],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onCallCancelled = useCallback(
|
||||||
|
(channelId: string) => {
|
||||||
|
if (incomingCall?.channelId === channelId) setIncomingCall(null)
|
||||||
|
},
|
||||||
|
[incomingCall, setIncomingCall],
|
||||||
|
)
|
||||||
|
|
||||||
|
useHubEvent('IncomingCall', onIncomingCall)
|
||||||
|
useHubEvent('CallDeclined', onCallDeclined)
|
||||||
|
useHubEvent('CallCancelled', onCallCancelled)
|
||||||
|
|
||||||
|
function acceptCall() {
|
||||||
|
if (!incomingCall) return
|
||||||
|
join(incomingCall.channelId, incomingCall.callerName)
|
||||||
|
setIncomingCall(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function declineCall() {
|
||||||
|
if (!incomingCall) return
|
||||||
|
const { channelId, callerId } = incomingCall
|
||||||
|
setIncomingCall(null)
|
||||||
|
try {
|
||||||
|
await connection?.invoke('DeclineCall', channelId, callerId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send DeclineCall', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!incomingCall) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Incoming call" onClose={() => void declineCall()}>
|
||||||
|
<div className="incoming-call-caller">
|
||||||
|
<Avatar
|
||||||
|
className="incoming-call-avatar"
|
||||||
|
src={incomingCall.callerAvatarUrl}
|
||||||
|
fallbackText={incomingCall.callerName.slice(0, 1).toUpperCase()}
|
||||||
|
alt={incomingCall.callerName}
|
||||||
|
/>
|
||||||
|
<p>{incomingCall.callerName} is calling you…</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={() => void declineCall()}>Decline</button>
|
||||||
|
<button className="accept-button" onClick={acceptCall}>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import type { ChannelResponse, ChannelType } from '../types/api'
|
||||||
|
import { useUiStore } from '../store/uiStore'
|
||||||
|
import { useCallStore } from '../store/callStore'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
export function ChannelSidebar({ serverId }: { serverId: string }) {
|
||||||
|
const api = useApi()
|
||||||
|
const auth = useAuth()
|
||||||
|
const { view, setView } = useUiStore()
|
||||||
|
const { activeChannelId, join, leave } = useCallStore()
|
||||||
|
const [channels, setChannels] = useState<ChannelResponse[]>([])
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [draftName, setDraftName] = useState('')
|
||||||
|
const [draftType, setDraftType] = useState<ChannelType>('Text')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.getServerChannels(serverId).then(setChannels)
|
||||||
|
}, [api, serverId])
|
||||||
|
|
||||||
|
async function createChannel() {
|
||||||
|
if (!draftName.trim()) return
|
||||||
|
const channel = await api.createChannel(serverId, draftName.trim(), draftType)
|
||||||
|
setChannels((prev) => [...prev, channel])
|
||||||
|
setDraftName('')
|
||||||
|
setDraftType('Text')
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="channel-sidebar">
|
||||||
|
<div className="channel-sidebar-header">Channels</div>
|
||||||
|
<div className="channel-list">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<div key={channel.id} className="channel-row">
|
||||||
|
<button
|
||||||
|
className={`channel-item ${view.kind === 'channel' && view.channelId === channel.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setView({ kind: 'channel', serverId, channelId: channel.id })}
|
||||||
|
>
|
||||||
|
{channel.type === 'Voice' ? '🔊' : '#'} {channel.name}
|
||||||
|
</button>
|
||||||
|
{channel.type === 'Voice' &&
|
||||||
|
(activeChannelId === channel.id ? (
|
||||||
|
<button className="channel-call-toggle" onClick={leave}>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="channel-call-toggle" onClick={() => join(channel.id, `${channel.name}`)}>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="channel-item add" onClick={() => setIsCreating(true)}>
|
||||||
|
+ Add channel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="current-user-footer">
|
||||||
|
<span className="current-user-info">
|
||||||
|
<Avatar
|
||||||
|
className="current-user-avatar"
|
||||||
|
src={auth.user?.profile.picture}
|
||||||
|
fallbackText={(auth.user?.profile.preferred_username ?? auth.user?.profile.name ?? '?').slice(0, 1).toUpperCase()}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{auth.user?.profile.preferred_username ?? auth.user?.profile.name}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => void auth.signoutRedirect()}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<Modal title="Create a channel" onClose={() => setIsCreating(false)}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Channel name"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && void createChannel()}
|
||||||
|
/>
|
||||||
|
<div className="modal-radio-row">
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={draftType === 'Text'} onChange={() => setDraftType('Text')} /># Text
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={draftType === 'Voice'} onChange={() => setDraftType('Voice')} /> 🔊 Voice
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={() => setIsCreating(false)}>Cancel</button>
|
||||||
|
<button onClick={() => void createChannel()}>Create</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import type { MessageAttachmentInput } from '../api/client'
|
||||||
|
import type { MessageResponse } from '../types/api'
|
||||||
|
import { useChatHub } from '../hub/ChatHubProvider'
|
||||||
|
import { useHubEvent } from '../hub/useHubEvent'
|
||||||
|
import { MessageItem } from './MessageItem'
|
||||||
|
import { MessageComposer } from './MessageComposer'
|
||||||
|
|
||||||
|
const GROUP_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
export function ChatPanel({ channelId }: { channelId: string }) {
|
||||||
|
const api = useApi()
|
||||||
|
const auth = useAuth()
|
||||||
|
const connection = useChatHub()
|
||||||
|
const [messages, setMessages] = useState<MessageResponse[]>([])
|
||||||
|
const currentUserId = auth.user?.profile.sub
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void api.getChannelMessages(channelId).then((page) => {
|
||||||
|
if (!cancelled) setMessages([...page].reverse())
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [api, channelId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection) return
|
||||||
|
void connection.invoke('JoinChannel', channelId).catch((error) => console.error('JoinChannel failed', error))
|
||||||
|
return () => {
|
||||||
|
void connection.invoke('LeaveChannel', channelId).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, [connection, channelId])
|
||||||
|
|
||||||
|
const onMessageCreated = useCallback(
|
||||||
|
(message: MessageResponse) => {
|
||||||
|
if (message.channelId !== channelId) return
|
||||||
|
setMessages((prev) => [...prev, message])
|
||||||
|
},
|
||||||
|
[channelId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMessageUpdated = useCallback(
|
||||||
|
(message: MessageResponse) => {
|
||||||
|
if (message.channelId !== channelId) return
|
||||||
|
setMessages((prev) => prev.map((m) => (m.id === message.id ? message : m)))
|
||||||
|
},
|
||||||
|
[channelId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMessageDeleted = useCallback((messageId: string) => {
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== messageId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useHubEvent('MessageCreated', onMessageCreated)
|
||||||
|
useHubEvent('MessageUpdated', onMessageUpdated)
|
||||||
|
useHubEvent('MessageDeleted', onMessageDeleted)
|
||||||
|
|
||||||
|
async function handleSend(content: string, attachments: MessageAttachmentInput[]) {
|
||||||
|
await api.sendMessage(channelId, content, attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(id: string, newContent: string) {
|
||||||
|
await api.editMessage(id, newContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await api.deleteMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-panel">
|
||||||
|
<div className="message-list">
|
||||||
|
{messages.length === 0 && <p className="empty-state">No messages yet. Say hi!</p>}
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const previous = messages[index - 1]
|
||||||
|
const showHeader =
|
||||||
|
!previous ||
|
||||||
|
previous.author.id !== message.author.id ||
|
||||||
|
new Date(message.createdAt).getTime() - new Date(previous.createdAt).getTime() > GROUP_WINDOW_MS
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isOwn={message.author.id === currentUserId}
|
||||||
|
showHeader={showHeader}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<MessageComposer onSend={handleSend} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Modal } from './Modal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({ title, message, confirmLabel = 'Confirm', onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onCancel}>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="danger-button" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import type { FriendshipResponse, UserResponse } from '../types/api'
|
||||||
|
import { useHubEvent } from '../hub/useHubEvent'
|
||||||
|
import { useUiStore } from '../store/uiStore'
|
||||||
|
import { usePresenceStore } from '../store/presenceStore'
|
||||||
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
|
import { PresenceDot } from './PresenceDot'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
function userLabel(user: UserResponse) {
|
||||||
|
return user.displayName ?? user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingConfirm {
|
||||||
|
kind: 'remove' | 'block'
|
||||||
|
friendshipId: string
|
||||||
|
userId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FriendsPanel() {
|
||||||
|
const api = useApi()
|
||||||
|
const setView = useUiStore((s) => s.setView)
|
||||||
|
const [friends, setFriends] = useState<FriendshipResponse[]>([])
|
||||||
|
const [requests, setRequests] = useState<FriendshipResponse[]>([])
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<UserResponse[]>([])
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null)
|
||||||
|
const seedPresence = usePresenceStore((s) => s.seed)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
void api.getFriends().then((friendships) => {
|
||||||
|
setFriends(friendships)
|
||||||
|
seedPresence(friendships.map((f) => [f.otherUser.id, f.presence]))
|
||||||
|
})
|
||||||
|
void api.getPendingFriendRequests().then(setRequests)
|
||||||
|
}, [api, seedPresence])
|
||||||
|
|
||||||
|
useEffect(refresh, [refresh])
|
||||||
|
useHubEvent('FriendRequestReceived', refresh)
|
||||||
|
useHubEvent('FriendshipUpdated', refresh)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const handle = setTimeout(() => void api.searchUsers(query.trim()).then(setResults), 300)
|
||||||
|
return () => clearTimeout(handle)
|
||||||
|
}, [api, query])
|
||||||
|
|
||||||
|
async function runAction(action: Promise<unknown>) {
|
||||||
|
setErrorMessage(null)
|
||||||
|
try {
|
||||||
|
await action
|
||||||
|
refresh()
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof ApiError ? error.message : 'Something went wrong')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDirectMessage(user: UserResponse) {
|
||||||
|
try {
|
||||||
|
const channel = await api.getOrCreateDirectMessageChannel(user.id)
|
||||||
|
setView({ kind: 'dm', channelId: channel.id, otherUser: user })
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof ApiError ? error.message : 'Something went wrong')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming = requests.filter((r) => r.isIncoming)
|
||||||
|
const outgoing = requests.filter((r) => !r.isIncoming)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="friends-panel">
|
||||||
|
{errorMessage && <p className="error-text">{errorMessage}</p>}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Add friend</h2>
|
||||||
|
<input placeholder="Search by username…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
|
<ul className="user-list">
|
||||||
|
{results.map((user) => (
|
||||||
|
<li key={user.id}>
|
||||||
|
<Avatar className="user-avatar-sm" src={user.avatarUrl} fallbackText={userLabel(user).slice(0, 1).toUpperCase()} alt={userLabel(user)} />
|
||||||
|
<span>{userLabel(user)}</span>
|
||||||
|
<button onClick={() => void runAction(api.sendFriendRequest(user.id))}>Add friend</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{incoming.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2>Incoming requests</h2>
|
||||||
|
<ul className="user-list">
|
||||||
|
{incoming.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Avatar
|
||||||
|
className="user-avatar-sm"
|
||||||
|
src={r.otherUser.avatarUrl}
|
||||||
|
fallbackText={userLabel(r.otherUser).slice(0, 1).toUpperCase()}
|
||||||
|
alt={userLabel(r.otherUser)}
|
||||||
|
/>
|
||||||
|
<span>{userLabel(r.otherUser)}</span>
|
||||||
|
<button onClick={() => void runAction(api.acceptFriendRequest(r.id))}>Accept</button>
|
||||||
|
<button onClick={() => void runAction(api.rejectFriendRequest(r.id))}>Reject</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outgoing.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2>Outgoing requests</h2>
|
||||||
|
<ul className="user-list">
|
||||||
|
{outgoing.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Avatar
|
||||||
|
className="user-avatar-sm"
|
||||||
|
src={r.otherUser.avatarUrl}
|
||||||
|
fallbackText={userLabel(r.otherUser).slice(0, 1).toUpperCase()}
|
||||||
|
alt={userLabel(r.otherUser)}
|
||||||
|
/>
|
||||||
|
<span>{userLabel(r.otherUser)}</span>
|
||||||
|
<em>Pending</em>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Friends</h2>
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<p className="empty-state">No friends yet — search a username above to add one.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="user-list">
|
||||||
|
{friends.map((f) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<Avatar
|
||||||
|
className="user-avatar-sm"
|
||||||
|
src={f.otherUser.avatarUrl}
|
||||||
|
fallbackText={userLabel(f.otherUser).slice(0, 1).toUpperCase()}
|
||||||
|
alt={userLabel(f.otherUser)}
|
||||||
|
/>
|
||||||
|
<PresenceDot userId={f.otherUser.id} />
|
||||||
|
<span>{userLabel(f.otherUser)}</span>
|
||||||
|
<button onClick={() => void openDirectMessage(f.otherUser)}>Message</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setPendingConfirm({
|
||||||
|
kind: 'remove',
|
||||||
|
friendshipId: f.id,
|
||||||
|
userId: f.otherUser.id,
|
||||||
|
label: userLabel(f.otherUser),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setPendingConfirm({
|
||||||
|
kind: 'block',
|
||||||
|
friendshipId: f.id,
|
||||||
|
userId: f.otherUser.id,
|
||||||
|
label: userLabel(f.otherUser),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Block
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pendingConfirm && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={pendingConfirm.kind === 'remove' ? 'Remove friend' : 'Block user'}
|
||||||
|
message={
|
||||||
|
pendingConfirm.kind === 'remove'
|
||||||
|
? `Remove ${pendingConfirm.label} from your friends?`
|
||||||
|
: `Block ${pendingConfirm.label}? They won't be able to message you, and this also removes them as a friend.`
|
||||||
|
}
|
||||||
|
confirmLabel={pendingConfirm.kind === 'remove' ? 'Remove' : 'Block'}
|
||||||
|
onCancel={() => setPendingConfirm(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const action = pendingConfirm
|
||||||
|
setPendingConfirm(null)
|
||||||
|
void runAction(action.kind === 'remove' ? api.removeFriend(action.friendshipId) : api.blockUser(action.userId))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import { ApiError, uploadFileToPresignedUrl } from '../api/client'
|
||||||
|
import type { MessageAttachmentInput } from '../api/client'
|
||||||
|
import type { AttachmentKind } from '../types/api'
|
||||||
|
import { useToastStore } from '../store/toastStore'
|
||||||
|
|
||||||
|
interface PendingAttachment {
|
||||||
|
file: File | Blob
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
kind: AttachmentKind
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessKind(contentType: string): AttachmentKind {
|
||||||
|
if (contentType.startsWith('image/')) return 'Image'
|
||||||
|
if (contentType.startsWith('audio/')) return 'Audio'
|
||||||
|
return 'File'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageComposer({ onSend }: { onSend: (content: string, attachments: MessageAttachmentInput[]) => Promise<void> }) {
|
||||||
|
const api = useApi()
|
||||||
|
const pushToast = useToastStore((s) => s.push)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [pending, setPending] = useState<PendingAttachment[]>([])
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const recordedChunksRef = useRef<Blob[]>([])
|
||||||
|
|
||||||
|
function onFilesPicked(files: FileList | null) {
|
||||||
|
if (!files) return
|
||||||
|
const additions = Array.from(files).map((file) => ({
|
||||||
|
file,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type || 'application/octet-stream',
|
||||||
|
kind: guessKind(file.type),
|
||||||
|
}))
|
||||||
|
setPending((prev) => [...prev, ...additions])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (isRecording) {
|
||||||
|
mediaRecorderRef.current?.stop()
|
||||||
|
setIsRecording(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const recorder = new MediaRecorder(stream)
|
||||||
|
recordedChunksRef.current = []
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) recordedChunksRef.current.push(e.data)
|
||||||
|
}
|
||||||
|
recorder.onstop = () => {
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
const blob = new Blob(recordedChunksRef.current, { type: 'audio/webm' })
|
||||||
|
setPending((prev) => [...prev, { file: blob, fileName: `voice-message-${Date.now()}.webm`, contentType: 'audio/webm', kind: 'Audio' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
recorder.start()
|
||||||
|
setIsRecording(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(index: number) {
|
||||||
|
setPending((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!content.trim() && pending.length === 0) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
try {
|
||||||
|
const uploaded: MessageAttachmentInput[] = []
|
||||||
|
for (const attachment of pending) {
|
||||||
|
const { uploadUrl, key } = await api.requestAttachmentUploadUrl(attachment.fileName, attachment.contentType)
|
||||||
|
await uploadFileToPresignedUrl(uploadUrl, attachment.file, attachment.contentType)
|
||||||
|
uploaded.push({ key, fileName: attachment.fileName, contentType: attachment.contentType, kind: attachment.kind })
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSend(content.trim(), uploaded)
|
||||||
|
setContent('')
|
||||||
|
setPending([])
|
||||||
|
} catch (error) {
|
||||||
|
pushToast(error instanceof ApiError ? error.message : 'Failed to send message', 'error')
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-composer">
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<ul className="pending-attachments">
|
||||||
|
{pending.map((attachment, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{attachment.fileName}
|
||||||
|
<button onClick={() => removePending(index)}>✕</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="composer-row">
|
||||||
|
<input type="file" ref={fileInputRef} multiple hidden onChange={(e) => onFilesPicked(e.target.files)} />
|
||||||
|
<button title="Attach file" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
<button title={isRecording ? 'Stop recording' : 'Record voice message'} onClick={() => void toggleRecording()}>
|
||||||
|
{isRecording ? '⏹' : '🎙'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="composer-input"
|
||||||
|
placeholder="Message…"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button disabled={isSending} onClick={() => void handleSend()}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { MessageResponse } from '../types/api'
|
||||||
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { formatMessageTimestamp, formatShortTime } from '../utils/formatTimestamp'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: MessageResponse
|
||||||
|
isOwn: boolean
|
||||||
|
showHeader: boolean
|
||||||
|
onEdit: (id: string, content: string) => Promise<void>
|
||||||
|
onDelete: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageItem({ message, isOwn, showHeader, onEdit, onDelete }: Props) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(message.content)
|
||||||
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||||
|
const authorName = message.author.displayName ?? message.author.username
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (draft.trim() && draft !== message.content) {
|
||||||
|
await onEdit(message.id, draft.trim())
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`message-item ${showHeader ? '' : 'compact'}`}>
|
||||||
|
{showHeader ? (
|
||||||
|
<Avatar className="message-avatar" src={message.author.avatarUrl} fallbackText={authorName.slice(0, 1).toUpperCase()} alt={authorName} />
|
||||||
|
) : (
|
||||||
|
<span className="message-timestamp-gutter">{formatShortTime(message.createdAt)}</span>
|
||||||
|
)}
|
||||||
|
<div className="message-body">
|
||||||
|
{showHeader && (
|
||||||
|
<div className="message-header">
|
||||||
|
<span className="message-author">{authorName}</span>
|
||||||
|
<span className="message-timestamp">{formatMessageTimestamp(message.createdAt)}</span>
|
||||||
|
{message.editedAt && <span className="message-edited">(edited)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="message-edit-row">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void saveEdit()
|
||||||
|
if (e.key === 'Escape') setIsEditing(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => void saveEdit()}>Save</button>
|
||||||
|
<button onClick={() => setIsEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="message-content">
|
||||||
|
{message.content}
|
||||||
|
{!showHeader && message.editedAt && <span className="message-edited"> (edited)</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.attachments.length > 0 && (
|
||||||
|
<ul className="message-attachments">
|
||||||
|
{message.attachments.map((attachment) => (
|
||||||
|
<li key={attachment.id}>
|
||||||
|
{attachment.kind === 'Audio' ? '🎤' : attachment.kind === 'Image' ? '🖼' : '📎'} {attachment.fileName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwn && !isEditing && (
|
||||||
|
<div className="message-actions">
|
||||||
|
<button onClick={() => setIsEditing(true)}>Edit</button>
|
||||||
|
<button onClick={() => setIsConfirmingDelete(true)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConfirmingDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete message"
|
||||||
|
message="This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setIsConfirmingDelete(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setIsConfirmingDelete(false)
|
||||||
|
void onDelete(message.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { usePresenceStore } from '../store/presenceStore'
|
||||||
|
import type { PresenceStatus } from '../types/api'
|
||||||
|
|
||||||
|
const LABELS: Record<PresenceStatus, string> = {
|
||||||
|
Online: 'Online',
|
||||||
|
Busy: 'In a call',
|
||||||
|
Offline: 'Offline',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresenceDot({ userId, showLabel = false }: { userId: string; showLabel?: boolean }) {
|
||||||
|
const status = usePresenceStore((s) => s.statuses[userId] ?? 'Offline')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="presence" title={LABELS[status]}>
|
||||||
|
<span className={`presence-dot presence-dot-${status.toLowerCase()}`} />
|
||||||
|
{showLabel && <span className="presence-label">{LABELS[status]}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useHubEvent } from '../hub/useHubEvent'
|
||||||
|
import { usePresenceStore } from '../store/presenceStore'
|
||||||
|
import type { PresenceStatus } from '../types/api'
|
||||||
|
|
||||||
|
export function PresenceListener() {
|
||||||
|
const setStatus = usePresenceStore((s) => s.setStatus)
|
||||||
|
|
||||||
|
const onPresenceChanged = useCallback(
|
||||||
|
(userId: string, status: PresenceStatus) => setStatus(userId, status),
|
||||||
|
[setStatus],
|
||||||
|
)
|
||||||
|
|
||||||
|
useHubEvent('PresenceChanged', onPresenceChanged)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useApi } from '../api/useApi'
|
||||||
|
import type { ServerResponse } from '../types/api'
|
||||||
|
import { useUiStore } from '../store/uiStore'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
export function ServerRail() {
|
||||||
|
const api = useApi()
|
||||||
|
const { view, setView } = useUiStore()
|
||||||
|
const [servers, setServers] = useState<ServerResponse[]>([])
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [draftName, setDraftName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.getServers().then(setServers)
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
async function selectServer(server: ServerResponse) {
|
||||||
|
const channels = await api.getServerChannels(server.id)
|
||||||
|
const firstTextChannel = channels.find((c) => c.type === 'Text') ?? channels[0]
|
||||||
|
if (firstTextChannel) {
|
||||||
|
setView({ kind: 'channel', serverId: server.id, channelId: firstTextChannel.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServer() {
|
||||||
|
if (!draftName.trim()) return
|
||||||
|
const server = await api.createServer(draftName.trim())
|
||||||
|
setServers((prev) => [...prev, server])
|
||||||
|
setDraftName('')
|
||||||
|
setIsCreating(false)
|
||||||
|
void selectServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeServerId = view.kind === 'channel' ? view.serverId : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="server-rail">
|
||||||
|
<button
|
||||||
|
className={`server-icon ${view.kind === 'friends' ? 'active' : ''}`}
|
||||||
|
title="Friends"
|
||||||
|
onClick={() => setView({ kind: 'friends' })}
|
||||||
|
>
|
||||||
|
DM
|
||||||
|
</button>
|
||||||
|
<div className="server-rail-divider" />
|
||||||
|
{servers.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.id}
|
||||||
|
className={`server-icon ${activeServerId === server.id ? 'active' : ''}`}
|
||||||
|
title={server.name}
|
||||||
|
onClick={() => void selectServer(server)}
|
||||||
|
>
|
||||||
|
<Avatar className="server-icon-avatar" src={server.iconUrl} fallbackText={server.name.slice(0, 2).toUpperCase()} alt={server.name} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="server-icon add" title="Create server" onClick={() => setIsCreating(true)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<Modal title="Create a server" onClose={() => setIsCreating(false)}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Server name"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && void createServer()}
|
||||||
|
/>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={() => setIsCreating(false)}>Cancel</button>
|
||||||
|
<button onClick={() => void createServer()}>Create</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useToastStore } from '../store/toastStore'
|
||||||
|
|
||||||
|
export function ToastHost() {
|
||||||
|
const toasts = useToastStore((s) => s.toasts)
|
||||||
|
const dismiss = useToastStore((s) => s.dismiss)
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-host">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`toast toast-${toast.tone}`} onClick={() => dismiss(toast.id)}>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
||||||
|
|
||||||
|
const ChatHubContext = createContext<HubConnection | null>(null)
|
||||||
|
|
||||||
|
export function ChatHubProvider({ children }: { children: ReactNode }) {
|
||||||
|
const auth = useAuth()
|
||||||
|
const [connection, setConnection] = useState<HubConnection | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubConnection = new HubConnectionBuilder()
|
||||||
|
.withUrl(`${API_BASE_URL}/hubs/chat`, {
|
||||||
|
accessTokenFactory: () => auth.user?.access_token ?? '',
|
||||||
|
})
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Warning)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
let cleanedUp = false
|
||||||
|
|
||||||
|
hubConnection
|
||||||
|
.start()
|
||||||
|
.then(() => {
|
||||||
|
if (!cleanedUp) setConnection(hubConnection)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cleanedUp) console.error('Failed to connect to chat hub', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanedUp = true
|
||||||
|
void hubConnection.stop()
|
||||||
|
setConnection(null)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [auth.isAuthenticated, auth.user?.profile.sub])
|
||||||
|
|
||||||
|
return <ChatHubContext.Provider value={connection}>{children}</ChatHubContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatHub(): HubConnection | null {
|
||||||
|
return useContext(ChatHubContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useChatHub } from './ChatHubProvider'
|
||||||
|
|
||||||
|
export function useHubEvent<TArgs extends unknown[]>(eventName: string, handler: (...args: TArgs) => void) {
|
||||||
|
const connection = useChatHub()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.on(eventName, handler as (...args: unknown[]) => void)
|
||||||
|
return () => {
|
||||||
|
connection.off(eventName, handler as (...args: unknown[]) => void)
|
||||||
|
}
|
||||||
|
}, [connection, eventName, handler])
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #1e1f22;
|
||||||
|
color: #dbdee1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #4e5058;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #5865f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: #1e1f22;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #dbdee1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-screen {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #f23f42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-rail {
|
||||||
|
width: 72px;
|
||||||
|
background: #1e1f22;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-rail-divider {
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: #35363c;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #313338;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon.active,
|
||||||
|
.server-icon:hover {
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #5865f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: #2b2d31;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #1e1f22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item {
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: #96989d;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item.active,
|
||||||
|
.channel-item:hover {
|
||||||
|
background: #3f4147;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row .channel-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-call-toggle {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-footer {
|
||||||
|
padding: 10px;
|
||||||
|
background: #232428;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #5865f2;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #313338;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header {
|
||||||
|
padding: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #1e1f22;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #5865f2;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-dock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #111214;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 45vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-dock-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #96989d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-dock-room {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-dock-connecting {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
color: #96989d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.compact {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #5865f2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp-gutter {
|
||||||
|
width: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #72767d;
|
||||||
|
opacity: 0;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.compact:hover .message-timestamp-gutter {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp,
|
||||||
|
.message-edited {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #80838a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin: 2px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-attachments {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #96989d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions button {
|
||||||
|
background: transparent;
|
||||||
|
color: #80838a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions button:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #3f4147;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-edit-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-edit-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-attachments {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-attachments li {
|
||||||
|
background: #2b2d31;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
background: #383a40;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-panel {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #2b2d31;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list li span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-sm {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #5865f2;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #80838a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: #313338;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card input[type='text'],
|
||||||
|
.modal-card input:not([type]) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-radio-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-radio-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: #f23f42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button:hover {
|
||||||
|
background: #d53437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-button {
|
||||||
|
background: #23a55a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-button:hover {
|
||||||
|
background: #1e8f4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-call-caller {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-call-caller p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-call-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #5865f2;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-ringing-badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #f0b132;
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-host {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 200;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: #2b2d31;
|
||||||
|
border-left: 4px solid #5865f2;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
border-left-color: #f23f42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list li .presence,
|
||||||
|
.dm-header .presence {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #80848e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot-online {
|
||||||
|
background: #23a55a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot-busy {
|
||||||
|
background: #f0b132;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot-offline {
|
||||||
|
background: #80848e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #96989d;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from 'react-oidc-context'
|
||||||
|
import '@livekit/components-styles'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { oidcConfig } from './auth/oidcConfig'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider {...oidcConfig}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
|
export function AuthCallback() {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
if (auth.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="centered-screen">
|
||||||
|
<p>Signing in…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.error) {
|
||||||
|
return (
|
||||||
|
<div className="centered-screen">
|
||||||
|
<p className="error-text">Sign-in failed: {auth.error.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="centered-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>Ping</h1>
|
||||||
|
<p>Chat, voice and video, self-hosted.</p>
|
||||||
|
<button onClick={() => void auth.signinRedirect()}>Sign in with Zitadel</button>
|
||||||
|
{auth.error && <p className="error-text">{auth.error.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface IncomingCall {
|
||||||
|
channelId: string
|
||||||
|
callerId: string
|
||||||
|
callerName: string
|
||||||
|
callerAvatarUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutgoingCall {
|
||||||
|
channelId: string
|
||||||
|
calleeId: string
|
||||||
|
calleeName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallInviteState {
|
||||||
|
incomingCall: IncomingCall | null
|
||||||
|
outgoingCall: OutgoingCall | null
|
||||||
|
setIncomingCall: (call: IncomingCall | null) => void
|
||||||
|
setOutgoingCall: (call: OutgoingCall | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCallInviteStore = create<CallInviteState>((set) => ({
|
||||||
|
incomingCall: null,
|
||||||
|
outgoingCall: null,
|
||||||
|
setIncomingCall: (call) => set({ incomingCall: call }),
|
||||||
|
setOutgoingCall: (call) => set({ outgoingCall: call }),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface CallState {
|
||||||
|
activeChannelId: string | null
|
||||||
|
activeChannelLabel: string | null
|
||||||
|
join: (channelId: string, label: string) => void
|
||||||
|
leave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCallStore = create<CallState>((set) => ({
|
||||||
|
activeChannelId: null,
|
||||||
|
activeChannelLabel: null,
|
||||||
|
join: (channelId, label) => set({ activeChannelId: channelId, activeChannelLabel: label }),
|
||||||
|
leave: () => set({ activeChannelId: null, activeChannelLabel: null }),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { PresenceStatus } from '../types/api'
|
||||||
|
|
||||||
|
interface PresenceState {
|
||||||
|
statuses: Record<string, PresenceStatus>
|
||||||
|
setStatus: (userId: string, status: PresenceStatus) => void
|
||||||
|
seed: (entries: Array<[string, PresenceStatus]>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePresenceStore = create<PresenceState>((set) => ({
|
||||||
|
statuses: {},
|
||||||
|
setStatus: (userId, status) =>
|
||||||
|
set((s) => ({ statuses: { ...s.statuses, [userId]: status } })),
|
||||||
|
seed: (entries) =>
|
||||||
|
set((s) => ({ statuses: { ...s.statuses, ...Object.fromEntries(entries) } })),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type ToastTone = 'error' | 'info'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
tone: ToastTone
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
toasts: Toast[]
|
||||||
|
push: (message: string, tone?: ToastTone) => void
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
push: (message, tone = 'info') => {
|
||||||
|
const id = nextId++
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id, message, tone }] }))
|
||||||
|
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 5000)
|
||||||
|
},
|
||||||
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { UserResponse } from '../types/api'
|
||||||
|
|
||||||
|
export type View =
|
||||||
|
| { kind: 'friends' }
|
||||||
|
| { kind: 'channel'; serverId: string; channelId: string }
|
||||||
|
| { kind: 'dm'; channelId: string; otherUser: UserResponse }
|
||||||
|
|
||||||
|
interface UiState {
|
||||||
|
view: View
|
||||||
|
setView: (view: View) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUiStore = create<UiState>((set) => ({
|
||||||
|
view: { kind: 'friends' },
|
||||||
|
setView: (view) => set({ view }),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface UserResponse {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string | null
|
||||||
|
avatarUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FriendshipStatus = 'Pending' | 'Accepted' | 'Blocked'
|
||||||
|
|
||||||
|
export type PresenceStatus = 'Offline' | 'Online' | 'Busy'
|
||||||
|
|
||||||
|
export interface FriendshipResponse {
|
||||||
|
id: string
|
||||||
|
otherUser: UserResponse
|
||||||
|
status: FriendshipStatus
|
||||||
|
isIncoming: boolean
|
||||||
|
createdAt: string
|
||||||
|
presence: PresenceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
iconUrl: string | null
|
||||||
|
ownerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelType = 'Text' | 'Voice' | 'DirectMessage'
|
||||||
|
|
||||||
|
export interface ChannelResponse {
|
||||||
|
id: string
|
||||||
|
serverId: string | null
|
||||||
|
name: string | null
|
||||||
|
type: ChannelType
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentKind = 'File' | 'Image' | 'Audio'
|
||||||
|
|
||||||
|
export interface AttachmentResponse {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
kind: AttachmentKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageResponse {
|
||||||
|
id: string
|
||||||
|
channelId: string
|
||||||
|
author: UserResponse
|
||||||
|
content: string
|
||||||
|
replyToMessageId: string | null
|
||||||
|
createdAt: string
|
||||||
|
editedAt: string | null
|
||||||
|
attachments: AttachmentResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentUploadResponse {
|
||||||
|
uploadUrl: string
|
||||||
|
bucket: string
|
||||||
|
key: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallTokenResponse {
|
||||||
|
token: string
|
||||||
|
roomName: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function formatMessageTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(now.getDate() - 1)
|
||||||
|
const time = formatShortTime(iso)
|
||||||
|
|
||||||
|
if (date.toDateString() === now.toDateString()) return `Today at ${time}`
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return `Yesterday at ${time}`
|
||||||
|
return `${date.toLocaleDateString()} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_AUTH_AUTHORITY: string
|
||||||
|
readonly VITE_AUTH_CLIENT_ID: string
|
||||||
|
readonly VITE_AUTH_REDIRECT_URI: string
|
||||||
|
readonly VITE_LIVEKIT_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3100,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user