Skip to content

Commit f8ac40a

Browse files
committed
feat: harden chat realtime flow and refresh supabase baseline setup
1 parent 16df6f2 commit f8ac40a

43 files changed

Lines changed: 1591 additions & 277 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ SENTRY_DNS=
1515
NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION=
1616

1717
SUPABASE_ACCESS_TOKEN=
18-
SUPABASE_PROJECT_ID=
18+
SUPABASE_PROJECT_ID=vswabkwgipyweqsabzwv

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ next-env.d.ts
4848
# supabase
4949
supabase/*
5050
!supabase/migrations
51+
!supabase/migrations_archive
52+
!supabase/migrations_archive/**
5153

5254

5355
yarn.lock

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,38 +48,62 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
4848

4949
## Database Migrations
5050

51-
Login first (if you haven't):
51+
For a brand-new Supabase project, use this flow from the repo root.
52+
53+
This repository now uses a squashed baseline migration for fresh installs:
54+
55+
- Active baseline: `supabase/migrations/20260407120000_baseline_fresh_setup.sql`
56+
57+
- Historical migrations archive: `supabase/migrations_archive/`
58+
59+
1. Login to Supabase CLI:
5260

5361
```bash
5462
npx supabase login
5563
```
5664

57-
Link the cloud project to this local one:
65+
2. Initialize local Supabase config (only if missing):
66+
67+
```bash
68+
npx supabase init
69+
```
70+
71+
3. Link this repo to your cloud project:
5872

5973
```bash
6074
npx supabase link
6175
```
6276

63-
It'll show the list of project you have select your project.
77+
You can select from the project list, or run `npx supabase link --project-ref <project-ref>`.
6478

65-
Push migrations to cloud:
79+
4. Push all migrations to the new project:
6680

6781
```bash
6882
npx supabase db push
6983
```
7084

71-
Pull migrations from cloud:
85+
On a fresh project this applies only the single baseline migration.
86+
87+
5. (Optional) Pull remote schema changes into migrations:
7288

7389
```bash
7490
npx supabase db pull
7591
```
7692

77-
Updating types (if you ever changed migrations):
93+
6. Regenerate Supabase TypeScript types after schema changes:
7894

7995
```bash
8096
npx supabase gen types typescript --project-id <project-id> --schema public > app/supabase-types.ts
8197
```
8298

99+
If you want to re-run the full migration chain on local development:
100+
101+
```bash
102+
npx supabase db reset
103+
```
104+
105+
If you need to inspect migration history, use the files in `supabase/migrations_archive/`.
106+
83107

84108
## Learn More
85109

app/components/Chat.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export default function Chat({ user }: { user: User }) {
169169
supabase,
170170
userId: user.id,
171171
showModal,
172+
globalConversationId: GLOBAL_CONVERSATION_ID,
172173
});
173174

174175
const { badgesByUserId } = useChatBadges({
@@ -197,7 +198,6 @@ export default function Chat({ user }: { user: User }) {
197198
setConversations,
198199
setParticipantMetaByConversationId,
199200
setLastSeenByUserId,
200-
setUnreadCountByConversationId,
201201
fetchUnreadCountsForConversations,
202202
markConversationAsRead,
203203
});
@@ -236,9 +236,10 @@ export default function Chat({ user }: { user: User }) {
236236

237237
const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || "";
238238

239-
const { sendMessage } = useChatMessageComposer({
239+
const { sendMessage, isSendingMessage } = useChatMessageComposer({
240240
supabase,
241241
userId: user.id,
242+
channelRef,
242243
conversationId,
243244
input,
244245
attachments,
@@ -255,6 +256,7 @@ export default function Chat({ user }: { user: User }) {
255256
const { textareaRef, handleInputChange, handleInputKeyDown } =
256257
useChatInputBehavior({
257258
input,
259+
isSendingMessage,
258260
conversationId,
259261
attachmentsCount: attachments.length,
260262
setInput,
@@ -263,10 +265,8 @@ export default function Chat({ user }: { user: User }) {
263265
maxChars: 1000,
264266
});
265267

266-
const totalUnreadCount = useMemo(
267-
() => Object.values(unreadCountByConversationId).reduce((sum, count) => sum + count, 0),
268-
[unreadCountByConversationId],
269-
);
268+
const canSendMessage =
269+
!isSendingMessage && (input.trim().length > 0 || attachments.length > 0);
270270

271271
const globalConversations = conversations.filter((c) => c.type === "global");
272272
const privateConversations = conversations
@@ -356,14 +356,7 @@ export default function Chat({ user }: { user: User }) {
356356
<div className={`w-full md:w-[300px] flex-shrink-0 border-r border-white/5 flex flex-col bg-[#0a0a1a] md:bg-transparent z-20 absolute md:relative h-full transition-transform duration-300 ${conversationId ? '-translate-x-full md:translate-x-0' : 'translate-x-0'}`}>
357357
<div className="p-5 border-b border-white/5">
358358
<div className="flex items-center justify-between mb-4">
359-
<div className="flex items-center gap-2">
360-
<h2 className="text-lg font-bold text-gray-100 tracking-tight">Message category</h2>
361-
{totalUnreadCount > 0 && (
362-
<span className="min-w-[24px] h-6 px-2 rounded-full bg-rose-500/90 text-white text-[11px] font-bold flex items-center justify-center">
363-
{totalUnreadCount > 99 ? "99+" : totalUnreadCount}
364-
</span>
365-
)}
366-
</div>
359+
<h2 className="text-lg font-bold text-gray-100 tracking-tight">Message category</h2>
367360
<button
368361
onClick={() => setShowModal(true)}
369362
className="w-8 h-8 rounded-full bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center hover:bg-indigo-500/20 transition"
@@ -566,6 +559,7 @@ export default function Chat({ user }: { user: User }) {
566559
>
567560
<button
568561
onClick={() => fileInputRef.current?.click()}
562+
disabled={isSendingMessage}
569563
className="w-10 h-10 mb-[2px] rounded-full bg-transparent hover:bg-white/10 flex items-center justify-center transition-all duration-300 flex-shrink-0 group"
570564
title="Attach file"
571565
>
@@ -596,15 +590,17 @@ export default function Chat({ user }: { user: User }) {
596590
<div className="mb-[2px] pr-1">
597591
<button
598592
onClick={sendMessage}
599-
disabled={!input.trim() && attachments.length === 0}
593+
disabled={!canSendMessage}
600594
className={`h-10 px-5 rounded-[20px] font-semibold text-[14px] flex items-center gap-2.5 transition-all duration-300 flex-shrink-0
601-
${(input.trim() || attachments.length > 0)
595+
${canSendMessage
602596
? "bg-gradient-to-r from-indigo-500 to-violet-500 hover:from-indigo-400 hover:to-violet-400 text-white shadow-md shadow-indigo-500/25 active:scale-95"
603597
: "bg-white/5 text-gray-500 cursor-not-allowed"}
604598
`}
605599
>
606-
<span className="hidden sm:inline">Send</span>
607-
<FontAwesomeIcon icon={faPaperPlane} className={`w-[14px] h-[14px] transition-transform ${(input.trim() || attachments.length > 0) ? "translate-x-0.5" : ""}`} />
600+
<span className="hidden sm:inline">
601+
{isSendingMessage ? "Sending..." : "Send"}
602+
</span>
603+
<FontAwesomeIcon icon={faPaperPlane} className={`w-[14px] h-[14px] transition-transform ${canSendMessage ? "translate-x-0.5" : ""}`} />
608604
</button>
609605
</div>
610606
</div>

app/components/chat/hooks/useActiveConversationStream.ts

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,63 @@ const getAttachmentFingerprint = (attachments: Message["attachments"] = []) =>
3939
})
4040
.join("::");
4141

42+
const EPHEMERAL_RECONCILE_WINDOW_MS = 15_000;
43+
const BROADCAST_DUPLICATE_WINDOW_MS = 1_500;
44+
45+
const isEphemeralMessageId = (messageId: string) =>
46+
messageId.startsWith("temp-") || messageId.startsWith("live-");
47+
48+
const isCreatedWithinWindow = (
49+
candidateCreatedAt: string,
50+
incomingCreatedAt: string,
51+
windowMs = EPHEMERAL_RECONCILE_WINDOW_MS,
52+
) => {
53+
const candidateTimestamp = Date.parse(candidateCreatedAt);
54+
const incomingTimestamp = Date.parse(incomingCreatedAt);
55+
56+
if (!Number.isFinite(candidateTimestamp) || !Number.isFinite(incomingTimestamp)) {
57+
return true;
58+
}
59+
60+
return Math.abs(incomingTimestamp - candidateTimestamp) <= windowMs;
61+
};
62+
63+
const normalizeAttachments = (attachments: unknown): Message["attachments"] => {
64+
if (!Array.isArray(attachments)) return [];
65+
66+
return attachments
67+
.map((rawAttachment) => {
68+
if (!rawAttachment || typeof rawAttachment !== "object") return null;
69+
70+
const attachment = rawAttachment as Record<string, unknown>;
71+
const filename =
72+
typeof attachment.filename === "string" ? attachment.filename : "";
73+
const mimetype =
74+
typeof attachment.mimetype === "string" ? attachment.mimetype : "";
75+
const publicUrl =
76+
typeof attachment.public_url === "string" ? attachment.public_url : "";
77+
const rawFilesize = attachment.filesize;
78+
const filesize =
79+
typeof rawFilesize === "number"
80+
? rawFilesize
81+
: typeof rawFilesize === "string"
82+
? Number(rawFilesize)
83+
: 0;
84+
85+
return {
86+
filename,
87+
mimetype,
88+
filesize: Number.isFinite(filesize) ? filesize : 0,
89+
public_url: publicUrl,
90+
};
91+
})
92+
.filter(
93+
(
94+
attachment,
95+
): attachment is Message["attachments"][number] => attachment !== null,
96+
);
97+
};
98+
4299
export function useActiveConversationStream({
43100
supabase,
44101
conversationId,
@@ -91,6 +148,114 @@ export function useActiveConversationStream({
91148
setRemoteTypingState(conversationId, null);
92149
},
93150
)
151+
.on(
152+
"broadcast",
153+
{
154+
event: "message",
155+
},
156+
({ payload }) => {
157+
const messagePayload = payload as {
158+
conversation_id?: string;
159+
sender_id?: string;
160+
text?: string;
161+
attachments?: unknown;
162+
created_at?: string;
163+
client_message_id?: string;
164+
};
165+
166+
if (messagePayload.conversation_id !== conversationId) return;
167+
if (!messagePayload.sender_id || messagePayload.sender_id === userId) {
168+
return;
169+
}
170+
const senderId = messagePayload.sender_id;
171+
172+
const incomingCreatedAt =
173+
typeof messagePayload.created_at === "string"
174+
? messagePayload.created_at
175+
: new Date().toISOString();
176+
const incomingAttachments = normalizeAttachments(
177+
messagePayload.attachments,
178+
);
179+
const clientMessageId =
180+
typeof messagePayload.client_message_id === "string" &&
181+
messagePayload.client_message_id.length > 0
182+
? messagePayload.client_message_id
183+
: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
184+
const liveMessageId = `live-${clientMessageId}`;
185+
186+
setMessages((prev) => {
187+
if (prev.some((message) => message.id === liveMessageId)) {
188+
return prev;
189+
}
190+
191+
const incomingText = messagePayload.text ?? "";
192+
const incomingFingerprint = getAttachmentFingerprint(incomingAttachments);
193+
194+
const hasMatchingMessage = prev.some((message) => {
195+
if (isEphemeralMessageId(message.id)) return false;
196+
if (message.sender_id !== senderId) return false;
197+
if (message.conversation_id !== conversationId) return false;
198+
if (message.text !== incomingText) return false;
199+
if (
200+
getAttachmentFingerprint(message.attachments) !== incomingFingerprint
201+
) {
202+
return false;
203+
}
204+
205+
return isCreatedWithinWindow(
206+
message.created_at,
207+
incomingCreatedAt,
208+
BROADCAST_DUPLICATE_WINDOW_MS,
209+
);
210+
});
211+
212+
if (hasMatchingMessage) {
213+
return prev;
214+
}
215+
216+
return [
217+
...prev,
218+
{
219+
id: liveMessageId,
220+
conversation_id: conversationId,
221+
sender_id: senderId,
222+
text: incomingText,
223+
attachments: incomingAttachments,
224+
created_at: incomingCreatedAt,
225+
},
226+
];
227+
});
228+
229+
void markConversationAsRead(conversationId);
230+
231+
window.setTimeout(() => {
232+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
233+
}, 100);
234+
},
235+
)
236+
.on(
237+
"broadcast",
238+
{
239+
event: "message_retract",
240+
},
241+
({ payload }) => {
242+
const retractPayload = payload as {
243+
conversation_id?: string;
244+
sender_id?: string;
245+
client_message_id?: string;
246+
};
247+
248+
if (retractPayload.conversation_id !== conversationId) return;
249+
if (retractPayload.sender_id === userId) return;
250+
if (!retractPayload.client_message_id) return;
251+
252+
const liveMessageId = `live-${retractPayload.client_message_id}`;
253+
254+
setMessages((prev) =>
255+
prev.filter((message) => message.id !== liveMessageId),
256+
);
257+
},
258+
)
94259
.on(
95260
"postgres_changes",
96261
{
@@ -105,7 +270,7 @@ export function useActiveConversationStream({
105270
conversation_id: payload.new.conversation_id,
106271
sender_id: payload.new.sender_id,
107272
text: payload.new.text,
108-
attachments: payload.new.attachments ?? [],
273+
attachments: normalizeAttachments(payload.new.attachments),
109274
created_at: payload.new.created_at,
110275
};
111276

@@ -119,12 +284,20 @@ export function useActiveConversationStream({
119284
);
120285

121286
const optimisticMessageIndex = prev.findIndex((message) => {
122-
if (!message.id.startsWith("temp-")) return false;
287+
if (!isEphemeralMessageId(message.id)) return false;
123288
if (message.sender_id !== incomingMessage.sender_id) return false;
124289
if (message.conversation_id !== incomingMessage.conversation_id) {
125290
return false;
126291
}
127292
if (message.text !== incomingMessage.text) return false;
293+
if (
294+
!isCreatedWithinWindow(
295+
message.created_at,
296+
incomingMessage.created_at,
297+
)
298+
) {
299+
return false;
300+
}
128301

129302
return (
130303
getAttachmentFingerprint(message.attachments) ===

0 commit comments

Comments
 (0)