diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index a8ab584..9dda994 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -8,6 +8,7 @@ import { useLayoutStore } from '../stores/layout.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
+import { NotificationPrompt } from './NotificationPrompt.tsx';
import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
@@ -223,6 +224,7 @@ export function Layout() {
{new Date().toISOString().slice(0, 10)}
+
);
}
diff --git a/web/src/components/NotificationPrompt.tsx b/web/src/components/NotificationPrompt.tsx
new file mode 100644
index 0000000..4a4ab49
--- /dev/null
+++ b/web/src/components/NotificationPrompt.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react';
+import { usePushStore } from '../stores/push.ts';
+
+export function NotificationPrompt() {
+ const { isSupported, permission, isSubscribed, subscribe } = usePushStore();
+ const [dismissed, setDismissed] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (localStorage.getItem('push-prompt-dismissed') === 'true') {
+ setDismissed(true);
+ }
+ }, []);
+
+ // Don't show if not supported, already subscribed, permission resolved, or dismissed
+ if (!isSupported || isSubscribed || permission === 'granted' || permission === 'denied' || dismissed) {
+ return null;
+ }
+
+ const handleEnable = async () => {
+ setLoading(true);
+ try {
+ await subscribe();
+ } catch {
+ // user denied or error
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDismiss = () => {
+ setDismissed(true);
+ localStorage.setItem('push-prompt-dismissed', 'true');
+ };
+
+ return (
+
+
🔔
+
+
Enable notifications?
+
Get notified about new messages
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts
index b0f0506..459d132 100644
--- a/web/src/stores/auth.ts
+++ b/web/src/stores/auth.ts
@@ -72,10 +72,12 @@ interface AuthState {
}
// ponytail: auto-subscribe to push notifications on auth
+// Only fires when permission is already 'granted' (re-subscribe on session restore).
+// iOS requires a user gesture for the initial permission prompt — that's handled by NotificationPrompt.
function autoSubscribePush() {
import("../stores/push.ts").then(({ usePushStore }) => {
const ps = usePushStore.getState();
- if (ps.isSupported && Notification.permission !== 'denied' && !ps.isSubscribed) {
+ if (ps.isSupported && Notification.permission === 'granted' && !ps.isSubscribed) {
ps.subscribe();
}
});