Skip to content
This repository was archived by the owner on Sep 16, 2026. It is now read-only.

Commit 30e1694

Browse files
committed
Add enhanced share previews
1 parent e7856f4 commit 30e1694

11 files changed

Lines changed: 886 additions & 1 deletion

File tree

apps/server/convex/chatShares.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { v } from "convex/values";
2+
import type { Id } from "./_generated/dataModel";
3+
import type { MutationCtx, QueryCtx } from "./_generated/server";
4+
import { mutation, query } from "./_generated/server";
5+
import { assertOwnsChat } from "./chats";
6+
import { requireAuthUserId } from "./lib/auth";
7+
8+
const shareDoc = v.object({
9+
_id: v.id("chatShares"),
10+
_creationTime: v.number(),
11+
userId: v.id("users"),
12+
chatId: v.id("chats"),
13+
shareId: v.string(),
14+
createdAt: v.number(),
15+
updatedAt: v.number(),
16+
revokedAt: v.optional(v.number()),
17+
});
18+
19+
const sharedMessageDoc = v.object({
20+
id: v.string(),
21+
role: v.string(),
22+
content: v.string(),
23+
createdAt: v.number(),
24+
});
25+
26+
const sharedPreviewDoc = v.object({
27+
shareId: v.string(),
28+
title: v.string(),
29+
firstUserPrompt: v.union(v.string(), v.null()),
30+
firstAssistantResponse: v.union(v.string(), v.null()),
31+
});
32+
33+
const sharedChatDoc = v.object({
34+
shareId: v.string(),
35+
title: v.string(),
36+
firstUserPrompt: v.union(v.string(), v.null()),
37+
firstAssistantResponse: v.union(v.string(), v.null()),
38+
messages: v.array(sharedMessageDoc),
39+
});
40+
41+
const MAX_PUBLIC_MESSAGES = 300;
42+
43+
function makeShareId() {
44+
const token = crypto.randomUUID().replaceAll("-", "");
45+
return token.slice(0, 22);
46+
}
47+
48+
async function generateUniqueShareId(ctx: MutationCtx): Promise<string> {
49+
for (let i = 0; i < 5; i++) {
50+
const shareId = makeShareId();
51+
const existing = await ctx.db
52+
.query("chatShares")
53+
.withIndex("by_share_id", (q) => q.eq("shareId", shareId))
54+
.first();
55+
if (!existing) return shareId;
56+
}
57+
throw new Error("Unable to generate share token");
58+
}
59+
60+
async function getActiveShareForChat(
61+
ctx: QueryCtx | MutationCtx,
62+
userId: Id<"users">,
63+
chatId: Id<"chats">,
64+
) {
65+
return ctx.db
66+
.query("chatShares")
67+
.withIndex("by_user_chat_revoked_updated", (q) =>
68+
q.eq("userId", userId).eq("chatId", chatId).eq("revokedAt", undefined),
69+
)
70+
.order("desc")
71+
.first();
72+
}
73+
74+
function normalizePreviewText(text: string) {
75+
return text.replace(/\s+/g, " ").trim();
76+
}
77+
78+
function buildPreview(messages: Array<{ role: string; content: string }>) {
79+
let firstUserPrompt: string | null = null;
80+
let firstAssistantResponse: string | null = null;
81+
82+
for (const message of messages) {
83+
const content = normalizePreviewText(message.content);
84+
if (!content) continue;
85+
if (!firstUserPrompt && message.role === "user") {
86+
firstUserPrompt = content;
87+
continue;
88+
}
89+
if (!firstAssistantResponse && message.role === "assistant") {
90+
firstAssistantResponse = content;
91+
}
92+
if (firstUserPrompt && firstAssistantResponse) break;
93+
}
94+
95+
return { firstUserPrompt, firstAssistantResponse };
96+
}
97+
98+
export const getByChat = query({
99+
args: {
100+
chatId: v.id("chats"),
101+
userId: v.id("users"),
102+
},
103+
returns: v.union(shareDoc, v.null()),
104+
handler: async (ctx, args) => {
105+
const userId = await requireAuthUserId(ctx, args.userId);
106+
const chat = await assertOwnsChat(ctx, args.chatId, userId);
107+
if (!chat) return null;
108+
return getActiveShareForChat(ctx, userId, args.chatId);
109+
},
110+
});
111+
112+
export const createOrGet = mutation({
113+
args: {
114+
chatId: v.id("chats"),
115+
userId: v.id("users"),
116+
},
117+
returns: v.object({
118+
shareId: v.string(),
119+
createdAt: v.number(),
120+
}),
121+
handler: async (ctx, args) => {
122+
const userId = await requireAuthUserId(ctx, args.userId);
123+
const chat = await assertOwnsChat(ctx, args.chatId, userId);
124+
if (!chat) {
125+
throw new Error("Chat not found");
126+
}
127+
128+
const existing = await getActiveShareForChat(ctx, userId, args.chatId);
129+
if (existing) {
130+
return {
131+
shareId: existing.shareId,
132+
createdAt: existing.createdAt,
133+
};
134+
}
135+
136+
const now = Date.now();
137+
const shareId = await generateUniqueShareId(ctx);
138+
139+
await ctx.db.insert("chatShares", {
140+
userId,
141+
chatId: args.chatId,
142+
shareId,
143+
createdAt: now,
144+
updatedAt: now,
145+
});
146+
147+
return {
148+
shareId,
149+
createdAt: now,
150+
};
151+
},
152+
});
153+
154+
export const revoke = mutation({
155+
args: {
156+
chatId: v.id("chats"),
157+
userId: v.id("users"),
158+
},
159+
returns: v.object({ revoked: v.boolean() }),
160+
handler: async (ctx, args) => {
161+
const userId = await requireAuthUserId(ctx, args.userId);
162+
const chat = await assertOwnsChat(ctx, args.chatId, userId);
163+
if (!chat) return { revoked: false };
164+
165+
const share = await getActiveShareForChat(ctx, userId, args.chatId);
166+
if (!share) return { revoked: false };
167+
168+
const now = Date.now();
169+
await ctx.db.patch(share._id, {
170+
revokedAt: now,
171+
updatedAt: now,
172+
});
173+
174+
return { revoked: true };
175+
},
176+
});
177+
178+
export const getPreviewByShareId = query({
179+
args: {
180+
shareId: v.string(),
181+
},
182+
returns: v.union(sharedPreviewDoc, v.null()),
183+
handler: async (ctx, args) => {
184+
const share = await ctx.db
185+
.query("chatShares")
186+
.withIndex("by_share_id", (q) => q.eq("shareId", args.shareId))
187+
.first();
188+
if (!share || share.revokedAt) return null;
189+
190+
const chat = await ctx.db.get(share.chatId);
191+
if (!chat || chat.deletedAt) return null;
192+
193+
const messagesPage = await ctx.db
194+
.query("messages")
195+
.withIndex("by_chat_not_deleted", (q) =>
196+
q.eq("chatId", chat._id).eq("deletedAt", undefined),
197+
)
198+
.order("asc")
199+
.paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES });
200+
201+
const messages = messagesPage.page
202+
.filter((message) => message.role === "user" || message.role === "assistant")
203+
.map((message) => ({
204+
role: message.role,
205+
content: message.content,
206+
}));
207+
const preview = buildPreview(messages);
208+
209+
return {
210+
shareId: share.shareId,
211+
title: chat.title,
212+
firstUserPrompt: preview.firstUserPrompt,
213+
firstAssistantResponse: preview.firstAssistantResponse,
214+
};
215+
},
216+
});
217+
218+
export const getPublicByShareId = query({
219+
args: {
220+
shareId: v.string(),
221+
},
222+
returns: v.union(sharedChatDoc, v.null()),
223+
handler: async (ctx, args) => {
224+
const share = await ctx.db
225+
.query("chatShares")
226+
.withIndex("by_share_id", (q) => q.eq("shareId", args.shareId))
227+
.first();
228+
if (!share || share.revokedAt) return null;
229+
230+
const chat = await ctx.db.get(share.chatId);
231+
if (!chat || chat.deletedAt) return null;
232+
233+
const messagesPage = await ctx.db
234+
.query("messages")
235+
.withIndex("by_chat_not_deleted", (q) =>
236+
q.eq("chatId", chat._id).eq("deletedAt", undefined),
237+
)
238+
.order("asc")
239+
.paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES });
240+
241+
const messages = messagesPage.page
242+
.filter((message) => message.role === "user" || message.role === "assistant")
243+
.map((message) => ({
244+
id: message._id,
245+
role: message.role,
246+
content: message.content,
247+
createdAt: message.createdAt,
248+
}));
249+
const preview = buildPreview(messages);
250+
251+
return {
252+
shareId: share.shareId,
253+
title: chat.title,
254+
firstUserPrompt: preview.firstUserPrompt,
255+
firstAssistantResponse: preview.firstAssistantResponse,
256+
messages,
257+
};
258+
},
259+
});

