Skip to content

Commit bec220f

Browse files
authored
Merge pull request #98 from techdiary-dev/copilot/add-realtime-ui-updates
feat: realtime UI updates via Pusher/Soketi + TanStack Query invalidation (Phase 1 & 2)
2 parents 971c6ee + 329bc8a commit bec220f

12 files changed

Lines changed: 251 additions & 4 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"modern-screenshot": "^4.6.8",
6161
"next": "^16.2.1",
6262
"pg": "^8.14.1",
63+
"pusher": "^5.3.3",
64+
"pusher-js": "^8.5.0",
6365
"react": "^19",
6466
"react-advanced-cropper": "^0.20.1",
6567
"react-dom": "^19",

src/app/(home)/_components/ArticleFeed.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const ArticleFeed = () => {
8585
!article?.cover_image
8686
? extractImageUrlsFromMarkdown(article?.body ?? "")?.splice(
8787
0,
88-
4
88+
4,
8989
)
9090
: []
9191
}

src/app/api/socket/auth/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { pusherServer } from "@/lib/pusher/pusher.server";
2+
import { authID } from "@/backend/services/session.actions";
3+
import { NextRequest, NextResponse } from "next/server";
4+
5+
/**
6+
* Pusher private-channel auth endpoint.
7+
* Pusher-js POSTs `socket_id` and `channel_name` as application/x-www-form-urlencoded.
8+
* We verify the user's session and only sign subscriptions to the caller's own channel.
9+
*/
10+
export async function POST(req: NextRequest) {
11+
if (!pusherServer) {
12+
return new NextResponse("Realtime not configured", { status: 503 });
13+
}
14+
15+
const userId = await authID();
16+
if (!userId) {
17+
return new NextResponse("Unauthorized", { status: 401 });
18+
}
19+
20+
const body = await req.text();
21+
const params = new URLSearchParams(body);
22+
const socketId = params.get("socket_id") ?? "";
23+
const channel = params.get("channel_name") ?? "";
24+
25+
// Only allow subscribing to the caller's own user channel
26+
if (channel !== `private-user.${userId}`) {
27+
return new NextResponse("Forbidden", { status: 403 });
28+
}
29+
30+
const authResponse = pusherServer.authorizeChannel(socketId, channel);
31+
return NextResponse.json(authResponse);
32+
}

src/backend/services/article-cleanup-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use server";
2-
import { and, lt, lte, neq } from "sqlkit";
2+
import { and, lte, neq } from "sqlkit";
33
import { persistenceRepository } from "../persistence/persistence-repositories";
44
import { handleActionException } from "./RepositoryException";
55
import { deleteArticleById } from "./search.service";

src/backend/services/comment.action.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { and, eq, inArray } from "sqlkit";
1010
import { CommentPresentation } from "../models/domain-models";
1111
import { inngest } from "@/lib/inngest";
1212
import { assertCommentResourceExists } from "./notifications.payload";
13+
import { publishMessage } from "@/lib/pusher/pusher.server";
1314

1415
const sql = String.raw;
1516

@@ -68,6 +69,12 @@ export const createMyComment = async (
6869
console.error("[inngest] Failed to send notification event:", err);
6970
});
7071

72+
void publishMessage(
73+
`resource.${resource_type}.${resource_id}`,
74+
"comment.created",
75+
{ scope: "comments" },
76+
);
77+
7178
return created?.rows?.[0];
7279
};
7380

@@ -97,6 +104,12 @@ export const updateMyComment = async (
97104
data: { body: input.body, updated_at: new Date() },
98105
});
99106

107+
void publishMessage(
108+
`resource.${existing.resource_type}.${existing.resource_id}`,
109+
"comment.updated",
110+
{ scope: "comments" },
111+
);
112+
100113
return { success: true as const, data: { id: input.id } };
101114
} catch (error) {
102115
return handleActionException(error);
@@ -149,6 +162,12 @@ export const deleteMyComment = async (
149162
where: inArray("id", ids),
150163
});
151164

165+
void publishMessage(
166+
`resource.${root.resource_type}.${root.resource_id}`,
167+
"comment.deleted",
168+
{ scope: "comments" },
169+
);
170+
152171
return { success: true as const, data: { id: input.id } };
153172
} catch (error) {
154173
return handleActionException(error);

src/components/comment-section.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Trash2,
1919
} from "lucide-react";
2020
import Link from "next/link";
21-
import React, { useMemo, useState } from "react";
21+
import React, { useMemo, useState, useEffect } from "react";
2222
import { useImmer } from "use-immer";
2323
import { useLoginPopup } from "./app-login-popup";
2424
import ResourceReaction from "./ResourceReaction";
@@ -38,6 +38,7 @@ import { Button } from "./ui/button";
3838
import { Skeleton } from "./ui/skeleton";
3939
import { Textarea } from "./ui/textarea";
4040
import getFileUrl from "@/utils/getFileUrl";
41+
import { listenChannel } from "@/lib/pusher/pusher.client";
4142

4243
const Context = React.createContext<
4344
{ mutatingId?: string; setMutatingId: (id?: string) => void } | undefined
@@ -155,6 +156,22 @@ export const CommentSection = (props: {
155156
refetchOnReconnect: false,
156157
});
157158

