Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type * as backgroundStream from "../backgroundStream.js";
import type * as benchmarks from "../benchmarks.js";
import type * as chatExport from "../chatExport.js";
import type * as chatFork from "../chatFork.js";
import type * as chatShares from "../chatShares.js";
import type * as chatTitle from "../chatTitle.js";
import type * as chatTitleGeneration from "../chatTitleGeneration.js";
import type * as chats from "../chats.js";
Expand Down Expand Up @@ -76,6 +77,7 @@ declare const fullApi: ApiFromModules<{
benchmarks: typeof benchmarks;
chatExport: typeof chatExport;
chatFork: typeof chatFork;
chatShares: typeof chatShares;
chatTitle: typeof chatTitle;
chatTitleGeneration: typeof chatTitleGeneration;
chats: typeof chats;
Expand Down
258 changes: 258 additions & 0 deletions apps/server/convex/chatShares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { mutation, query } from "./_generated/server";
import { assertOwnsChat } from "./chats";
import { requireAuthUserId } from "./lib/auth";

const shareDoc = v.object({
_id: v.id("chatShares"),
_creationTime: v.number(),
userId: v.id("users"),
chatId: v.id("chats"),
shareId: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
revokedAt: v.optional(v.number()),
});

const sharedMessageDoc = v.object({
id: v.string(),
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
createdAt: v.number(),
});

const sharedPreviewDoc = v.object({
shareId: v.string(),
title: v.string(),
firstUserPrompt: v.union(v.string(), v.null()),
firstAssistantResponse: v.union(v.string(), v.null()),
});

const sharedChatDoc = v.object({
shareId: v.string(),
title: v.string(),
firstUserPrompt: v.union(v.string(), v.null()),
firstAssistantResponse: v.union(v.string(), v.null()),
messages: v.array(sharedMessageDoc),
});

const MAX_PUBLIC_MESSAGES = 300;
type SharedMessage = {
id: string;
role: "user" | "assistant";
content: string;
createdAt: number;
};

function makeShareId() {
const token = crypto.randomUUID().replace(/-/g, "");
return token.slice(0, 22);
}

async function generateUniqueShareId(ctx: MutationCtx): Promise<string> {
for (let i = 0; i < 5; i++) {
const shareId = makeShareId();
const existing = await ctx.db
.query("chatShares")
.withIndex("by_share_id", (q) => q.eq("shareId", shareId))
.first();
if (!existing) return shareId;
}
throw new Error("Unable to generate share token");
}

async function getActiveShareForChat(
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
chatId: Id<"chats">,
) {
const shares = await ctx.db
.query("chatShares")
.withIndex("by_user_chat_revoked_updated", (q) => q.eq("userId", userId).eq("chatId", chatId))
.order("desc")
.collect();
return shares.find((share) => !share.revokedAt) ?? null;
}
Comment thread
leoisadev1 marked this conversation as resolved.

function normalizePreviewText(text: string) {
return text.replace(/\s+/g, " ").trim();
}

function buildPreview(messages: Array<{ role: string; content: string }>) {
let firstUserPrompt: string | null = null;
let firstAssistantResponse: string | null = null;

for (const message of messages) {
const content = normalizePreviewText(message.content);
if (!content) continue;
if (!firstUserPrompt && message.role === "user") {
firstUserPrompt = content;
continue;
}
if (!firstAssistantResponse && message.role === "assistant") {
firstAssistantResponse = content;
}
if (firstUserPrompt && firstAssistantResponse) break;
}

return { firstUserPrompt, firstAssistantResponse };
}

async function getSharedMessages(ctx: QueryCtx, chatId: Id<"chats">) {
const messagesPage = await ctx.db
.query("messages")
.withIndex("by_chat_not_deleted", (q) =>
q.eq("chatId", chatId).eq("deletedAt", undefined),
)
.order("asc")
.paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES });

return messagesPage.page.flatMap<SharedMessage>((message) => {
if (message.role !== "user" && message.role !== "assistant") {
return [];
}

return [
{
id: message._id,
role: message.role,
content: message.content,
createdAt: message.createdAt,
},
];
});
}

export const getByChat = query({
args: {
chatId: v.id("chats"),
},
returns: v.union(shareDoc, v.null()),
handler: async (ctx, args) => {
const userId = await requireAuthUserId(ctx);
const chat = await assertOwnsChat(ctx, args.chatId, userId);
if (!chat) return null;
return getActiveShareForChat(ctx, userId, args.chatId);
},
});

export const createOrGet = mutation({
args: {
chatId: v.id("chats"),
},
returns: v.object({
shareId: v.string(),
createdAt: v.number(),
}),
handler: async (ctx, args) => {
const userId = await requireAuthUserId(ctx);
const chat = await assertOwnsChat(ctx, args.chatId, userId);
if (!chat) {
throw new Error("Chat not found");
}

const existing = await getActiveShareForChat(ctx, userId, args.chatId);
if (existing) {
return {
shareId: existing.shareId,
createdAt: existing.createdAt,
};
}

const now = Date.now();
Comment thread
leoisadev1 marked this conversation as resolved.
const shareId = await generateUniqueShareId(ctx);

await ctx.db.insert("chatShares", {
userId,
chatId: args.chatId,
shareId,
createdAt: now,
updatedAt: now,
});

return {
shareId,
createdAt: now,
};
},
});

