feat: launch readiness — PWA, API hardening, memory leak fixes, sticker removal

- Add PWA infrastructure: vite-plugin-pwa, manifest, service worker,
  SW update prompt component, placeholder icons, Apple meta tags
- Harden API client: 401 auto-logout, AbortController timeouts
  (30s standard, 120s uploads), onUnauthorized callback
- Fix memory leaks: clear voice user status on leave, clean up all
  Maps (channelToSpaceMap, permissions, etc.) on removeSpace
- Upgrade error boundary to Aether Drift design with Try Again button,
  collapsible stack trace, and componentDidCatch logging
- Configure desktop icon paths in electron-builder.yml
- Remove sticker feature (server routes, schema, types, UI components)
- Fix Docker build: use **/node_modules in .dockerignore to prevent
  COPY from clobbering pnpm-installed workspace dependencies
- Add vite-env.d.ts declarations for noise suppressor wasm imports
- Exclude test files from tsc build via tsconfig
This commit is contained in:
Jannis Braun
2026-03-15 15:41:22 +01:00
parent b08f40feb7
commit 4d230711fc
36 changed files with 2740 additions and 1283 deletions
+5
View File
@@ -4,6 +4,7 @@ import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout';
import { JoinPage } from './components/JoinPage';
import { SwUpdatePrompt } from './components/ui/SwUpdatePrompt';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -27,6 +28,8 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
export function App() {
return (
<>
<SwUpdatePrompt />
<Routes>
<Route
path="/login"
@@ -67,5 +70,7 @@ export function App() {
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
</>
);
}
+62 -62
View File
@@ -47,8 +47,6 @@ import type {
SpaceFolder,
InvitePreview,
GifResult,
StickerPack,
Sticker,
} from '@backspace/shared';
export class RateLimitError extends Error {
@@ -196,16 +194,6 @@ export class BackspaceApiClient {
enabled: () => Promise<{ enabled: boolean }>;
};
readonly stickers: {
getPacks: (spaceId: string) => Promise<{ packs: StickerPack[] }>;
createPack: (spaceId: string, data: { name: string; description?: string }) => Promise<StickerPack>;
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => Promise<StickerPack>;
deletePack: (spaceId: string, packId: string) => Promise<{ success: boolean }>;
uploadSticker: (spaceId: string, packId: string, file: File, name: string, tags?: string) => Promise<Sticker>;
deleteSticker: (stickerId: string) => Promise<{ success: boolean }>;
myStickers: () => Promise<{ packs: StickerPack[] }>;
};
readonly admin: {
storageStats: () => Promise<StorageStats>;
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
@@ -216,7 +204,7 @@ export class BackspaceApiClient {
deleteUser: (userId: string) => Promise<{ success: boolean }>;
};
constructor(baseUrl: string, getToken: () => string | null) {
constructor(baseUrl: string, getToken: () => string | null, onUnauthorized?: () => void) {
async function request<T>(
method: string,
path: string,
@@ -236,13 +224,30 @@ export class BackspaceApiClient {
}
}
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Request timed out');
}
throw err;
}
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401 && requireAuth && onUnauthorized) {
onUnauthorized();
}
if (response.status === 429) {
const body = await response.json().catch(() => ({}));
const retryAfter = (body as { retryAfter?: number }).retryAfter
@@ -266,13 +271,30 @@ export class BackspaceApiClient {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${baseUrl}/uploads`, {
method: 'POST',
headers,
body: formData,
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000);
let response: Response;
try {
response = await fetch(`${baseUrl}/uploads`, {
method: 'POST',
headers,
body: formData,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Request timed out');
}
throw err;
}
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401 && onUnauthorized) {
onUnauthorized();
}
if (response.status === 429) {
const body = await response.json().catch(() => ({}));
const retryAfter = (body as { retryAfter?: number }).retryAfter
@@ -540,42 +562,6 @@ export class BackspaceApiClient {
enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'),
};
this.stickers = {
getPacks: (spaceId: string) =>
request<{ packs: StickerPack[] }>('GET', `/spaces/${spaceId}/sticker-packs`),
createPack: (spaceId: string, data: { name: string; description?: string }) =>
request<StickerPack>('POST', `/spaces/${spaceId}/sticker-packs`, data),
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) =>
request<StickerPack>('PATCH', `/spaces/${spaceId}/sticker-packs/${packId}`, data),
deletePack: (spaceId: string, packId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/sticker-packs/${packId}`),
uploadSticker: async (spaceId: string, packId: string, file: File, name: string, tags = '') => {
const formData = new FormData();
formData.append('file', file);
formData.append('name', name);
formData.append('tags', tags);
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${baseUrl}/spaces/${spaceId}/sticker-packs/${packId}/stickers`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
}
return response.json() as Promise<Sticker>;
},
deleteSticker: (stickerId: string) =>
request<{ success: boolean }>('DELETE', `/stickers/${stickerId}`),
myStickers: () =>
request<{ packs: StickerPack[] }>('GET', '/users/@me/stickers'),
};
this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
@@ -598,9 +584,23 @@ export class BackspaceApiClient {
}
}
export const api = new BackspaceApiClient('/api', () => localStorage.getItem('backspace_token'));
function handleUnauthorized(): void {
localStorage.removeItem('backspace_token');
if (
!window.location.pathname.startsWith('/login') &&
!window.location.pathname.startsWith('/register')
) {
window.location.href = '/login';
}
}
export function createApiClient(origin: string, getToken: () => string | null): BackspaceApiClient {
export const api = new BackspaceApiClient(
'/api',
() => localStorage.getItem('backspace_token'),
handleUnauthorized,
);
export function createApiClient(origin: string, getToken: () => string | null, onUnauthorized?: () => void): BackspaceApiClient {
const baseUrl = origin ? `${origin}/api` : '/api';
return new BackspaceApiClient(baseUrl, getToken);
return new BackspaceApiClient(baseUrl, getToken, onUnauthorized);
}
@@ -28,7 +28,7 @@ export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
skinTonePosition="search"
previewPosition="none"
navPosition="bottom"
perLine={9}
perLine={10}
maxFrequentRows={2}
emojiSize={24}
emojiButtonSize={32}
@@ -6,7 +6,7 @@ import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import { InputPopover, type InputPopoverTab } from './InputPopover';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser, type Sticker } from '@backspace/shared';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore';
interface MessageInputProps {
@@ -31,7 +31,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const inputContainerRef = useRef<HTMLDivElement>(null);
const popoverAnchorRef = useRef<HTMLDivElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const sendStickerMessage = useChatStore((s) => s.sendStickerMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useSpaceStore((s) => s.members);
@@ -39,8 +38,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
// Feature flags
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
const spaces = useSpaceStore((s) => s.spaces);
const stickersEnabled = spaces.length > 0; // stickers available if user is in any space
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
@@ -284,11 +281,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
sendMessage(channelId, url);
}, [channelId, sendMessage]);
const handleStickerSelect = useCallback((sticker: Sticker) => {
setActivePopover(null);
sendStickerMessage(channelId, sticker.id);
}, [channelId, sendStickerMessage]);
const togglePopover = useCallback((tab: InputPopoverTab) => {
setActivePopover((prev) => prev === tab ? null : tab);
}, []);
@@ -309,17 +301,15 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<div ref={popoverAnchorRef} data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<TypingIndicator channelId={channelId} />
{/* Input popover (emoji / gif / stickers) */}
{/* Input popover (emoji / gif) */}
{activePopover && (
<InputPopover
activeTab={activePopover}
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
stickersEnabled={stickersEnabled}
onTabChange={setActivePopover}
/>
)}
@@ -460,19 +450,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</button>
)}
{/* Sticker button */}
<button
onClick={() => togglePopover('stickers')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'stickers' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Stickers"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
</svg>
</button>
{/* Emoji button */}
<button
onClick={() => togglePopover('emoji')}
@@ -1,140 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '../../api/client';
import type { Sticker, StickerPack } from '@backspace/shared';
interface StickerPickerProps {
onStickerSelect: (sticker: Sticker) => void;
}
interface StickerCache {
packs: StickerPack[];
fetchedAt: number;
}
let stickerCache: StickerCache | null = null;
const CACHE_TTL = 60_000; // 60s
export function StickerPicker({ onStickerSelect }: StickerPickerProps) {
const [packs, setPacks] = useState<StickerPack[]>(stickerCache?.packs ?? []);
const [loading, setLoading] = useState(!stickerCache || Date.now() - stickerCache.fetchedAt > CACHE_TTL);
const [query, setQuery] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (stickerCache && Date.now() - stickerCache.fetchedAt <= CACHE_TTL) {
setPacks(stickerCache.packs);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api.stickers.myStickers()
.then((data) => {
if (cancelled) return;
stickerCache = { packs: data.packs, fetchedAt: Date.now() };
setPacks(data.packs);
setLoading(false);
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const filteredPacks = query.trim()
? packs
.map((pack) => ({
...pack,
stickers: pack.stickers.filter(
(s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
s.tags.toLowerCase().includes(query.toLowerCase()),
),
}))
.filter((pack) => pack.stickers.length > 0)
: packs;
const totalStickers = packs.reduce((sum, p) => sum + p.stickers.length, 0);
// Prevent keyboard events from bubbling
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
const getStickerUrl = useCallback((sticker: Sticker) => {
const filename = sticker.filename;
if (filename.startsWith('http') || filename.startsWith('/')) return filename;
return `/api/uploads/${filename}`;
}, []);
return (
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stickers"
className="input-search w-full"
autoFocus
/>
</div>
{/* Results */}
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{loading ? (
<div className="grid grid-cols-4 gap-2 p-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-square bg-surface-elevated rounded-lg animate-pulse" />
))}
</div>
) : totalStickers === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="text-txt-tertiary text-sm mb-1">No stickers available</div>
<div className="text-txt-tertiary text-xs">
Space admins can add sticker packs in Space Settings.
</div>
</div>
) : filteredPacks.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
No stickers matching "{query}"
</div>
) : (
<div className="space-y-3">
{filteredPacks.map((pack) => (
<div key={pack.id}>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider px-1 mb-1.5">
{pack.name}
</div>
<div className="grid grid-cols-4 gap-1.5">
{pack.stickers.map((sticker) => (
<button
key={sticker.id}
onClick={() => onStickerSelect(sticker)}
className="aspect-square rounded-lg overflow-hidden hover:bg-interactive-hover transition-colors p-1.5 group"
title={sticker.name}
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain group-hover:scale-110 transition-transform"
loading="lazy"
/>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
/** Invalidate the sticker cache (called when WS events indicate sticker changes) */
export function invalidateStickerCache(): void {
stickerCache = null;
}
@@ -10,7 +10,6 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
import { StickersPanel } from './spaceSettingsPanels/StickersPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
@@ -271,7 +270,7 @@ export function SpaceSettingsModal() {
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
@@ -301,11 +300,6 @@ export function SpaceSettingsModal() {
Discovery
</button>
)}
{canManageSpace && (
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
Stickers
</button>
)}
<button onClick={() => setTab('members')} className={tabClass('members')}>
Members
</button>
@@ -326,7 +320,6 @@ export function SpaceSettingsModal() {
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
@@ -1,301 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../../../api/client';
import { ConfirmDialog } from '../../ui/ConfirmDialog';
import type { StickerPack, Sticker } from '@backspace/shared';
interface StickersPanelProps {
spaceId: string;
}
export function StickersPanel({ spaceId }: StickersPanelProps) {
const [packs, setPacks] = useState<StickerPack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Create pack state
const [newPackName, setNewPackName] = useState('');
const [newPackDesc, setNewPackDesc] = useState('');
const [creating, setCreating] = useState(false);
// Upload sticker state
const [uploadPackId, setUploadPackId] = useState<string | null>(null);
const [stickerName, setStickerName] = useState('');
const [stickerTags, setStickerTags] = useState('');
const [stickerFile, setStickerFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<{ type: 'pack' | 'sticker'; id: string; name: string } | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchPacks = async () => {
try {
const { packs: data } = await api.stickers.getPacks(spaceId);
setPacks(data);
setLoading(false);
} catch {
setError('Failed to load sticker packs');
setLoading(false);
}
};
useEffect(() => {
fetchPacks();
}, [spaceId]);
const handleCreatePack = async () => {
if (!newPackName.trim()) return;
setCreating(true);
setError('');
try {
const pack = await api.stickers.createPack(spaceId, {
name: newPackName.trim(),
description: newPackDesc.trim() || undefined,
});
setPacks((prev) => [...prev, { ...pack, stickers: [] }]);
setNewPackName('');
setNewPackDesc('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create pack');
} finally {
setCreating(false);
}
};
const handleUploadSticker = async () => {
if (!uploadPackId || !stickerFile || !stickerName.trim()) return;
setUploading(true);
setError('');
try {
const sticker = await api.stickers.uploadSticker(
spaceId,
uploadPackId,
stickerFile,
stickerName.trim(),
stickerTags.trim(),
);
setPacks((prev) =>
prev.map((p) =>
p.id === uploadPackId
? { ...p, stickers: [...p.stickers, sticker] }
: p,
),
);
setStickerName('');
setStickerTags('');
setStickerFile(null);
setUploadPackId(null);
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload sticker');
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setError('');
try {
if (deleteTarget.type === 'pack') {
await api.stickers.deletePack(spaceId, deleteTarget.id);
setPacks((prev) => prev.filter((p) => p.id !== deleteTarget.id));
} else {
await api.stickers.deleteSticker(deleteTarget.id);
setPacks((prev) =>
prev.map((p) => ({
...p,
stickers: p.stickers.filter((s) => s.id !== deleteTarget.id),
})),
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setDeleting(false);
setDeleteTarget(null);
}
};
const getStickerUrl = (sticker: Sticker) => {
if (sticker.filename.startsWith('http') || sticker.filename.startsWith('/'))
return sticker.filename;
return `/api/uploads/${sticker.filename}`;
};
if (loading) {
return <div className="text-sm text-txt-tertiary">Loading sticker packs...</div>;
}
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Manage sticker packs for this space. Members can use these stickers in messages.
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Create Pack */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Create Sticker Pack
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="text"
value={newPackName}
onChange={(e) => setNewPackName(e.target.value.slice(0, 32))}
placeholder="Pack name"
className="input-standard w-full"
/>
<input
type="text"
value={newPackDesc}
onChange={(e) => setNewPackDesc(e.target.value.slice(0, 100))}
placeholder="Description (optional)"
className="input-standard w-full"
/>
<button
onClick={handleCreatePack}
disabled={creating || !newPackName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Pack'}
</button>
</div>
</div>
{/* Existing Packs */}
{packs.length === 0 ? (
<div className="text-sm text-txt-tertiary">No sticker packs yet.</div>
) : (
<div className="space-y-4">
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg bg-white/[0.02] p-3.5">
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-txt-primary">{pack.name}</div>
{pack.description && (
<div className="text-xs text-txt-tertiary">{pack.description}</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadPackId(uploadPackId === pack.id ? null : pack.id)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-interactive-hover hover:bg-interactive-active rounded transition-colors"
>
{uploadPackId === pack.id ? 'Cancel' : 'Add Sticker'}
</button>
<button
onClick={() => setDeleteTarget({ type: 'pack', id: pack.id, name: pack.name })}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Delete Pack
</button>
</div>
</div>
{/* Upload form for this pack */}
{uploadPackId === pack.id && (
<div className="border-t border-white/[0.06] pt-2 mt-2 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={stickerName}
onChange={(e) => setStickerName(e.target.value.slice(0, 32))}
placeholder="Sticker name"
className="input-standard flex-1"
/>
<input
type="text"
value={stickerTags}
onChange={(e) => setStickerTags(e.target.value.slice(0, 100))}
placeholder="Tags (optional)"
className="input-standard flex-1"
/>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/webp,image/gif"
onChange={(e) => setStickerFile(e.target.files?.[0] ?? null)}
className="text-sm text-txt-secondary file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-interactive-hover file:text-txt-primary hover:file:bg-interactive-active"
/>
<button
onClick={handleUploadSticker}
disabled={uploading || !stickerFile || !stickerName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50 flex-shrink-0"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
<div className="text-[10px] text-txt-tertiary">
PNG, WebP, or GIF. Max 512x512px, 500KB.
</div>
</div>
)}
{/* Sticker grid */}
{pack.stickers.length > 0 && (
<div className="grid grid-cols-5 gap-2 mt-2">
{pack.stickers.map((sticker) => (
<div
key={sticker.id}
className="relative group aspect-square rounded-lg bg-surface-base overflow-hidden"
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain p-1"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => setDeleteTarget({ type: 'sticker', id: sticker.id, name: sticker.name })}
className="p-1 text-white hover:text-txt-danger transition-colors"
title="Delete sticker"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 text-[9px] text-white truncate opacity-0 group-hover:opacity-100 transition-opacity">
{sticker.name}
</div>
</div>
))}
</div>
)}
{pack.stickers.length === 0 && (
<div className="text-xs text-txt-tertiary mt-1">No stickers in this pack yet.</div>
)}
</div>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={`Delete ${deleteTarget?.type === 'pack' ? 'Sticker Pack' : 'Sticker'}`}
description={`Are you sure you want to delete "${deleteTarget?.name}"?${
deleteTarget?.type === 'pack' ? ' All stickers in this pack will be deleted.' : ''
} Existing messages will show "Sticker unavailable".`}
confirmLabel={deleting ? 'Deleting...' : 'Delete'}
onConfirm={handleDelete}
variant="danger"
loading={deleting}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { useRegisterSW } from 'virtual:pwa-register/react';
export function SwUpdatePrompt() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW();
if (!needRefresh) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] glass-pill px-4 py-2.5 flex items-center gap-3 text-sm text-txt-primary shadow-lg">
<span>A new version is available</span>
<button
onClick={() => updateServiceWorker(true)}
className="px-3 py-1 rounded-md bg-accent-primary text-white text-xs font-medium hover:opacity-90 transition-opacity"
>
Reload
</button>
</div>
);
}
+1 -14
View File
@@ -333,6 +333,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
addVoiceUser(event.channelId, event.userId);
} else {
removeVoiceUser(event.channelId, event.userId);
clearVoiceUserStatus(event.userId);
}
break;
@@ -755,20 +756,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
// ─── Sticker events (all origins) ────────────────────────────────────
case 'sticker_pack_created':
case 'sticker_pack_updated':
case 'sticker_pack_deleted':
case 'sticker_created':
case 'sticker_deleted': {
// Invalidate the sticker picker cache so next open fetches fresh data
import('../components/chat/StickerPicker').then(({ invalidateStickerCache }) => {
invalidateStickerCache();
});
break;
}
case 'pong':
break;
+68 -20
View File
@@ -6,17 +6,21 @@ import './styles/globals.css';
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
{ hasError: boolean; error: Error | null; showStack: boolean }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
this.state = { hasError: false, error: null, showStack: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (
@@ -25,28 +29,72 @@ class ErrorBoundary extends React.Component<
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#232428',
color: '#ffffff',
fontFamily: 'sans-serif',
backgroundColor: '#0b0b10',
color: '#efefef',
fontFamily: "'DM Sans', sans-serif",
flexDirection: 'column',
gap: '16px',
padding: '24px',
}}>
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Something went wrong</h1>
<p style={{ color: '#abacb2' }}>{this.state.error?.message}</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '8px 24px',
backgroundColor: '#5865f2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Reload
</button>
<p style={{ color: '#a0a0aa', maxWidth: '480px', textAlign: 'center' }}>{this.state.error?.message}</p>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
padding: '8px 24px',
backgroundColor: '#7c6cf6',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontFamily: "'DM Sans', sans-serif",
}}
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
style={{
padding: '8px 24px',
backgroundColor: 'transparent',
color: '#a0a0aa',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontFamily: "'DM Sans', sans-serif",
}}
>
Reload Page
</button>
</div>
{this.state.error?.stack && (
<details
open={this.state.showStack}
onToggle={(e) => this.setState({ showStack: (e.target as HTMLDetailsElement).open })}
style={{ maxWidth: '600px', width: '100%', marginTop: '8px' }}
>
<summary style={{ color: '#a0a0aa', cursor: 'pointer', fontSize: '13px' }}>
Error details
</summary>
<pre style={{
marginTop: '8px',
padding: '12px',
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: '8px',
fontSize: '11px',
color: '#a0a0aa',
overflow: 'auto',
maxHeight: '200px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{this.state.error.stack}
</pre>
</details>
)}
</div>
);
}
-40
View File
@@ -38,7 +38,6 @@ interface ChatState {
clearAllMessages: () => void;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
sendStickerMessage: (channelId: string, stickerId: string) => Promise<void>;
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
addMessage: (channelId: string, message: MessageWithUser) => void;
@@ -290,45 +289,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
sendStickerMessage: async (channelId: string, stickerId: string) => {
const isDm = isDmChannel(channelId);
const currentUser = useAuthStore.getState().user;
const origin = getChannelOrigin(channelId);
const client = getApiForOrigin(origin);
// Optimistic message
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
if (currentUser) {
const optimisticMessage: MessageWithUser = {
id: tempId,
channelId: isDm ? '' : channelId,
userId: currentUser.id,
content: null,
replyToId: null,
editedAt: null,
createdAt: Date.now(),
user: currentUser,
attachments: [],
reactions: [],
stickerId,
};
if (isDm) {
(optimisticMessage as any).dmChannelId = channelId;
}
get().addMessage(channelId, optimisticMessage);
}
try {
if (isDm) {
await client.dm.sendMessage(channelId, { stickerId });
} else {
await client.channels.sendMessage(channelId, { content: '', stickerId });
}
} catch {
get().removeMessage(tempId, channelId);
}
},
editMessage: async (messageId: string, content: string, channelId: string) => {
const isDm = isDmChannel(channelId);
const origin = getChannelOrigin(channelId);
+31 -4
View File
@@ -408,10 +408,37 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
},
removeSpace: (spaceId: string) => {
set((state) => ({
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
}));
set((state) => {
// Collect channel IDs belonging to this space for map cleanup
const channelIdsToRemove = new Set<string>();
for (const [channelId, sid] of state.channelToSpaceMap) {
if (sid === spaceId) channelIdsToRemove.add(channelId);
}
const channelToSpaceMap = new Map(state.channelToSpaceMap);
const channelPermissions = new Map(state.channelPermissions);
const channelOriginMap = new Map(state.channelOriginMap);
const channelLastMessageIds = new Map(state.channelLastMessageIds);
const spacePermissions = new Map(state.spacePermissions);
for (const channelId of channelIdsToRemove) {
channelToSpaceMap.delete(channelId);
channelPermissions.delete(channelId);
channelOriginMap.delete(channelId);
channelLastMessageIds.delete(channelId);
}
spacePermissions.delete(spaceId);
return {
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
channelToSpaceMap,
channelPermissions,
channelOriginMap,
channelLastMessageIds,
spacePermissions,
};
});
},
updateMemberPresence: (userId: string, status: string) => {
+13
View File
@@ -1 +1,14 @@
/// <reference types="vite/client" />
declare module '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url' {
const url: string;
export default url;
}
declare module '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url' {
const url: string;
export default url;
}
declare module '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url' {
const url: string;
export default url;
}