159+
// Phase 2: subscribe to the resource's public Pusher channel and
160+
// invalidate the comments query when any mutation arrives from another client.
161+
useEffect(() => {
162+
const channelName = `resource.${props.resource_type}.${props.resource_id}`;
163+
const invalidate = () => {
164+
queryClient.invalidateQueries({
165+
queryKey: ["comments", props.resource_id, props.resource_type],
166+
});
167+
};
168+
return listenChannel(channelName, {
169+
"comment.created": invalidate,
170+
"comment.updated": invalidate,
171+
"comment.deleted": invalidate,
172+
});
173+
}, [props.resource_id, props.resource_type, queryClient]);
174+
158175
const generated_comment_id = () => crypto.randomUUID();
159176
const listQueryKey = [
160177
"comments",

src/components/providers/CommonProviders.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ThemeProvider } from "./theme-provider";
1111
import { AppConfirmProvider } from "../app-confirm";
1212
import { AppAlertProvider } from "../app-alert";
1313
import { AppLoginPopupProvider } from "../app-login-popup";
14+
import { RealtimeProvider } from "./RealtimeProvider";
1415

1516
type Props = PropsWithChildren<{
1617
initialTheme?: ThemePreference;
@@ -34,7 +35,7 @@ const CommonProviders: React.FC<Props> = ({
3435
initialTheme={initialTheme}
3536
migrateThemeFromLocalStorage={migrateThemeFromLocalStorage}
3637
>
37-
{children}
38+
<RealtimeProvider>{children}</RealtimeProvider>
3839
</ThemeProvider>
3940
</AppLoginPopupProvider>
4041
</Suspense>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import { listenChannel } from "@/lib/pusher/pusher.client";
4+
import { useSession } from "@/store/session.atom";
5+
import { useQueryClient } from "@tanstack/react-query";
6+
import React, { PropsWithChildren, useEffect } from "react";
7+
8+
/**
9+
* Subscribes to the authenticated user's private Pusher channel
10+
* (`private-user.{userId}`) and invalidates TanStack Query caches
11+
* when realtime events arrive.
12+
*
13+
* Phase 1 — Notifications:
14+
* event `notification.new` → invalidate `my-notifications` and
15+
* `unread-notification-count`.
16+
*
17+
* This component is a no-op when Pusher is not configured
18+
* (NEXT_PUBLIC_PUSHER_APP_KEY is absent) or when the user is not
19+
* signed in.
20+
*/
21+
export function RealtimeProvider({ children }: PropsWithChildren) {
22+
const session = useSession();
23+
const queryClient = useQueryClient();
24+
25+
const userId = session?.user?.id;
26+
27+
useEffect(() => {
28+
if (!userId) return;
29+
30+
const channelName = `private-user.${userId}`;
31+
return listenChannel(channelName, "notification.new", () => {
32+
queryClient.invalidateQueries({ queryKey: ["my-notifications"] });
33+
queryClient.invalidateQueries({
34+
queryKey: ["unread-notification-count"],
35+
});
36+
});
37+
}, [userId, queryClient]);
38+
39+
return <>{children}</>;
40+
}

src/env.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ export const env = createEnv({
2323
// Inngest
2424
INNGEST_EVENT_KEY: z.string().optional(),
2525
INNGEST_SIGNING_KEY: z.string().optional(),
26+
27+
// Pusher / Soketi (server-side)
28+
PUSHER_WS_HOST: z.string().min(1),
29+
PUSHER_APP_ID: z.string().min(1),
30+
PUSHER_APP_KEY: z.string().min(1),
31+
PUSHER_APP_SECRET: z.string().min(1),
2632
},
2733
client: {
2834
NEXT_PUBLIC_MEILISEARCH_API_HOST: z.url(),
2935
NEXT_PUBLIC_MEILISEARCH_SEARCH_API_KEY: z.string(),
36+
37+
// Pusher / Soketi (client-side)
38+
NEXT_PUBLIC_PUSHER_APP_KEY: z.string().min(1),
39+
NEXT_PUBLIC_PUSHER_WS_HOST: z.string().min(1),
3040
},
3141
runtimeEnv: {
3242
NODE_ENV: process.env.NODE_ENV,
@@ -50,6 +60,14 @@ export const env = createEnv({
5060

5161
INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY,
5262
INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY,
63+
64+
PUSHER_APP_ID: process.env.PUSHER_APP_ID,
65+
PUSHER_APP_KEY: process.env.PUSHER_APP_KEY,
66+
PUSHER_APP_SECRET: process.env.PUSHER_APP_SECRET,
67+
PUSHER_WS_HOST: process.env.PUSHER_WS_HOST,
68+
69+
NEXT_PUBLIC_PUSHER_APP_KEY: process.env.NEXT_PUBLIC_PUSHER_APP_KEY,
70+
NEXT_PUBLIC_PUSHER_WS_HOST: process.env.NEXT_PUBLIC_PUSHER_WS_HOST,
5371
},
5472
onValidationError(issues: readonly StandardSchemaV1.Issue[]) {
5573
console.error("❌ Invalid environment variables:", issues);

src/lib/inngest.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { persistenceRepository } from "@/backend/persistence/persistence-repositories";
88
import { ActionException } from "@/backend/services/RepositoryException";
99
import { buildPersistableNotification } from "@/backend/services/notifications.payload";
10+
import { publishMessage } from "@/lib/pusher/pusher.server";
1011
import { deleteExpiredArticles } from "@/backend/services/article-cleanup-service";
1112

1213
const notificationPayloadSchema = z.object({
@@ -188,6 +189,12 @@ export const persistNotificationFn = inngest.createFunction(
188189
},
189190
]);
190191

192+
// Broadcast a lightweight signal so the recipient's browser can invalidate
193+
// its TanStack Query caches without polling.
194+
await publishMessage(`private-user.${data.recipient_id}`, "notification.new", {
195+
scope: "notifications",
196+
});
197+
191198
return { success: true };
192199
},
193200
);

0 commit comments

Comments
 (0)