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,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
|
||||
}
|
||||
Reference in New Issue
Block a user