use-realtime-movies.ts 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. "use client";
  2. import { useCallback } from "react";
  3. import type { RealtimePostgresChangesPayload } from "@supabase/supabase-js";
  4. import { useQueryClient, type InfiniteData } from "@tanstack/react-query";
  5. import type { Database } from "@/types/database";
  6. import { useRealtimeChannel } from "./use-realtime-channel";
  7. import { buildEqFilter } from "@/lib/realtime/subscription-manager";
  8. import { useCurrentUser } from "@/hooks/use-current-user";
  9. type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
  10. function moviesQueryKey(groupId: string): readonly [string, string] {
  11. return ["group-movies", groupId] as const;
  12. }
  13. /**
  14. * Hook that subscribes to Supabase Realtime changes on the movies table
  15. * for a specific group_id. Updates the TanStack Query cache directly
  16. * on INSERT, UPDATE, and DELETE events.
  17. */
  18. export function useRealtimeMovies(groupId: string | null) {
  19. const queryClient = useQueryClient();
  20. // Use the current user id so realtime events can dual-invalidate
  21. // the cross-list ["all-user-movies", userId] query key alongside the
  22. // per-group ["group-movies", groupId] cache update.
  23. const { data: currentUser } = useCurrentUser();
  24. const userId = currentUser?.id ?? null;
  25. const handlePayload = useCallback(
  26. (payload: RealtimePostgresChangesPayload<MovieRow>) => {
  27. if (!groupId) return;
  28. const key = moviesQueryKey(groupId);
  29. if (payload.eventType === "INSERT") {
  30. const newMovie = payload.new;
  31. queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
  32. if (!old) return undefined;
  33. // Avoid duplicates across all pages
  34. if (old.pages.some((page) => page.some((m) => m.id === newMovie.id))) return old;
  35. // Prepend to first page (ordered by added_at DESC)
  36. return {
  37. pages: [[newMovie, ...old.pages[0]], ...old.pages.slice(1)],
  38. pageParams: old.pageParams,
  39. };
  40. });
  41. if (userId) {
  42. void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
  43. }
  44. } else if (payload.eventType === "UPDATE") {
  45. const updated = payload.new;
  46. queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
  47. if (!old) return undefined;
  48. return {
  49. pages: old.pages.map((page) => page.map((m) => (m.id === updated.id ? updated : m))),
  50. pageParams: old.pageParams,
  51. };
  52. });
  53. if (userId) {
  54. void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
  55. }
  56. } else if (payload.eventType === "DELETE") {
  57. const deleted = payload.old;
  58. if (deleted && "id" in deleted) {
  59. queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
  60. if (!old) return undefined;
  61. return {
  62. pages: old.pages.map((page) => page.filter((m) => m.id !== deleted.id)),
  63. pageParams: old.pageParams,
  64. };
  65. });
  66. if (userId) {
  67. void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
  68. }
  69. }
  70. }
  71. },
  72. [groupId, queryClient, userId],
  73. );
  74. const { status } = useRealtimeChannel<MovieRow>({
  75. channelName: groupId ? `movies:${groupId}` : null,
  76. config: {
  77. event: "*",
  78. schema: "public",
  79. table: "movies",
  80. filter: groupId ? buildEqFilter("group_id", groupId) : undefined,
  81. onPayload: handlePayload,
  82. },
  83. enabled: !!groupId,
  84. });
  85. return { status };
  86. }