Skip to content

Commit 18fee00

Browse files
committed
feat: Base Setup for realtime
1 parent 3d8ca30 commit 18fee00

File tree

7 files changed

+172
-20
lines changed

7 files changed

+172
-20
lines changed

.env.local.example

+4-1
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,7 @@ UNKEY_ROOT_KEY=""
9797
############################ NEXT_PUBLIC VARIABLES ############################
9898
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
9999
NEXT_PUBLIC_STORAGE_URL=http://localhost:3200
100-
NEXT_PUBLIC_PLATFORM_URL=http://localhost:3300
100+
NEXT_PUBLIC_PLATFORM_URL=http://localhost:3300
101+
NEXT_PUBLIC_REALTIME_APP_KEY=secretsecretsecret
102+
NEXT_PUBLIC_REALTIME_HOST=localhost
103+
NEXT_PUBLIC_REALTIME_PORT=3904

apps/web/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@trpc/react-query": "10.45.2",
3838
"@trpc/server": "10.45.2",
3939
"@u22n/platform": "workspace:^",
40+
"@u22n/realtime": "workspace:^",
4041
"@u22n/tiptap": "workspace:^",
4142
"@u22n/utils": "workspace:^",
4243
"@uidotdev/usehooks": "^2.4.1",

apps/web/src/app/[orgShortCode]/convo/layout.tsx

+38-9
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,50 @@
11
'use client';
2-
import { Button } from '@/src/components/shadcn-ui/button';
2+
import { useEffect } from 'react';
33
import ConvoList from './_components/convo-list';
4-
import { usePreferencesState } from '@/src/stores/preferences-store';
4+
import { useRealtime } from '@/src/providers/realtime-provider';
55