export const revoke = mutation({
args: {
chatId: v.id("chats"),
},
returns: v.object({ revoked: v.boolean() }),
handler: async (ctx, args) => {
const userId = await requireAuthUserId(ctx);
const chat = await assertOwnsChat(ctx, args.chatId, userId);
if (!chat) return { revoked: false };

const share = await getActiveShareForChat(ctx, userId, args.chatId);
if (!share) return { revoked: false };

const now = Date.now();
await ctx.db.patch(share._id, {
revokedAt: now,
updatedAt: now,
});

return { revoked: true };
},
});

export const getPreviewByShareId = query({
args: {
shareId: v.string(),
},
returns: v.union(sharedPreviewDoc, v.null()),
handler: async (ctx, args) => {
const share = await ctx.db
.query("chatShares")
.withIndex("by_share_id", (q) => q.eq("shareId", args.shareId))
.first();
if (!share || share.revokedAt) return null;

const chat = await ctx.db.get(share.chatId);
if (!chat || chat.deletedAt) return null;

const messages = await getSharedMessages(ctx, chat._id);
const preview = buildPreview(messages);

return {
shareId: share.shareId,
title: chat.title,
firstUserPrompt: preview.firstUserPrompt,
firstAssistantResponse: preview.firstAssistantResponse,
};
},
});

export const getPublicByShareId = query({
args: {
shareId: v.string(),
},
returns: v.union(sharedChatDoc, v.null()),
handler: async (ctx, args) => {
const share = await ctx.db
.query("chatShares")
.withIndex("by_share_id", (q) => q.eq("shareId", args.shareId))
.first();
if (!share || share.revokedAt) return null;

const chat = await ctx.db.get(share.chatId);
if (!chat || chat.deletedAt) return null;

const messages = await getSharedMessages(ctx, chat._id);
const preview = buildPreview(messages);

return {
shareId: share.shareId,
title: chat.title,
firstUserPrompt: preview.firstUserPrompt,
firstAssistantResponse: preview.firstAssistantResponse,
messages,
};
},
});
11 changes: 11 additions & 0 deletions apps/server/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ export default defineSchema({
.index("by_user_last_message", ["userId", "lastMessageAt"])
.index("by_user_not_deleted", ["userId", "deletedAt", "updatedAt"])
.index("by_user_title", ["userId", "title"]),
chatShares: defineTable({
userId: v.id("users"),
chatId: v.id("chats"),
shareId: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
revokedAt: v.optional(v.number()),
})
.index("by_share_id", ["shareId"])
.index("by_user_chat_revoked_updated", ["userId", "chatId", "revokedAt", "updatedAt"])
.index("by_chat_revoked_updated", ["chatId", "revokedAt", "updatedAt"]),
messages: defineTable({
chatId: v.id("chats"),
clientMessageId: v.optional(v.string()),
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/__tests__/app-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,12 @@ vi.mock('@/components/icons', () => ({
}))

vi.mock('lucide-react', () => ({
CopyIcon: () => <span data-testid="copy-icon" />,
GitForkIcon: () => <span data-testid="git-fork-icon" />,
MailIcon: () => <span data-testid="mail-icon" />,
MessageCircleIcon: () => <span data-testid="message-circle-icon" />,
PencilIcon: () => <span data-testid="pencil-icon" />,
Share2Icon: () => <span data-testid="share2-icon" />,
SparklesIcon: () => <span data-testid="sparkles-icon" />,
Trash2Icon: () => <span data-testid="trash2-icon" />,
XIcon: () => <span data-testid="x-icon" />,
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
BulkSelectionBar,
ChatContextMenu,
DeleteChatDialog,
ShareChatDialog,
} from "./sidebar/chat-list-dialogs";
import { SidebarUser } from "./sidebar/sidebar-user";
import { useSidebarActions } from "./sidebar/use-sidebar-actions";
Expand Down Expand Up @@ -116,6 +117,12 @@ export function AppSidebar({
showBulkDeleteDialog,
setShowBulkDeleteDialog,
isBulkDeleting,
showShareDialog,
setShowShareDialog,
isGeneratingShare,
isRevokingShare,
shareChatId,
shareUrl,
editingChatId,
editValue,
setEditValue,
Expand All @@ -125,6 +132,10 @@ export function AppSidebar({
handleChatContextMenu,
handleQuickDelete,
handleRenameFromMenu,
handleShareFromMenu,
handleCopyShareLink,
handleNativeShare,
handleRevokeShare,
handleStartEdit,
handleCancelEdit,
handleSubmitEdit,
Expand Down Expand Up @@ -255,6 +266,7 @@ export function AppSidebar({
contextMenu={contextMenu}
contextMenuElementRef={contextMenuElementRef}
onRegenerateTitle={handleRegenerateTitle}
onShareFromMenu={handleShareFromMenu}
onRenameFromMenu={handleRenameFromMenu}
onDeleteFromMenu={handleDeleteFromMenu}
/>
Expand All @@ -276,6 +288,21 @@ export function AppSidebar({
selectedCount={selectedChatIds.size}
onBulkDelete={handleBulkDelete}
/>

<ShareChatDialog
open={showShareDialog}
onOpenChange={(isOpen) => {
setShowShareDialog(isOpen);
}}
chatTitle={chats.find((chat) => chat._id === shareChatId)?.title ?? "Shared chat"}
shareUrl={shareUrl}
isGenerating={isGeneratingShare}
isRevoking={isRevokingShare}
canNativeShare={typeof navigator !== "undefined" && "share" in navigator}
onCopyLink={handleCopyShareLink}
onNativeShare={handleNativeShare}
onRevokeShare={handleRevokeShare}
/>
</>
);
}
Loading
Loading