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:
2026-09-21 19:33:02 +02:00
parent 0623875dfa
commit 95a2d46408
48 changed files with 4356 additions and 0 deletions
+7
View File
@@ -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
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+28
View File
@@ -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
+32
View File
@@ -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.
+13
View File
@@ -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>
+10
View File
@@ -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;
}
}
+1879
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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

+24
View File
@@ -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

+127
View File
@@ -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>
)
}
+114
View File
@@ -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>
+9
View File
@@ -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

+14
View File
@@ -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\/?$/, '/'))
},
}
+22
View File
@@ -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>
)
}
+123
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+100
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+202
View File
@@ -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>
)
}
+132
View File
@@ -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>
)
}
+97
View File
@@ -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>
)
}
+12
View File
@@ -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>
)
}
+19
View File
@@ -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
}
+79
View File
@@ -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>
)
}
+18
View File
@@ -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>
)
}
+50
View File
@@ -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)
}
+17
View File
@@ -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])
}
+603
View File
@@ -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;
}
+18
View File
@@ -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>,
)
+24
View File
@@ -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 />
}
+16
View File
@@ -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>
)
}
+28
View File
@@ -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 }),
}))
+15
View File
@@ -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 }),
}))
+16
View File
@@ -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) } })),
}))
+27
View File
@@ -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) })),
}))
+17
View File
@@ -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 }),
}))
+69
View File
@@ -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
}
+15
View File
@@ -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' })
}
+13
View File
@@ -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
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
port: 3100,
},
})