rebreak-monorepo/apps/rebreak-native/hooks/useCommunityRealtime.ts
chahinebrini db377da7ce fix(native): realtime disconnect bug — accessToken callback + AppState handler
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>
2026-05-15 21:48:54 +02:00

153 lines
5.3 KiB
TypeScript

import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type { CommunityPost } from "../stores/community";
/**
* Realtime-Subscription für die Community-Feed-Page.
* Lauscht auf:
* - INSERT auf community_posts → invalidiert die Feed-Query (frischer Refetch)
* - UPDATE auf community_posts → patcht likes/comments-Counts inline
* - UPDATE auf domain_submissions → patcht domain_vote-Posts mit neuem Status
* - UPDATE auf game_challenges → patcht challenge-Status
*
* Pendant zum Nuxt-`communityStore.startRealtime()` aus apps/rebreak/.
*/
export function useCommunityRealtime(enabled: boolean = true) {
const queryClient = useQueryClient();
useEffect(() => {
if (!enabled) return;
let channel: RealtimeChannel | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function subscribe() {
const { data } = await supabase.auth.getSession();
const session = data.session;
if (!session?.access_token) return;
if (cancelled) return;
const myId = session.user.id;
channel = supabase
.channel(`community:posts:${Date.now()}`)
.on(
"postgres_changes",
{ event: "INSERT", schema: "rebreak", table: "community_posts" },
(payload: any) => {
const r = payload.new;
if (r.user_id === myId) return; // eigene Posts schon optimistisch hinzugefügt
if (r.is_moderated) return;
// Einfacher als Detail-Fetch: alle Feed-Queries invalidieren
queryClient.invalidateQueries({ queryKey: ["community-posts"] });
},
)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "rebreak", table: "community_posts" },
(payload: any) => {
const r = payload.new;
patchPostInAllQueries(queryClient, r.id, (p) => ({
...p,
likesCount: r.likes_count ?? p.likesCount,
dislikesCount: r.dislikes_count ?? p.dislikesCount,
commentsCount: r.comments_count ?? p.commentsCount,
repostsCount: r.reposts_count ?? p.repostsCount,
}));
},
)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "rebreak", table: "domain_submissions" },
(payload: any) => {
const r = payload.new;
if (
r.status !== "approved" &&
r.status !== "rejected" &&
r.status !== "in_review"
) {
return;
}
patchPostInAllQueries(queryClient, null, (p) => {
if (p.submission?.domain == null) return p;
// Wir kennen die submissionId nicht direkt am Post, also matche per domain.
// Realistisch unique pro user_id, aber Feed enthält Post mit submission-Objekt.
// Wenn der Post diese submission referenziert, patchen.
if (!p.submission || (p as any).submissionId !== r.id) {
// Falls du submissionId an Post-Schema hängst, hier nutzen.
// Solange nicht: invalidate fallback unten.
return p;
}
return {
...p,
submission: {
...p.submission,
status: r.status,
yesVotes: r.yes_votes ?? p.submission.yesVotes,
noVotes: r.no_votes ?? p.submission.noVotes,
reviewedAt: r.reviewed_at ?? p.submission.reviewedAt,
},
};
});
// Sicherheitshalber auch invalidieren — domain_vote ist selten genug.
queryClient.invalidateQueries({ queryKey: ["community-posts"] });
},
)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "rebreak", table: "game_challenges" },
(payload: any) => {
const r = payload.new;
patchPostInAllQueries(queryClient, null, (p) =>
p.challengeId === r.id ? { ...p, challengeStatus: r.status } : p,
);
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [enabled, queryClient]);
}
function patchPostInAllQueries(
queryClient: ReturnType<typeof useQueryClient>,
postId: string | null,
patcher: (p: CommunityPost) => CommunityPost,
) {
const queries = queryClient.getQueriesData<CommunityPost[]>({
queryKey: ["community-posts"],
});
for (const [key, data] of queries) {
if (!Array.isArray(data)) continue;
const next = data.map((p) => {
if (postId !== null && p.id !== postId) return p;
return patcher(p);
});
queryClient.setQueryData(key, next);
}
}