apps/server/convex/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ export default defineSchema({
6666
.index("by_user_last_message", ["userId", "lastMessageAt"])
6767
.index("by_user_not_deleted", ["userId", "deletedAt", "updatedAt"])
6868
.index("by_user_title", ["userId", "title"]),
69+
chatShares: defineTable({
70+
userId: v.id("users"),
71+
chatId: v.id("chats"),
72+
shareId: v.string(),
73+
createdAt: v.number(),
74+
updatedAt: v.number(),
75+
revokedAt: v.optional(v.number()),
76+
})
77+
.index("by_share_id", ["shareId"])
78+
.index("by_user_chat_revoked_updated", ["userId", "chatId", "revokedAt", "updatedAt"])
79+
.index("by_chat_revoked_updated", ["chatId", "revokedAt", "updatedAt"]),
6980
messages: defineTable({
7081
chatId: v.id("chats"),
7182
clientMessageId: v.optional(v.string()),

apps/web/src/components/app-sidebar.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
BulkSelectionBar,
2323
ChatContextMenu,
2424
DeleteChatDialog,
25+
ShareChatDialog,
2526
} from "./sidebar/chat-list-dialogs";
2627
import { SidebarUser } from "./sidebar/sidebar-user";
2728
import { useSidebarActions } from "./sidebar/use-sidebar-actions";
@@ -116,6 +117,11 @@ export function AppSidebar({
116117
showBulkDeleteDialog,
117118
setShowBulkDeleteDialog,
118119
isBulkDeleting,
120+
showShareDialog,
121+
setShowShareDialog,
122+
isGeneratingShare,
123+
shareChatId,
124+
shareUrl,
119125
editingChatId,
120126
editValue,
121127
setEditValue,
@@ -125,6 +131,9 @@ export function AppSidebar({
125131
handleChatContextMenu,
126132
handleQuickDelete,
127133
handleRenameFromMenu,
134+
handleShareFromMenu,
135+
handleCopyShareLink,
136+
handleNativeShare,
128137
handleStartEdit,
129138
handleCancelEdit,
130139
handleSubmitEdit,
@@ -255,6 +264,7 @@ export function AppSidebar({
255264
contextMenu={contextMenu}
256265
contextMenuElementRef={contextMenuElementRef}
257266
onRegenerateTitle={handleRegenerateTitle}
267+
onShareFromMenu={handleShareFromMenu}
258268
onRenameFromMenu={handleRenameFromMenu}
259269
onDeleteFromMenu={handleDeleteFromMenu}
260270
/>
@@ -276,6 +286,19 @@ export function AppSidebar({
276286
selectedCount={selectedChatIds.size}
277287
onBulkDelete={handleBulkDelete}
278288
/>
289+
290+
<ShareChatDialog
291+
open={showShareDialog}
292+
onOpenChange={(isOpen) => {
293+
setShowShareDialog(isOpen);
294+
}}
295+
chatTitle={chats.find((chat) => chat._id === shareChatId)?.title ?? "Shared chat"}
296+
shareUrl={shareUrl}
297+
isGenerating={isGeneratingShare}
298+
canNativeShare={typeof navigator !== "undefined" && "share" in navigator}
299+
onCopyLink={handleCopyShareLink}
300+
onNativeShare={handleNativeShare}
301+
/>
279302
</>
280303
);
281304
}

apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
44
import { createRef } from "react";
55

66
vi.mock("lucide-react", () => ({
7+
CopyIcon: () => <span data-testid="copy-icon" />,
8+
MailIcon: () => <span data-testid="mail-icon" />,
9+
MessageCircleIcon: () => <span data-testid="message-circle-icon" />,
710
PencilIcon: () => <span data-testid="pencil-icon" />,
11+
Share2Icon: () => <span data-testid="share-icon" />,
812
SparklesIcon: () => <span data-testid="sparkles-icon" />,
913
Trash2Icon: () => <span data-testid="trash-icon" />,
1014
}));
@@ -55,6 +59,7 @@ describe("ChatContextMenu", () => {
5559
const baseProps = {
5660
contextMenuElementRef: createRef<HTMLDivElement>(),
5761
onRegenerateTitle: vi.fn(),
62+
onShareFromMenu: vi.fn(),
5863
onRenameFromMenu: vi.fn(),
5964
onDeleteFromMenu: vi.fn(),
6065
};
@@ -74,6 +79,7 @@ describe("ChatContextMenu", () => {
7479
/>,
7580
);
7681
expect(screen.getByText("Regenerate name")).toBeDefined();
82+
expect(screen.getByText("Share")).toBeDefined();
7783
expect(screen.getByText("Rename")).toBeDefined();
7884
expect(screen.getByText("Delete chat")).toBeDefined();
7985
});
@@ -104,6 +110,19 @@ describe("ChatContextMenu", () => {
104110
expect(onRenameFromMenu).toHaveBeenCalled();
105111
});
106112

113+
it("calls onShareFromMenu when share button is clicked", () => {
114+
const onShareFromMenu = vi.fn();
115+
render(
116+
<ChatContextMenu
117+
{...baseProps}
118+
contextMenu={{ chatId: "chat-1", x: 100, y: 200 }}
119+
onShareFromMenu={onShareFromMenu}
120+
/>,
121+
);
122+
fireEvent.click(screen.getByText("Share"));
123+
expect(onShareFromMenu).toHaveBeenCalledWith("chat-1");
124+
});
125+
107126
it("calls onDeleteFromMenu when delete button is clicked", () => {
108127
const onDeleteFromMenu = vi.fn();
109128
render(

apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vi.mock("@server/convex/_generated/api", () => ({
3333
},
3434
messages: { getFirstUserMessage: "messages:getFirstUserMessage" },
3535
chats: { remove: "chats:remove", removeBulk: "chats:removeBulk" },
36+
chatShares: { createOrGet: "chatShares:createOrGet" },
3637
users: { hasOpenRouterKey: "users:hasOpenRouterKey" },
3738
},
3839
}));

0 commit comments

Comments
 (0)