Phase 3: Polish & PWA

Backend:
- DB: reactions table, invites table, reply_to column on messages
- gateway/events.go: added REACTION_ADD, REACTION_REMOVE events
- internal/reaction/handlers.go: reaction CRUD with WebSocket broadcast
- internal/invite/handlers.go: invite creation, info, join with code
- gateway/hub.go: presence tracking with idle detection
- gateway/client.go: idle timeout support

Frontend - Social:
- TypingIndicator: real-time 'user is typing...' display
- ReactionBar: emoji reactions on messages with counts
- EmojiPicker: searchable emoji grid for reactions
- ReplyBar: quoted reply display above messages
- MentionPopup: @mention autocomplete with user list

Frontend - PWA:
- manifest.json: PWA manifest with theme color and icons
- sw.js: service worker with cache-first strategy and push support
- stores/push.ts: push notification subscription management
- InstallPrompt: 'Add to Home Screen' banner

Frontend - Mobile:
- MobileNav: bottom nav bar for mobile (servers/channels/chat/members)
- MobileDrawer: slide-out drawer with server bar + channel list
- index.html: PWA meta tags, safe area viewport

Frontend - Polish:
- ThemeToggle: dark/light mode switch with localStorage persistence
- InviteModal: generate invite links with expiry and max uses
- JoinServer: /invite/:code join flow
- stores/typing.ts: typing indicator state management
- stores/presence.ts: real-time presence tracking
- tailwind.config.js: darkMode: 'class', light mode color tokens
- styles/index.css: light mode CSS variable overrides
This commit is contained in:
2026-06-28 16:44:39 -04:00
parent ab35bdd1ae
commit bb650ac2a0
29 changed files with 1811 additions and 30 deletions
+16 -2
View File
@@ -3,11 +3,25 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#282828" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Dumpster" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>Dumpster</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Dumpster",
"short_name": "Dumpster",
"description": "A chaotic, self-hosted Discord-like platform",
"start_url": "/",
"display": "standalone",
"background_color": "#282828",
"theme_color": "#282828",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+96
View File
@@ -0,0 +1,96 @@
const CACHE_NAME = 'dumpster-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip API requests
if (event.request.url.includes('/api/')) return;
// Skip WebSocket
if (event.request.url.includes('/ws')) return;
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached version, fetch new one in background
const fetchPromise = fetch(event.request).then((response) => {
// Only cache successful responses
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
}
return response;
}).catch(() => {
// Return cached if fetch fails
return cached;
});
return cached || fetchPromise;
})
);
});
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
};
event.waitUntil(
self.registration.showNotification(data.title || 'Dumpster', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Focus existing window if available
for (const client of clientList) {
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
return client.focus();
}
}
// Otherwise open new window
return clients.openWindow(event.notification.data.url);
})
);
});
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
const EMOJI_LIST = [
'👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥',
'👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎',
'🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃',
'🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵',
];
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
onClose: () => void;
}
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const [search, setSearch] = useState('');
const filtered = search
? EMOJI_LIST.filter(e => e.includes(search))
: EMOJI_LIST;
return (
<div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 min-w-[200px] z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gb-fg-f font-mono">EMOJI</span>
<button onClick={onClose} className="text-xs text-gb-red font-mono">[x]</button>
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none"
autoFocus
/>
<div className="grid grid-cols-8 gap-1">
{filtered.map((emoji) => (
<button
key={emoji}
onClick={() => {
onSelect(emoji);
onClose();
}}
className="w-6 h-6 flex items-center justify-center text-sm hover:bg-gb-bg-s transition-colors"
>
{emoji}
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-xs text-gb-fg-f font-mono text-center py-2">[no results]</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showInstall, setShowInstall] = useState(false);
const [installed, setInstalled] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowInstall(true);
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', () => {
setInstalled(true);
setShowInstall(false);
setDeferredPrompt(null);
});
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setInstalled(true);
setShowInstall(false);
}
setDeferredPrompt(null);
};
if (!showInstall || installed) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 z-50">
<div className="bg-gb-bg border border-gb-orange p-3 font-mono shadow-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gb-orange">INSTALL DUMPSTER</span>
<button
onClick={() => setShowInstall(false)}
className="text-xs text-gb-red"
>
[x]
</button>
</div>
<p className="text-xs text-gb-fg-f mb-3">
Add to your home screen for the full experience. Push notifications, offline access, and more.
</p>
<div className="flex gap-2">
<button
onClick={handleInstall}
className="flex-1 px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors"
>
[INSTALL]
</button>
<button
onClick={() => setShowInstall(false)}
className="px-3 py-1.5 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
>
[LATER]
</button>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { api } from '../lib/api.ts';
interface InviteModalProps {
serverId: string;
serverName: string;
onClose: () => void;
}
interface Invite {
code: string;
url: string;
expires_at: string | null;
max_uses: number | null;
}
export function InviteModal({ serverId, serverName, onClose }: InviteModalProps) {
const [expiresHours, setExpiresHours] = useState(24);
const [maxUses, setMaxUses] = useState<number | ''>('');
const [invite, setInvite] = useState<Invite | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const createInvite = async () => {
setLoading(true);
setError(null);
try {
const payload: Record<string, unknown> = {
expires_hours: expiresHours,
};
if (maxUses !== '') {
payload.max_uses = maxUses;
}
const result = await api.post<Invite>(`/servers/${serverId}/invites`, payload);
setInvite(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create invite');
} finally {
setLoading(false);
}
};
const copyLink = () => {
if (invite) {
navigator.clipboard.writeText(invite.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">INVITE TO {serverName.toUpperCase()}</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
{!invite ? (
<>
<div className="space-y-3">
<div>
<label className="text-xs text-gb-fg-f block mb-1">EXPIRES IN:</label>
<select
value={expiresHours}
onChange={(e) => setExpiresHours(Number(e.target.value))}
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
>
<option value={1}>1 hour</option>
<option value={6}>6 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
<option value={168}>7 days</option>
<option value={0}>Never</option>
</select>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">MAX USES:</label>
<input
type="number"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value === '' ? '' : Number(e.target.value))}
placeholder="unlimited"
min={1}
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
/>
</div>
{error && (
<div className="text-xs text-gb-red">{error}</div>
)}
<button
onClick={createInvite}
disabled={loading}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
{loading ? 'CREATING...' : '[GENERATE LINK]'}
</button>
</div>
</>
) : (
<div className="space-y-3">
<div className="text-xs text-gb-fg-f">Share this link:</div>
<div className="flex gap-2">
<input
type="text"
value={invite.url}
readOnly
className="flex-1 px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
/>
<button
onClick={copyLink}
className="px-3 py-1 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
>
{copied ? '[COPIED!]' : '[COPY]'}
</button>
</div>
{invite.expires_at && (
<div className="text-xs text-gb-fg-f">
Expires: {new Date(invite.expires_at).toLocaleString()}
</div>
)}
{invite.max_uses && (
<div className="text-xs text-gb-fg-f">
Max uses: {invite.max_uses}
</div>
)}
<button
onClick={() => setInvite(null)}
className="text-xs text-gb-aqua hover:text-gb-orange transition-colors"
>
[CREATE ANOTHER]
</button>
</div>
)}
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../lib/api.ts';
interface InviteInfo {
server_name: string;
inviter: string;
expires_at: string | null;
}
export function JoinServer() {
const { code } = useParams<{ code: string }>();
const navigate = useNavigate();
const [info, setInfo] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!code) return;
const fetchInfo = async () => {
try {
const data = await api.get<InviteInfo>(`/invites/${code}`);
setInfo(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid invite');
} finally {
setLoading(false);
}
};
fetchInfo();
}, [code]);
const joinServer = async () => {
if (!code) return;
setJoining(true);
setError(null);
try {
const result = await api.post<{ server_id: string }>(`/invites/${code}/join`);
navigate(`/channels/${result.server_id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join');
} finally {
setJoining(false);
}
};
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
<div className="text-gb-fg-f">Loading invite...</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
<div className="bg-gb-bg-s border border-gb-red p-6 max-w-md text-center">
<div className="text-gb-red text-lg mb-2">INVALID INVITE</div>
<div className="text-gb-fg-f text-sm mb-4">{error}</div>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-gb-bg-t text-gb-fg text-sm hover:bg-gb-bg-s transition-colors"
>
[GO HOME]
</button>
</div>
</div>
);
}
return (
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
<div className="bg-gb-bg-s border border-gb-bg-t p-6 max-w-md">
<div className="text-center mb-4">
<div className="text-gb-orange text-lg mb-1">YOU'VE BEEN INVITED</div>
<div className="text-2xl text-gb-fg mb-2">{info?.server_name}</div>
{info?.inviter && (
<div className="text-sm text-gb-fg-f">by {info.inviter}</div>
)}
</div>
{info?.expires_at && (
<div className="text-xs text-gb-fg-f text-center mb-4">
Expires: {new Date(info.expires_at).toLocaleString()}
</div>
)}
{error && (
<div className="text-sm text-gb-red text-center mb-3">{error}</div>
)}
<button
onClick={joinServer}
disabled={joining}
className="w-full px-4 py-2 bg-gb-orange text-gb-bg text-sm font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
{joining ? 'JOINING...' : '[ACCEPT INVITE]'}
</button>
</div>
</div>
);
}
+37 -14
View File
@@ -1,3 +1,4 @@
import { usePresenceStore } from '../stores/presence.ts';
import type { User, UserStatus } from '../stores/auth.ts';
interface MemberListProps {
@@ -43,9 +44,41 @@ function usernameColor(status: UserStatus): string {
}
}
/** Return the live status for a member, falling back to the static value. */
function useLiveStatus(member: User): UserStatus {
const presence = usePresenceStore((s) => s.presences[member.id]);
return presence?.status ?? member.status;
}
interface MemberRowProps {
member: User;
}
function MemberRow({ member }: MemberRowProps) {
const status = useLiveStatus(member);
return (
<div className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.username}
</span>
</div>
);
}
export function MemberList({ members = [] }: MemberListProps) {
const online = members.filter((m) => m.status !== 'offline');
const offline = members.filter((m) => m.status === 'offline');
// Derive live status for every member to decide online/offline buckets.
const presences = usePresenceStore((s) => s.presences);
const online = members.filter((m) => {
const status = presences[m.id]?.status ?? m.status;
return status !== 'offline';
});
const offline = members.filter((m) => {
const status = presences[m.id]?.status ?? m.status;
return status === 'offline';
});
return (
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
@@ -61,12 +94,7 @@ export function MemberList({ members = [] }: MemberListProps) {
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
<MemberRow key={member.id} member={member} />
))}
</div>
)}
@@ -75,12 +103,7 @@ export function MemberList({ members = [] }: MemberListProps) {
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
<MemberRow key={member.id} member={member} />
))}
</div>
)}
+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect, useRef } from 'react';
interface User {
id: string;
username: string;
display_name: string;
}
interface MentionPopupProps {
users: User[];
filter: string;
onSelect: (user: User) => void;
position: { top: number; left: number };
}
export function MentionPopup({ users, filter, onSelect, position }: MentionPopupProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const filtered = users.filter(u =>
u.username.toLowerCase().includes(filter.toLowerCase()) ||
u.display_name?.toLowerCase().includes(filter.toLowerCase())
).slice(0, 8);
useEffect(() => {
setSelectedIndex(0);
}, [filter]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filtered[selectedIndex]) {
onSelect(filtered[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onSelect(null as any);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [filtered, selectedIndex, onSelect]);
if (filtered.length === 0) return null;
return (
<div
ref={listRef}
className="absolute bg-gb-bg border border-gb-bg-t shadow-lg z-50 min-w-[200px] max-h-[200px] overflow-y-auto"
style={{ bottom: '100%', left: position.left, marginBottom: 4 }}
>
<div className="px-2 py-1 text-xs text-gb-fg-f font-mono border-b border-gb-bg-t">
MEMBERS
</div>
{filtered.map((user, index) => (
<button
key={user.id}
onClick={() => onSelect(user)}
onMouseEnter={() => setSelectedIndex(index)}
className={`
w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2
transition-colors
${index === selectedIndex
? 'bg-gb-orange text-gb-bg'
: 'text-gb-fg hover:bg-gb-bg-s'
}
`}
>
<span className="text-gb-green"></span>
<span>{user.display_name || user.username}</span>
{user.display_name && user.display_name !== user.username && (
<span className="text-gb-fg-f">({user.username})</span>
)}
</button>
))}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
interface MobileDrawerProps {
isOpen: boolean;
onClose: () => void;
}
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
if (!isOpen) return null;
return (
<div className="md:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Drawer */}
<div className="absolute left-0 top-0 bottom-0 w-[280px] bg-gb-bg flex">
{/* Server bar */}
<div className="w-[48px] bg-gb-bg-h">
<ServerBar />
</div>
{/* Channel list */}
<div className="flex-1">
<ChannelList />
</div>
</div>
{/* Swipe hint */}
<div className="absolute right-0 top-0 bottom-0 w-1 bg-gb-orange/20" />
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useVoiceStore } from '../stores/voice.ts';
import { ThemeToggle } from './ThemeToggle.tsx';
interface MobileNavProps {
activeTab: 'servers' | 'channels' | 'chat' | 'members';
onTabChange: (tab: 'servers' | 'channels' | 'chat' | 'members') => void;
onToggleDrawer: () => void;
}
export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavProps) {
const isConnected = useVoiceStore((state: any) => state.isConnected);
const tabs = [
{ id: 'servers' as const, label: '[=]', title: 'Servers' },
{ id: 'channels' as const, label: '[#]', title: 'Channels' },
{ id: 'chat' as const, label: '[_]', title: 'Chat' },
{ id: 'members' as const, label: '[@]', title: 'Members' },
];
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-gb-bg-h border-t border-gb-bg-t z-40">
<div className="flex items-center justify-around py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
if (tab.id === 'servers' || tab.id === 'channels') {
onToggleDrawer();
}
onTabChange(tab.id);
}}
className={`
px-3 py-1 text-xs font-mono transition-colors
${activeTab === tab.id
? 'text-gb-orange'
: 'text-gb-fg-f hover:text-gb-fg'
}
`}
title={tab.title}
>
{tab.label}
</button>
))}
{isConnected && (
<div className="w-1.5 h-1.5 rounded-full bg-gb-green animate-pulse" title="In voice" />
)}
<ThemeToggle />
</div>
{/* Safe area for phones with bottom notch */}
<div className="h-[env(safe-area-inset-bottom)]" />
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { api } from '../lib/api.ts';
interface Reaction {
emoji: string;
count: number;
users: string[];
reacted: boolean;
}
interface ReactionBarProps {
messageId: string;
reactions: Reaction[];
onRefresh: () => void;
}
export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) {
const [loading, setLoading] = useState(false);
const toggleReaction = async (emoji: string) => {
if (loading) return;
setLoading(true);
try {
const existing = reactions.find(r => r.emoji === emoji);
if (existing?.reacted) {
await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/messages/${messageId}/reactions`, { emoji });
}
onRefresh();
} catch (error) {
console.error('Failed to toggle reaction:', error);
} finally {
setLoading(false);
}
};
if (reactions.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1">
{reactions.map((reaction) => (
<button
key={reaction.emoji}
onClick={() => toggleReaction(reaction.emoji)}
disabled={loading}
className={`
px-1.5 py-0.5 text-xs font-mono border rounded-sm
transition-colors duration-100
${reaction.reacted
? 'border-gb-orange bg-gb-bg-t text-gb-orange'
: 'border-gb-bg-t bg-gb-bg-s text-gb-fg-f hover:border-gb-fg-f'
}
`}
title={reaction.users.join(', ')}
>
{reaction.emoji} {reaction.count}
</button>
))}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
interface ReplyBarProps {
replyTo: {
id: string;
content: string;
author: {
username: string;
accent_color?: string;
};
} | null;
onCancel?: () => void;
compact?: boolean;
}
export function ReplyBar({ replyTo, onCancel, compact }: ReplyBarProps) {
if (!replyTo) return null;
const truncatedContent = replyTo.content.length > 100
? replyTo.content.slice(0, 100) + '...'
: replyTo.content;
if (compact) {
return (
<div className="flex items-center gap-1 text-xs text-gb-fg-f font-mono pl-4 border-l-2 border-gb-orange">
<span className="text-gb-orange"></span>
<span style={{ color: replyTo.author.accent_color || 'var(--gb-fg-f)' }}>
{replyTo.author.username}
</span>
<span className="truncate">{truncatedContent}</span>
</div>
);
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-gb-bg-h border-l-2 border-gb-orange mb-1">
<span className="text-gb-orange text-xs font-mono"></span>
<span
className="text-xs font-mono font-bold"
style={{ color: replyTo.author.accent_color || 'var(--gb-fg-s)' }}
>
{replyTo.author.username}
</span>
<span className="text-xs font-mono text-gb-fg-f truncate flex-1">
{truncatedContent}
</span>
{onCancel && (
<button
onClick={onCancel}
className="text-xs text-gb-red font-mono hover:text-gb-orange transition-colors"
>
[x]
</button>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
type Theme = 'dark' | 'light';
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem('dumpster-theme');
return (stored === 'light' ? 'light' : 'dark');
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'light') {
root.classList.add('light');
root.classList.remove('dark');
} else {
root.classList.add('dark');
root.classList.remove('light');
}
localStorage.setItem('dumpster-theme', theme);
}, [theme]);
const toggle = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
return (
<button
onClick={toggle}
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
[{theme === 'dark' ? 'light' : 'dark'}]
</button>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { useTypingStore } from '../stores/typing.ts';
import { useAuthStore } from '../stores/auth.ts';
interface TypingIndicatorProps {
channelId: string;
}
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
const typingUsers = useTypingStore((state) => state.typingUsers[channelId] || []);
const currentUser = useAuthStore((state) => state.user);
// Filter out current user
const others = typingUsers.filter(u => u.userId !== currentUser?.id);
if (others.length === 0) return null;
const names = others.map(u => u.username);
let text: string;
if (names.length === 1) {
text = `<${names[0]}> is typing...`;
} else if (names.length === 2) {
text = `<${names[0]}> and <${names[1]}> are typing...`;
} else {
text = `${names.length} users are typing...`;
}
return (
<div className="px-4 py-1 text-xs text-gb-fg-f font-mono animate-pulse">
{text}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand';
import type { UserStatus } from './auth.ts';
export interface PresenceEntry {
userId: string;
username: string;
status: UserStatus;
}
interface PresenceState {
presences: Record<string, PresenceEntry>;
/** Upsert a single user's presence from a WS event. */
updatePresence: (userId: string, username: string, status: UserStatus) => void;
/** Get a user's current presence; defaults to offline. */
getPresence: (userId: string) => PresenceEntry;
}
const DEFAULT_PRESENCE: PresenceEntry = {
userId: '',
username: '',
status: 'offline',
};
export const usePresenceStore = create<PresenceState>((set, get) => ({
presences: {},
updatePresence: (userId, username, status) =>
set((state) => ({
presences: {
...state.presences,
[userId]: { userId, username, status },
},
})),
getPresence: (userId) => get().presences[userId] ?? DEFAULT_PRESENCE,
}));
+77
View File
@@ -0,0 +1,77 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
interface PushState {
isSupported: boolean;
isSubscribed: boolean;
permission: NotificationPermission;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
requestPermission: () => Promise<NotificationPermission>;
}
function urlBase64ToUint8Array(base64String: string): BufferSource {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export const usePushStore = create<PushState>((set, get) => ({
isSupported: 'serviceWorker' in navigator && 'PushManager' in window,
isSubscribed: false,
permission: 'default',
requestPermission: async () => {
if (!get().isSupported) return 'denied';
const permission = await Notification.requestPermission();
set({ permission });
return permission;
},
subscribe: async () => {
if (!get().isSupported) return;
try {
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Get VAPID public key from server
const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-key');
if (!publicKey) return;
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
await api.post('/push/subscribe', subscription.toJSON());
set({ isSubscribed: true });
} catch (error) {
console.error('Failed to subscribe to push:', error);
}
},
unsubscribe: async () => {
if (!get().isSupported) return;
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await api.post('/push/unsubscribe', { endpoint: subscription.endpoint });
await subscription.unsubscribe();
}
set({ isSubscribed: false });
} catch (error) {
console.error('Failed to unsubscribe from push:', error);
}
},
}));
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { useWebSocketStore } from './ws';
interface TypingUser {
userId: string;
username: string;
channelId: string;
timeout: ReturnType<typeof setTimeout>;
}
interface TypingState {
typingUsers: Record<string, TypingUser[]>;
sendTypingStart: (channelId: string) => void;
_handleTypingEvent: (data: { channel_id: string; user_id: string; username: string }) => void;
}
export const useTypingStore = create<TypingState>((set, get) => ({
typingUsers: {},
sendTypingStart: (channelId) => {
const ws = useWebSocketStore.getState();
if (ws.connected) {
ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } });
}
},
_handleTypingEvent: (data) => {
const { channel_id, user_id, username } = data;
const existing = get().typingUsers[channel_id] || [];
const existingUser = existing.find(u => u.userId === user_id);
if (existingUser) {
clearTimeout(existingUser.timeout);
}
const timeout = setTimeout(() => {
set(state => ({
typingUsers: {
...state.typingUsers,
[channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id),
},
}));
}, 3000);
set(state => ({
typingUsers: {
...state.typingUsers,
[channel_id]: [
...(state.typingUsers?.[channel_id] || []).filter((u: { userId: string }) => u.userId !== user_id),
{ userId: user_id, username, channelId: channel_id, timeout },
],
},
}));
},
}));
+17
View File
@@ -2,9 +2,12 @@ import { create } from 'zustand';
import { useMessageStore } from './message.ts';
import { useChannelStore } from './channel.ts';
import { useServerStore } from './server.ts';
import { usePresenceStore } from './presence.ts';
import { useTypingStore } from './typing.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
import type { UserStatus } from './auth.ts';
type UnknownPayload = Record<string, unknown>;
@@ -147,6 +150,20 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (server) updateServer(server);
break;
}
case 'PRESENCE_UPDATE': {
const { user_id, username, status } = data.payload as Record<string, string>;
if (user_id && username && status) {
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
}
break;
}
case 'TYPING_START': {
const { channel_id, user_id, username } = data.payload as Record<string, string>;
if (channel_id && user_id && username) {
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
}
break;
}
default:
break;
}
+14
View File
@@ -24,6 +24,20 @@
--gb-border: #665c54;
}
/* Light mode overrides */
.light {
--gb-bg-h: #f9f5d7;
--gb-bg: #fbf1c7;
--gb-bg-s: #ebdbb2;
--gb-bg-t: #d5c4a1;
--gb-bg-f: #bdae93;
--gb-fg: #282828;
--gb-fg-s: #3c3836;
--gb-fg-t: #504945;
--gb-fg-f: #7c6f64;
--gb-border: #a89984;
}
html, body, #root {
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
font-size: 14px;
+11
View File
@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
@@ -24,6 +25,16 @@ export default {
'gb-aqua': '#8ec07c',
'gb-orange': '#fe8019',
'gb-gray': '#928374',
// Light mode overrides
'gb-light-bg-h': '#f9f5d7',
'gb-light-bg': '#fbf1c7',
'gb-light-bg-s': '#ebdbb2',
'gb-light-bg-t': '#d5c4a1',
'gb-light-bg-f': '#bdae93',
'gb-light-fg': '#282828',
'gb-light-fg-s': '#3c3836',
'gb-light-fg-t': '#504945',
'gb-light-fg-f': '#7c6f64',
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}