diff --git a/web/index.html b/web/index.html
index ca2ec1e..19f2456 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,14 +2,31 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dumpster
diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png
new file mode 100644
index 0000000..0e04f8a
Binary files /dev/null and b/web/public/apple-touch-icon.png differ
diff --git a/web/public/favicon-16.png b/web/public/favicon-16.png
new file mode 100644
index 0000000..290d4da
Binary files /dev/null and b/web/public/favicon-16.png differ
diff --git a/web/public/favicon-32.png b/web/public/favicon-32.png
new file mode 100644
index 0000000..c671e2b
Binary files /dev/null and b/web/public/favicon-32.png differ
diff --git a/web/public/icons/icon-128.png b/web/public/icons/icon-128.png
new file mode 100644
index 0000000..caba87e
Binary files /dev/null and b/web/public/icons/icon-128.png differ
diff --git a/web/public/icons/icon-144.png b/web/public/icons/icon-144.png
new file mode 100644
index 0000000..3023c56
Binary files /dev/null and b/web/public/icons/icon-144.png differ
diff --git a/web/public/icons/icon-152.png b/web/public/icons/icon-152.png
new file mode 100644
index 0000000..1e0cd38
Binary files /dev/null and b/web/public/icons/icon-152.png differ
diff --git a/web/public/icons/icon-192-maskable.png b/web/public/icons/icon-192-maskable.png
new file mode 100644
index 0000000..c9e490a
Binary files /dev/null and b/web/public/icons/icon-192-maskable.png differ
diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png
index 1fa3d9d..81db14a 100644
Binary files a/web/public/icons/icon-192.png and b/web/public/icons/icon-192.png differ
diff --git a/web/public/icons/icon-384.png b/web/public/icons/icon-384.png
new file mode 100644
index 0000000..ca1712c
Binary files /dev/null and b/web/public/icons/icon-384.png differ
diff --git a/web/public/icons/icon-48.png b/web/public/icons/icon-48.png
new file mode 100644
index 0000000..1ba3ac9
Binary files /dev/null and b/web/public/icons/icon-48.png differ
diff --git a/web/public/icons/icon-512-maskable.png b/web/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..09fc07e
Binary files /dev/null and b/web/public/icons/icon-512-maskable.png differ
diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png
index 3e6dfff..0a27665 100644
Binary files a/web/public/icons/icon-512.png and b/web/public/icons/icon-512.png differ
diff --git a/web/public/icons/icon-72.png b/web/public/icons/icon-72.png
new file mode 100644
index 0000000..d28d488
Binary files /dev/null and b/web/public/icons/icon-72.png differ
diff --git a/web/public/icons/icon-96.png b/web/public/icons/icon-96.png
new file mode 100644
index 0000000..73e42de
Binary files /dev/null and b/web/public/icons/icon-96.png differ
diff --git a/web/public/manifest.json b/web/public/manifest.json
index 2d8e857..359fd57 100644
--- a/web/public/manifest.json
+++ b/web/public/manifest.json
@@ -1,22 +1,89 @@
{
- "name": "Dumpster",
+ "name": "Dumpster Chat",
"short_name": "Dumpster",
"description": "A chaotic, self-hosted Discord-like platform",
"start_url": "/",
+ "scope": "/",
"display": "standalone",
+ "display_override": ["window-controls-overlay", "standalone"],
+ "orientation": "any",
"background_color": "#282828",
"theme_color": "#282828",
- "orientation": "any",
+ "categories": ["social", "communication"],
+ "lang": "en",
+ "dir": "ltr",
"icons": [
+ {
+ "src": "/icons/icon-48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-72.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-128.png",
+ "sizes": "128x128",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-152.png",
+ "sizes": "152x152",
+ "type": "image/png"
+ },
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192-maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-384.png",
+ "sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
- "type": "image/png"
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Create Account",
+ "short_name": "Register",
+ "url": "/?register=true",
+ "icons": [
+ {
+ "src": "/icons/icon-96.png",
+ "sizes": "96x96"
+ }
+ ]
}
]
}
diff --git a/web/public/offline.html b/web/public/offline.html
new file mode 100644
index 0000000..41f9539
--- /dev/null
+++ b/web/public/offline.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+ Dumpster - Offline
+
+
+
+
+
📡
+
YOU ARE OFFLINE
+
+ Dumpster can't reach the server. Check your connection and try again.
+ Messages will sync when you're back online.
+
+
OFFLINE
+
+
+
+
+
+
diff --git a/web/public/sw.js b/web/public/sw.js
index 57e6998..dc9e656 100644
--- a/web/public/sw.js
+++ b/web/public/sw.js
@@ -1,61 +1,79 @@
-// ponytail: bump CACHE_NAME on each deploy, or stale HTML breaks assets.
-// Network-first for navigation (index.html) keeps hashed asset refs fresh.
-const CACHE_NAME = 'dumpster-v3';
+// Dumpster PWA Service Worker
+// Bump CACHE_VERSION on each deploy to force re-cache of hashed assets.
+const CACHE_VERSION = 'dumpster-v4';
+const OFFLINE_URL = '/offline.html';
-self.addEventListener('install', () => {
- self.skipWaiting();
+// Assets to pre-cache on install (shell files)
+const PRECACHE_URLS = [
+ '/',
+ '/offline.html',
+ '/favicon.png',
+ '/icons/icon-192.png',
+ '/icons/icon-512.png',
+];
+
+// Install: pre-cache shell and skip waiting
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
+ );
});
+// Activate: clean old caches and claim clients
self.addEventListener('activate', (event) => {
event.waitUntil(
- caches.keys().then((names) =>
- Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
- )
+ caches
+ .keys()
+ .then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n))))
+ .then(() => self.clients.claim())
);
- self.clients.claim();
});
+// Fetch handler
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
- // Skip API + WS
+ // Skip API, WebSocket, and cross-origin requests
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
-
- // Skip cross-origin requests (e.g. Giphy, LiveKit)
if (url.origin !== self.location.origin) return;
- // Network-first for navigation requests (index.html) — always fetch fresh HTML
- // so hashed asset references stay current after deploys.
+ // Navigation requests: network-first, offline fallback
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
- caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
+ caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
return response;
})
- .catch(() => caches.match('/index.html'))
+ .catch(() =>
+ caches.match(event.request).then((cached) => cached || caches.match(OFFLINE_URL))
+ )
);
return;
}
- // Cache-first for static assets (JS, CSS, images, fonts)
+ // Static assets (JS, CSS, images, fonts): stale-while-revalidate
+ // Serve cached immediately, fetch fresh in background
event.respondWith(
caches.match(event.request).then((cached) => {
- const fetchPromise = fetch(event.request).then((response) => {
- if (response.ok) {
- const clone = response.clone();
- caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
- }
- return response;
- }).catch(() => cached);
+ const fetchPromise = fetch(event.request)
+ .then((response) => {
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
+ }
+ return response;
+ })
+ .catch(() => cached);
return cached || fetchPromise;
})
);
});
+// Push notifications
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
@@ -65,11 +83,14 @@ self.addEventListener('push', (event) => {
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
vibrate: [100, 50, 100],
+ tag: data.tag || 'dumpster-notification',
+ renotify: true,
data: { url: data.url || '/' },
})
);
});
+// Notification click: focus or open window
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
diff --git a/web/src/App.tsx b/web/src/App.tsx
index f011c75..2f994c6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -12,6 +12,8 @@ import { DMChat } from './components/DMChat.tsx';
import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx';
import { ResetPasswordPage } from './components/ResetPasswordPage.tsx';
import { useAuthStore } from './stores/auth.ts';
+import { InstallBanner } from './components/InstallBanner.tsx';
+import { ConnectionStatus } from './components/ConnectionStatus.tsx';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -32,6 +34,8 @@ function App() {
return (
+
+
} />
} />
diff --git a/web/src/components/ConnectionStatus.tsx b/web/src/components/ConnectionStatus.tsx
new file mode 100644
index 0000000..292bd21
--- /dev/null
+++ b/web/src/components/ConnectionStatus.tsx
@@ -0,0 +1,40 @@
+import { useState, useEffect } from "react";
+
+export function ConnectionStatus() {
+ const [online, setOnline] = useState(navigator.onLine);
+ const [visible, setVisible] = useState(!navigator.onLine);
+
+ useEffect(() => {
+ const handleOnline = () => {
+ setOnline(true);
+ // Show "back online" briefly then hide
+ setVisible(true);
+ setTimeout(() => setVisible(false), 3000);
+ };
+ const handleOffline = () => {
+ setOnline(false);
+ setVisible(true);
+ };
+
+ window.addEventListener("online", handleOnline);
+ window.addEventListener("offline", handleOffline);
+ return () => {
+ window.removeEventListener("online", handleOnline);
+ window.removeEventListener("offline", handleOffline);
+ };
+ }, []);
+
+ if (!visible) return null;
+
+ return (
+
+ {online ? "● CONNECTION RESTORED" : "○ YOU ARE OFFLINE — messages will send when reconnected"}
+
+ );
+}
diff --git a/web/src/components/InstallBanner.tsx b/web/src/components/InstallBanner.tsx
new file mode 100644
index 0000000..68f3209
--- /dev/null
+++ b/web/src/components/InstallBanner.tsx
@@ -0,0 +1,98 @@
+import { useState, useEffect, useCallback } from "react";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+const DISMISSED_KEY = "dumpster-pwa-install-dismissed";
+
+export function InstallBanner() {
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [visible, setVisible] = useState(false);
+ const [isIOS, setIsIOS] = useState(false);
+
+ useEffect(() => {
+ // Check if already dismissed
+ if (localStorage.getItem(DISMISSED_KEY)) return;
+
+ // Detect iOS (no beforeinstallprompt)
+ const ua = window.navigator.userAgent;
+ const isIOSDevice = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Mac") && "ontouchend" in window);
+ const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
+
+ if (isStandalone) return; // Already installed
+
+ if (isIOSDevice && !isStandalone) {
+ setIsIOS(true);
+ // Show iOS instructions after a short delay
+ const timer = setTimeout(() => setVisible(true), 3000);
+ return () => clearTimeout(timer);
+ }
+
+ const handler = (e: Event) => {
+ e.preventDefault();
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
+ // Show banner after a short delay so it doesn't clash with page load
+ setTimeout(() => setVisible(true), 2000);
+ };
+
+ window.addEventListener("beforeinstallprompt", handler);
+ return () => window.removeEventListener("beforeinstallprompt", handler);
+ }, []);
+
+ const handleInstall = useCallback(async () => {
+ if (!deferredPrompt) return;
+ await deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+ if (outcome === "accepted") {
+ setVisible(false);
+ }
+ setDeferredPrompt(null);
+ }, [deferredPrompt]);
+
+ const handleDismiss = useCallback(() => {
+ setVisible(false);
+ localStorage.setItem(DISMISSED_KEY, "1");
+ }, []);
+
+ if (!visible) return null;
+
+ return (
+
+
+

+
+
Install Dumpster
+ {isIOS ? (
+
+ Tap Share then{" "}
+ Add to Home Screen
+
+ ) : (
+
+ Add to home screen for the full app experience
+
+ )}
+
+
+ {!isIOS && deferredPrompt && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx
index f53c2f7..8863e37 100644
--- a/web/src/components/LoginForm.tsx
+++ b/web/src/components/LoginForm.tsx
@@ -175,14 +175,6 @@ export function LoginForm() {
{isRegister ? "[BACK TO LOGIN]" : "[CREATE ACCOUNT]"}
- {!isRegister && (
-
- Use Link component:{" "}
-
- [HOME]
-
-
- )}
);