66
export default function Layout({
77
children
88
}: Readonly<{ children: React.ReactNode }>) {
9-
const {
10-
sidebarDocked,
11-
sidebarExpanded,
12-
setSidebarExpanded,
13-
setSidebarDocking
14-
} = usePreferencesState();
9+
// TODO: Implement the realtime event handlers and update query cache accordingly
10+
11+
const client = useRealtime();
12+
13+
useEffect(() => {
14+
client.on('convo:new', async ({ publicId }) => {
15+
//TODO: Handle new convo added
16+
console.info('New convo added', publicId);
17+
});
18+
19+
client.on('convo:hidden', async ({ publicId }) => {
20+
// TODO: Handle convo updated
21+
console.info('Convo Hidden', publicId);
22+
});
23+
24+
client.on('convo:deleted', async ({ publicId }) => {
25+
// TODO: Handle convo deleted
26+
console.info('Convo Delete', publicId);
27+
});
28+
29+
client.on(
30+
'convo:entry:new',
31+
async ({ convoPublicId, convoEntryPublicId }) => {
32+
// TODO: Handle new convo entry
33+
console.info('New convo entry', convoPublicId, convoEntryPublicId);
34+
}
35+
);
36+
37+
return () => {
38+
client.off('convo:new');
39+
client.off('convo:hidden');
40+
client.off('convo:deleted');
41+
client.off('convo:entry:new');
42+
};
43+
}, [client]);
44+
1545
return (
1646
<div className="grid h-full w-full grid-cols-3 gap-0">
1747
<div className="bg-red-5 col-span-1 flex w-full flex-col">
18-
{/* <Button onClick={() => setSidebarExpanded(true)}>Show</Button> */}
1948
<ConvoList />
2049
</div>
2150
<div className="col-span-2 w-full">{children}</div>

apps/web/src/app/[orgShortCode]/layout.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { buttonVariants } from '@/src/components/shadcn-ui/button';
66
import { SpinnerGap } from '@phosphor-icons/react';
77
import Link from 'next/link';
88
import { api } from '@/src/lib/trpc';
9+
import { RealtimeProvider } from '@/src/providers/realtime-provider';
910

1011
export default function Layout({
1112
children,
@@ -68,7 +69,9 @@ export default function Layout({
6869
<div className="h-full max-h-full w-fit">
6970
<Sidebar />
7071
</div>
71-
<div className="flex h-full w-full flex-row p-0">{children}</div>
72+
<div className="flex h-full w-full flex-row p-0">
73+
<RealtimeProvider>{children}</RealtimeProvider>
74+
</div>
7275
</div>
7376
</GlobalStoreProvider>
7477
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
createContext,
3+
useContext,
4+
useEffect,
5+
useMemo,
6+
type PropsWithChildren
7+
} from 'react';
8+
import RealtimeClient from '@u22n/realtime/client';
9+
import { useGlobalStore } from './global-store-provider';
10+
import { env } from 'next-runtime-env';
11+
import { toast } from 'sonner';
12+
13+
const realtimeContext = createContext<RealtimeClient | null>(null);
14+
15+
const appKey = env('NEXT_PUBLIC_REALTIME_APP_KEY')!;
16+
const host = env('NEXT_PUBLIC_REALTIME_HOST')!;
17+
const port = Number(env('NEXT_PUBLIC_REALTIME_PORT')!);
18+
const PLATFORM_URL = env('NEXT_PUBLIC_PLATFORM_URL')!;
19+
20+
export function RealtimeProvider({ children }: PropsWithChildren) {
21+
const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode);
22+
23+
const client = useMemo(
24+
() =>
25+
new RealtimeClient({
26+
appKey,
27+
host,
28+
port,
29+
authEndpoint: `${PLATFORM_URL}/realtime/auth`
30+
}),
31+
[]
32+
);
33+
34+
useEffect(() => {
35+
void client.connect({ orgShortCode }).catch(() => {
36+
toast.error(
37+
'Uninbox encountered an error while trying to connect to the realtime server'
38+
);
39+
});
40+
return () => {
41+
client.disconnect();
42+
};
43+
}, [client, orgShortCode]);
44+
45+
return (
46+
<realtimeContext.Provider value={client}>
47+
{children}
48+
</realtimeContext.Provider>
49+
);
50+
}
51+
52+
export function useRealtime() {
53+
const client = useContext(realtimeContext);
54+
if (!client) {
55+
throw new Error('useRealtime must be used within RealtimeProvider');
56+
}
57+
return client;
58+
}

packages/realtime/client.ts

+64-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import type { z } from 'zod';
44

55
export default class RealtimeClient {
66
private client: Pusher | null = null;
7+
#preConnectEventHandlers = new Map<keyof EventDataMap, Function[]>();
8+
#preConnectBroadcastHandlers = new Map<keyof EventDataMap, Function[]>();
9+
#connectionTimeout: NodeJS.Timeout | null = null;
10+
711
constructor(
812
private config: {
913
appKey: string;
@@ -12,6 +16,7 @@ export default class RealtimeClient {
1216
authEndpoint: string;
1317
}
1418
) {}
19+
1520
public async connect({ orgShortCode }: { orgShortCode: string }) {
1621
if (this.client) return;
1722
const client = new Pusher(this.config.appKey, {
@@ -44,13 +49,22 @@ export default class RealtimeClient {
4449

4550
client.signin();
4651
this.client = client;
52+
this.bindEvents();
4753
return new Promise<void>((resolve, reject) => {
54+
this.#connectionTimeout = setTimeout(() => {
55+
this.client = null;
56+
reject(new Error('Connection timeout'));
57+
}, 10000);
58+
4859
client.bind('pusher:signin_success', () => {
4960
client.unbind('pusher:signin_success');
61+
if (this.#connectionTimeout) clearTimeout(this.#connectionTimeout);
5062
resolve();
5163
});
64+
5265
client.bind('pusher:error', (e: unknown) => {
5366
this.client = null;
67+
if (this.#connectionTimeout) clearTimeout(this.#connectionTimeout);
5468
reject(e);
5569
});
5670
});
@@ -59,6 +73,7 @@ export default class RealtimeClient {
5973
public disconnect() {
6074
if (this.client) {
6175
this.client.disconnect();
76+
if (this.#connectionTimeout) clearTimeout(this.#connectionTimeout);
6277
this.client = null;
6378
}
6479
}
@@ -67,24 +82,64 @@ export default class RealtimeClient {
6782
event: T,
6883
callback: (data: z.infer<EventDataMap[T]>) => Promise<void>
6984
) {
70-
if (!this.client) return;
71-
this.client.bind(event, (e: unknown) =>
72-
callback(eventDataMaps[event].parse(e))
73-
);
85+
if (!this.client) {
86+
const existing = this.#preConnectEventHandlers.get(event) ?? [];
87+
existing.push(callback);
88+
this.#preConnectEventHandlers.set(event, existing);
89+
} else {
90+
this.client.bind(event, (e: unknown) =>
91+
callback(eventDataMaps[event].parse(e))
92+
);
93+
}
7494
}
7595

7696
public off<const T extends keyof EventDataMap>(event: T) {
77-
if (!this.client) return;
78-
this.client.unbind(event);
97+
if (!this.client) {
98+
// eslint-disable-next-line drizzle/enforce-delete-with-where
99+
this.#preConnectEventHandlers.delete(event);
100+
} else {
101+
this.client.unbind(event);
102+
}
79103
}
80104

81105
public onBroadcast<const T extends keyof EventDataMap>(
82106
event: T,
83107
callback: (data: z.infer<EventDataMap[T]>) => Promise<void>
84108
) {
109+
if (!this.client) {
110+
const existing = this.#preConnectBroadcastHandlers.get(event) ?? [];
111+
existing.push(callback);
112+
this.#preConnectBroadcastHandlers.set(event, existing);
113+
} else {
114+
this.client
115+
.subscribe('broadcasts')
116+
.bind(event, (e: unknown) => callback(eventDataMaps[event].parse(e)));
117+
}
118+
}
119+
120+
public get isConnected() {
121+
return !!this.client;
122+
}
123+
124+
private bindEvents() {
85125
if (!this.client) return;
86-
this.client
87-
.subscribe('broadcasts')
88-
.bind(event, (e: unknown) => callback(eventDataMaps[event].parse(e)));
126+
for (const [event, handlers] of this.#preConnectEventHandlers) {
127+
handlers.forEach((handler) => {
128+
if (!this.client) return;
129+
this.client.bind(event, (e: unknown) =>
130+
handler(eventDataMaps[event].parse(e))
131+
);
132+
});
133+
}
134+
for (const [event, handlers] of this.#preConnectBroadcastHandlers) {
135+
handlers.forEach((handler) => {
136+
if (!this.client) return;
137+
this.client
138+
.subscribe('broadcasts')
139+
.bind(event, (e: unknown) => handler(eventDataMaps[event].parse(e)));
140+
});
141+
}
142+
this.#preConnectEventHandlers.clear();
143+
this.#preConnectBroadcastHandlers.clear();
89144
}
90145
}

pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)