Bug (diagnosed by backyard, see project_session_2026-05-15_push.md): - Manual `supabase.realtime.setAuth()` calls in subscribe-hooks set `_manuallySetToken=true` internally, blocking the automatic token-refresh on heartbeat. After ~1h the cached access_token expires → Postgres-Changes silently stop arriving (channel still shows "joined" but no events). - Plus: no AppState handler → no Foreground-Reconnect trigger after Background-kill of WebSocket. Fix A — lib/supabase.ts: createClient now passes a `realtime.accessToken` async callback that returns the current session token. Heartbeat picks fresh tokens automatically, no manual setAuth needed. Fix A — all 5 manual `supabase.realtime.setAuth()` calls removed from useChatRealtime, useCommunityRealtime, useDomainSubmissionRealtime, stores/notifications. Token is handled by the callback now. Fix B — _layout.tsx: AppState listener calls supabase.auth.startAutoRefresh()/stopAutoRefresh() — official Supabase RN pattern. On Foreground-Return, onAuthStateChange fires TOKEN_REFRESHED → realtime.setAuth gets called internally. Required for upcoming Auto-Detect protected-device handshake (Realtime channel listens on protected_devices status transitions pending→active). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
149 lines
4.0 KiB
TypeScript
149 lines
4.0 KiB
TypeScript
import { create } from "zustand";
|
|
import { apiFetch } from "../lib/api";
|
|
import { supabase } from "../lib/supabase";
|
|
import type { RealtimeChannel } from "@supabase/supabase-js";
|
|
|
|
export interface AppNotification {
|
|
id: string;
|
|
type: string;
|
|
actorName: string;
|
|
actorAvatar: string | null;
|
|
postId: string | null;
|
|
preview: string | null;
|
|
readAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
type NotificationState = {
|
|
items: AppNotification[];
|
|
unread: number;
|
|
loaded: boolean;
|
|
load: () => Promise<void>;
|
|
markRead: () => Promise<void>;
|
|
remove: (id: string) => Promise<void>;
|
|
startRealtime: () => Promise<void>;
|
|
stopRealtime: () => void;
|
|
reset: () => void;
|
|
};
|
|
|
|
let realtimeSub: RealtimeChannel | null = null;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
|
items: [],
|
|
unread: 0,
|
|
loaded: false,
|
|
|
|
load: async () => {
|
|
try {
|
|
const res = await apiFetch<{ items: AppNotification[]; unread: number }>(
|
|
"/api/notifications",
|
|
);
|
|
set({ items: res.items ?? [], unread: res.unread ?? 0, loaded: true });
|
|
} catch (err) {
|
|
console.warn("[notifications] load failed:", err);
|
|
}
|
|
},
|
|
|
|
markRead: async () => {
|
|
if (get().unread === 0) return;
|
|
const now = new Date().toISOString();
|
|
set((s) => ({
|
|
unread: 0,
|
|
items: s.items.map((n) => ({ ...n, readAt: n.readAt ?? now })),
|
|
}));
|
|
try {
|
|
await apiFetch("/api/notifications/read", { method: "POST" });
|
|
} catch (err) {
|
|
console.warn("[notifications] markRead failed:", err);
|
|
}
|
|
},
|
|
|
|
remove: async (id) => {
|
|
set((s) => ({ items: s.items.filter((n) => n.id !== id) }));
|
|
try {
|
|
await apiFetch(`/api/notifications/${id}`, { method: "DELETE" });
|
|
} catch (err) {
|
|
console.warn("[notifications] remove failed:", err);
|
|
}
|
|
},
|
|
|
|
startRealtime: async () => {
|
|
if (realtimeSub) return;
|
|
const { data } = await supabase.auth.getSession();
|
|
const session = data.session;
|
|
if (!session?.user?.id) return;
|
|
|
|
const myId = session.user.id;
|
|
|
|
realtimeSub = supabase
|
|
.channel(`notifications:${myId}:${Date.now()}`)
|
|
.on(
|
|
"postgres_changes",
|
|
{
|
|
event: "INSERT",
|
|
schema: "rebreak",
|
|
table: "notifications",
|
|
filter: `recipient_id=eq.${myId}`,
|
|
},
|
|
(payload: any) => {
|
|
const r = payload.new;
|
|
const notif: AppNotification = {
|
|
id: r.id,
|
|
type: r.type,
|
|
actorName: r.actor_name,
|
|
actorAvatar: r.actor_avatar ?? null,
|
|
postId: r.post_id ?? null,
|
|
preview: r.preview ?? null,
|
|
readAt: null,
|
|
createdAt: r.created_at,
|
|
};
|
|
set((s) => {
|
|
if (s.items.find((n) => n.id === notif.id)) return s;
|
|
return { items: [notif, ...s.items], unread: s.unread + 1 };
|
|
});
|
|
},
|
|
)
|
|
.on(
|
|
"postgres_changes",
|
|
{
|
|
event: "DELETE",
|
|
schema: "rebreak",
|
|
table: "notifications",
|
|
filter: `recipient_id=eq.${myId}`,
|
|
},
|
|
(payload: any) => {
|
|
const id = payload.old?.id;
|
|
if (!id) return;
|
|
set((s) => ({ items: s.items.filter((n) => n.id !== id) }));
|
|
},
|
|
)
|
|
.subscribe((status) => {
|
|
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
|
|
console.warn("[notifRealtime] error:", status);
|
|
get().stopRealtime();
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
reconnectTimer = setTimeout(() => {
|
|
get().startRealtime();
|
|
}, 3000);
|
|
}
|
|
});
|
|
},
|
|
|
|
stopRealtime: () => {
|
|
if (realtimeSub) {
|
|
supabase.removeChannel(realtimeSub);
|
|
realtimeSub = null;
|
|
}
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
},
|
|
|
|
reset: () => {
|
|
get().stopRealtime();
|
|
set({ items: [], unread: 0, loaded: false });
|
|
},
|
|
}));
|