From 30e169401dd9874ebf04ed0a92c581b0b93224dc Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 10 Mar 2026 12:31:59 -0400 Subject: [PATCH 1/5] Add enhanced share previews --- apps/server/convex/chatShares.ts | 259 ++++++++++++++++++ apps/server/convex/schema.ts | 11 + apps/web/src/components/app-sidebar.tsx | 23 ++ .../__tests__/chat-list-dialogs.test.tsx | 19 ++ .../__tests__/use-sidebar-actions.test.ts | 1 + .../components/sidebar/chat-list-dialogs.tsx | 123 ++++++++- .../components/sidebar/use-sidebar-actions.ts | 79 ++++++ apps/web/src/lib/share-preview.ts | 46 ++++ apps/web/src/routeTree.gen.ts | 42 +++ apps/web/src/routes/api/og/share/$shareId.ts | 133 +++++++++ apps/web/src/routes/share/$shareId.tsx | 151 ++++++++++ 11 files changed, 886 insertions(+), 1 deletion(-) create mode 100644 apps/server/convex/chatShares.ts create mode 100644 apps/web/src/lib/share-preview.ts create mode 100644 apps/web/src/routes/api/og/share/$shareId.ts create mode 100644 apps/web/src/routes/share/$shareId.tsx diff --git a/apps/server/convex/chatShares.ts b/apps/server/convex/chatShares.ts new file mode 100644 index 00000000..a910a442 --- /dev/null +++ b/apps/server/convex/chatShares.ts @@ -0,0 +1,259 @@ +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.string(), + 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; + +function makeShareId() { + const token = crypto.randomUUID().replaceAll("-", ""); + return token.slice(0, 22); +} + +async function generateUniqueShareId(ctx: MutationCtx): Promise { + 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">, +) { + return ctx.db + .query("chatShares") + .withIndex("by_user_chat_revoked_updated", (q) => + q.eq("userId", userId).eq("chatId", chatId).eq("revokedAt", undefined), + ) + .order("desc") + .first(); +} + +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 }; +} + +export const getByChat = query({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union(shareDoc, v.null()), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + 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"), + userId: v.id("users"), + }, + returns: v.object({ + shareId: v.string(), + createdAt: v.number(), + }), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + 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(); + 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"), + userId: v.id("users"), + }, + returns: v.object({ revoked: v.boolean() }), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + 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 messagesPage = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", chat._id).eq("deletedAt", undefined), + ) + .order("asc") + .paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES }); + + const messages = messagesPage.page + .filter((message) => message.role === "user" || message.role === "assistant") + .map((message) => ({ + role: message.role, + content: message.content, + })); + 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 messagesPage = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", chat._id).eq("deletedAt", undefined), + ) + .order("asc") + .paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES }); + + const messages = messagesPage.page + .filter((message) => message.role === "user" || message.role === "assistant") + .map((message) => ({ + id: message._id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + })); + const preview = buildPreview(messages); + + return { + shareId: share.shareId, + title: chat.title, + firstUserPrompt: preview.firstUserPrompt, + firstAssistantResponse: preview.firstAssistantResponse, + messages, + }; + }, +}); diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index d6c56b7b..75902b89 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -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()), diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index fd0186d5..465c438b 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -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"; @@ -116,6 +117,11 @@ export function AppSidebar({ showBulkDeleteDialog, setShowBulkDeleteDialog, isBulkDeleting, + showShareDialog, + setShowShareDialog, + isGeneratingShare, + shareChatId, + shareUrl, editingChatId, editValue, setEditValue, @@ -125,6 +131,9 @@ export function AppSidebar({ handleChatContextMenu, handleQuickDelete, handleRenameFromMenu, + handleShareFromMenu, + handleCopyShareLink, + handleNativeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, @@ -255,6 +264,7 @@ export function AppSidebar({ contextMenu={contextMenu} contextMenuElementRef={contextMenuElementRef} onRegenerateTitle={handleRegenerateTitle} + onShareFromMenu={handleShareFromMenu} onRenameFromMenu={handleRenameFromMenu} onDeleteFromMenu={handleDeleteFromMenu} /> @@ -276,6 +286,19 @@ export function AppSidebar({ selectedCount={selectedChatIds.size} onBulkDelete={handleBulkDelete} /> + + { + setShowShareDialog(isOpen); + }} + chatTitle={chats.find((chat) => chat._id === shareChatId)?.title ?? "Shared chat"} + shareUrl={shareUrl} + isGenerating={isGeneratingShare} + canNativeShare={typeof navigator !== "undefined" && "share" in navigator} + onCopyLink={handleCopyShareLink} + onNativeShare={handleNativeShare} + /> ); } diff --git a/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx b/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx index cd260c18..e0449bb2 100644 --- a/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx +++ b/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx @@ -4,7 +4,11 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { createRef } from "react"; vi.mock("lucide-react", () => ({ + CopyIcon: () => , + MailIcon: () => , + MessageCircleIcon: () => , PencilIcon: () => , + Share2Icon: () => , SparklesIcon: () => , Trash2Icon: () => , })); @@ -55,6 +59,7 @@ describe("ChatContextMenu", () => { const baseProps = { contextMenuElementRef: createRef(), onRegenerateTitle: vi.fn(), + onShareFromMenu: vi.fn(), onRenameFromMenu: vi.fn(), onDeleteFromMenu: vi.fn(), }; @@ -74,6 +79,7 @@ describe("ChatContextMenu", () => { />, ); expect(screen.getByText("Regenerate name")).toBeDefined(); + expect(screen.getByText("Share")).toBeDefined(); expect(screen.getByText("Rename")).toBeDefined(); expect(screen.getByText("Delete chat")).toBeDefined(); }); @@ -104,6 +110,19 @@ describe("ChatContextMenu", () => { expect(onRenameFromMenu).toHaveBeenCalled(); }); + it("calls onShareFromMenu when share button is clicked", () => { + const onShareFromMenu = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Share")); + expect(onShareFromMenu).toHaveBeenCalledWith("chat-1"); + }); + it("calls onDeleteFromMenu when delete button is clicked", () => { const onDeleteFromMenu = vi.fn(); render( diff --git a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts index 76417ab0..7b61e2de 100644 --- a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts +++ b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts @@ -33,6 +33,7 @@ vi.mock("@server/convex/_generated/api", () => ({ }, messages: { getFirstUserMessage: "messages:getFirstUserMessage" }, chats: { remove: "chats:remove", removeBulk: "chats:removeBulk" }, + chatShares: { createOrGet: "chatShares:createOrGet" }, users: { hasOpenRouterKey: "users:hasOpenRouterKey" }, }, })); diff --git a/apps/web/src/components/sidebar/chat-list-dialogs.tsx b/apps/web/src/components/sidebar/chat-list-dialogs.tsx index 9a6be786..e3370ffd 100644 --- a/apps/web/src/components/sidebar/chat-list-dialogs.tsx +++ b/apps/web/src/components/sidebar/chat-list-dialogs.tsx @@ -1,4 +1,4 @@ -import { PencilIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { CopyIcon, MailIcon, MessageCircleIcon, PencilIcon, Share2Icon, SparklesIcon, Trash2Icon } from "lucide-react"; import { Button } from "../ui/button"; import { AlertDialog, @@ -15,6 +15,7 @@ export interface ChatContextMenuProps { contextMenu: { chatId: string; x: number; y: number } | null; contextMenuElementRef: React.RefObject; onRegenerateTitle: (chatId: string) => void; + onShareFromMenu: (chatId: string) => void; onRenameFromMenu: () => void; onDeleteFromMenu: (chatId: string) => void; } @@ -23,6 +24,7 @@ export function ChatContextMenu({ contextMenu, contextMenuElementRef, onRegenerateTitle, + onShareFromMenu, onRenameFromMenu, onDeleteFromMenu, }: ChatContextMenuProps) { @@ -43,6 +45,14 @@ export function ChatContextMenu({ Regenerate name + + + {targets.map((target) => ( + + ))} + + + + + Close + + + + ); +} + export interface DeleteChatDialogProps { deleteChatId: string | null; deleteChat: { title: string } | null | undefined; diff --git a/apps/web/src/components/sidebar/use-sidebar-actions.ts b/apps/web/src/components/sidebar/use-sidebar-actions.ts index f3e250e1..9660c8ce 100644 --- a/apps/web/src/components/sidebar/use-sidebar-actions.ts +++ b/apps/web/src/components/sidebar/use-sidebar-actions.ts @@ -8,6 +8,8 @@ import { useBulkSelectionStore } from "@/stores/bulk-selection"; import type { MouseEvent } from "react"; import type { ChatItem } from "./chat-list"; +const chatSharesApi = (api as any).chatShares; + const CONTEXT_MENU_PADDING = 12; interface UseSidebarActionsParams { @@ -41,6 +43,10 @@ export function useSidebarActions({ const [deleteChatId, setDeleteChatId] = useState(null); const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); + const [showShareDialog, setShowShareDialog] = useState(false); + const [isGeneratingShare, setIsGeneratingShare] = useState(false); + const [shareChatId, setShareChatId] = useState(null); + const [shareUrl, setShareUrl] = useState(""); const [editingChatId, setEditingChatId] = useState(null); const [editValue, setEditValue] = useState(""); const [editOriginal, setEditOriginal] = useState(""); @@ -108,6 +114,71 @@ export function useSidebarActions({ setEditValue(chat.title); }; + const handleShareFromMenu = useCallback( + async (chatId: string) => { + if (!convexClient || !convexUser?._id) return; + + setContextMenu(null); + setShareChatId(chatId); + setShowShareDialog(true); + setIsGeneratingShare(true); + setShareUrl(""); + try { + const result = await convexClient.mutation(chatSharesApi.createOrGet, { + chatId: chatId as Id<"chats">, + userId: convexUser._id, + }); + if (typeof window !== "undefined") { + setShareUrl(`${window.location.origin}/share/${result.shareId}`); + } + } catch (error) { + console.warn("[Chat] Failed to create share link:", error); + toast.error("Failed to create share link"); + } finally { + setIsGeneratingShare(false); + } + }, + [convexUser?._id], + ); + + const handleCopyShareLink = useCallback(async () => { + if (!shareUrl) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(shareUrl); + } else { + const input = document.createElement("textarea"); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + toast.success("Share link copied"); + } catch (error) { + console.warn("[Chat] Failed to copy share link:", error); + toast.error("Failed to copy share link"); + } + }, [shareUrl]); + + const handleNativeShare = useCallback(async () => { + if (!shareUrl || typeof navigator === "undefined" || !("share" in navigator)) return; + const title = chats.find((chat) => chat._id === shareChatId)?.title ?? "Shared chat"; + try { + await navigator.share({ + title, + text: `Check out this shared chat: ${title}`, + url: shareUrl, + }); + } catch (error) { + // AbortError is expected when user closes native share sheet. + if (!(error instanceof DOMException && error.name === "AbortError")) { + console.warn("[Chat] Native share failed:", error); + toast.error("Native share failed"); + } + } + }, [chats, shareChatId, shareUrl]); + const handleStartEdit = (chatId: string, title: string, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -347,6 +418,11 @@ export function useSidebarActions({ showBulkDeleteDialog, setShowBulkDeleteDialog, isBulkDeleting, + showShareDialog, + setShowShareDialog, + isGeneratingShare, + shareChatId, + shareUrl, editingChatId, editValue, setEditValue, @@ -356,6 +432,9 @@ export function useSidebarActions({ handleChatContextMenu, handleQuickDelete, handleRenameFromMenu, + handleShareFromMenu, + handleCopyShareLink, + handleNativeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, diff --git a/apps/web/src/lib/share-preview.ts b/apps/web/src/lib/share-preview.ts new file mode 100644 index 00000000..d6d65072 --- /dev/null +++ b/apps/web/src/lib/share-preview.ts @@ -0,0 +1,46 @@ +const ELLIPSIS = "..."; + +function normalize(value: string | null | undefined) { + if (!value) return ""; + return value.replace(/\s+/g, " ").trim(); +} + +export function truncatePreview(value: string | null | undefined, maxLength: number) { + const normalized = normalize(value); + if (!normalized) return ""; + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - ELLIPSIS.length)).trimEnd()}${ELLIPSIS}`; +} + +export function buildShareDescription({ + title, + firstUserPrompt, + firstAssistantResponse, +}: { + title: string; + firstUserPrompt?: string | null; + firstAssistantResponse?: string | null; +}) { + const user = truncatePreview(firstUserPrompt, 120); + const assistant = truncatePreview(firstAssistantResponse, 140); + + if (user && assistant) { + return `Chat: ${truncatePreview(title, 70)} • Prompt: ${user} • Response: ${assistant}`; + } + if (user) { + return `Chat: ${truncatePreview(title, 70)} • Prompt: ${user}`; + } + if (assistant) { + return `Chat: ${truncatePreview(title, 70)} • Response: ${assistant}`; + } + return `Shared chat from osschat: ${truncatePreview(title, 120)}`; +} + +export function encodeSvgText(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6c3176df..0057b757 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,10 +23,12 @@ import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as ApiTypingRouteImport } from './routes/api/typing' import { Route as ApiOpenrouterKeyRouteImport } from './routes/api/openrouter-key' import { Route as ApiModelsRouteImport } from './routes/api/models' +import { Route as ShareShareIdRouteImport } from './routes/share/$shareId' import { Route as ApiWorkflowGenerateTitleRouteImport } from './routes/api/workflow/generate-title' import { Route as ApiWorkflowExportChatRouteImport } from './routes/api/workflow/export-chat' import { Route as ApiWorkflowDeleteAccountRouteImport } from './routes/api/workflow/delete-account' import { Route as ApiWorkflowCleanupRouteImport } from './routes/api/workflow/cleanup' +import { Route as ApiOgShareShareIdRouteImport } from './routes/api/og/share/$shareId' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -98,6 +100,11 @@ const ApiModelsRoute = ApiModelsRouteImport.update({ path: '/api/models', getParentRoute: () => rootRouteImport, } as any) +const ShareShareIdRoute = ShareShareIdRouteImport.update({ + id: '/share/$shareId', + path: '/share/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const ApiWorkflowGenerateTitleRoute = ApiWorkflowGenerateTitleRouteImport.update({ id: '/api/workflow/generate-title', @@ -120,6 +127,11 @@ const ApiWorkflowCleanupRoute = ApiWorkflowCleanupRouteImport.update({ path: '/api/workflow/cleanup', getParentRoute: () => rootRouteImport, } as any) +const ApiOgShareShareIdRoute = ApiOgShareShareIdRouteImport.update({ + id: '/api/og/share/$shareId', + path: '/api/og/share/$shareId', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -136,10 +148,12 @@ export interface FileRoutesByFullPath { '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/share/$shareId': typeof ShareShareIdRoute '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute + '/api/og/share/$shareId': typeof ApiOgShareShareIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -156,10 +170,12 @@ export interface FileRoutesByTo { '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/share/$shareId': typeof ShareShareIdRoute '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute + '/api/og/share/$shareId': typeof ApiOgShareShareIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -177,10 +193,12 @@ export interface FileRoutesById { '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/share/$shareId': typeof ShareShareIdRoute '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute + '/api/og/share/$shareId': typeof ApiOgShareShareIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -199,10 +217,12 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/share/$shareId' | '/api/workflow/cleanup' | '/api/workflow/delete-account' | '/api/workflow/export-chat' | '/api/workflow/generate-title' + | '/api/og/share/$shareId' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -219,10 +239,12 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/share/$shareId' | '/api/workflow/cleanup' | '/api/workflow/delete-account' | '/api/workflow/export-chat' | '/api/workflow/generate-title' + | '/api/og/share/$shareId' id: | '__root__' | '/' @@ -239,10 +261,12 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/share/$shareId' | '/api/workflow/cleanup' | '/api/workflow/delete-account' | '/api/workflow/export-chat' | '/api/workflow/generate-title' + | '/api/og/share/$shareId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -260,10 +284,12 @@ export interface RootRouteChildren { AuthSignInRoute: typeof AuthSignInRoute CChatIdRoute: typeof CChatIdRoute OpenrouterCallbackRoute: typeof OpenrouterCallbackRoute + ShareShareIdRoute: typeof ShareShareIdRoute ApiWorkflowCleanupRoute: typeof ApiWorkflowCleanupRoute ApiWorkflowDeleteAccountRoute: typeof ApiWorkflowDeleteAccountRoute ApiWorkflowExportChatRoute: typeof ApiWorkflowExportChatRoute ApiWorkflowGenerateTitleRoute: typeof ApiWorkflowGenerateTitleRoute + ApiOgShareShareIdRoute: typeof ApiOgShareShareIdRoute } declare module '@tanstack/react-router' { @@ -317,6 +343,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CChatIdRouteImport parentRoute: typeof rootRouteImport } + '/share/$shareId': { + id: '/share/$shareId' + path: '/share/$shareId' + fullPath: '/share/$shareId' + preLoaderRoute: typeof ShareShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/auth/sign-in': { id: '/auth/sign-in' path: '/auth/sign-in' @@ -394,6 +427,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWorkflowCleanupRouteImport parentRoute: typeof rootRouteImport } + '/api/og/share/$shareId': { + id: '/api/og/share/$shareId' + path: '/api/og/share/$shareId' + fullPath: '/api/og/share/$shareId' + preLoaderRoute: typeof ApiOgShareShareIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -412,10 +452,12 @@ const rootRouteChildren: RootRouteChildren = { AuthSignInRoute: AuthSignInRoute, CChatIdRoute: CChatIdRoute, OpenrouterCallbackRoute: OpenrouterCallbackRoute, + ShareShareIdRoute: ShareShareIdRoute, ApiWorkflowCleanupRoute: ApiWorkflowCleanupRoute, ApiWorkflowDeleteAccountRoute: ApiWorkflowDeleteAccountRoute, ApiWorkflowExportChatRoute: ApiWorkflowExportChatRoute, ApiWorkflowGenerateTitleRoute: ApiWorkflowGenerateTitleRoute, + ApiOgShareShareIdRoute: ApiOgShareShareIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/og/share/$shareId.ts b/apps/web/src/routes/api/og/share/$shareId.ts new file mode 100644 index 00000000..bcbc4ae6 --- /dev/null +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -0,0 +1,133 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { api } from "@server/convex/_generated/api"; +import { getConvexServerClient } from "@/lib/convex-server"; +import { + buildShareDescription, + encodeSvgText, + truncatePreview, +} from "@/lib/share-preview"; + +const WIDTH = 1200; +const HEIGHT = 630; +const chatSharesApi = (api as any).chatShares; + +function splitLines(value: string, maxChars: number, maxLines: number) { + const words = value.split(/\s+/); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxChars) { + current = candidate; + continue; + } + if (current) { + lines.push(current); + } + current = word; + if (lines.length >= maxLines) break; + } + + if (lines.length < maxLines && current) { + lines.push(current); + } + + return lines.slice(0, maxLines); +} + +function renderLines(lines: string[], x: number, y: number, lineHeight: number) { + return lines + .map((line, index) => { + const posY = y + index * lineHeight; + return `${encodeSvgText(line)}`; + }) + .join(""); +} + +function renderSmallLines( + lines: string[], + x: number, + y: number, + lineHeight: number, + label: string, + labelColor: string, +) { + const labelText = `${encodeSvgText(label)}`; + const content = lines + .map((line, index) => { + const posY = y + 34 + index * lineHeight; + return `${encodeSvgText(line)}`; + }) + .join(""); + return `${labelText}${content}`; +} + +function buildSvg({ + title, + firstUserPrompt, + firstAssistantResponse, +}: { + title: string; + firstUserPrompt: string | null; + firstAssistantResponse: string | null; +}) { + const titleLines = splitLines(truncatePreview(title, 90) || "Shared Chat", 34, 2); + const userLines = splitLines(truncatePreview(firstUserPrompt, 220) || "No prompt preview available.", 56, 3); + const assistantLines = splitLines( + truncatePreview(firstAssistantResponse, 220) || "No response preview available.", + 56, + 3, + ); + const description = buildShareDescription({ title, firstUserPrompt, firstAssistantResponse }); + + return ` + + + + + + + + + + + + + + + + Shared on osschat + ${renderLines(titleLines, 108, 188, 46)} + ${renderSmallLines(userLines, 108, 288, 36, "Prompt", "#FBBF24")} + ${renderSmallLines(assistantLines, 108, 436, 36, "AI Response", "#34D399")} + ${encodeSvgText(description)} +`; +} + +export const Route = createFileRoute("/api/og/share/$shareId")({ + server: { + handlers: { + GET: async ({ params }) => { + const client = getConvexServerClient(); + const preview = await client.query(chatSharesApi.getPreviewByShareId, { + shareId: params.shareId, + }); + + const svg = buildSvg({ + title: preview?.title ?? "Shared Chat", + firstUserPrompt: preview?.firstUserPrompt ?? null, + firstAssistantResponse: preview?.firstAssistantResponse ?? null, + }); + + return new Response(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=86400", + }, + }); + }, + }, + }, +}); diff --git a/apps/web/src/routes/share/$shareId.tsx b/apps/web/src/routes/share/$shareId.tsx new file mode 100644 index 00000000..c6af3d47 --- /dev/null +++ b/apps/web/src/routes/share/$shareId.tsx @@ -0,0 +1,151 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; +import { api } from "@server/convex/_generated/api"; +import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; +import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; +import { Button } from "@/components/ui/button"; +import { getConvexServerClient } from "@/lib/convex-server"; +import { buildShareDescription } from "@/lib/share-preview"; + +const FALLBACK_ORIGIN = "https://osschat.dev"; +const chatSharesApi = (api as any).chatShares; + +type LoaderData = { + origin: string; + shareUrl: string; + ogImageUrl: string; + shared: { + shareId: string; + title: string; + firstUserPrompt: string | null; + firstAssistantResponse: string | null; + messages: Array<{ + id: string; + role: string; + content: string; + createdAt: number; + }>; + } | null; +}; + +const getSharedChatPageData = createServerFn({ method: "GET" }) + .validator((input: { shareId: string }) => input) + .handler(async ({ data }): Promise => { + const request = getRequest(); + const host = + request.headers.get("x-forwarded-host") || + request.headers.get("host"); + const proto = + request.headers.get("x-forwarded-proto") || + (host?.includes("localhost") ? "http" : "https"); + const origin = host ? `${proto}://${host}` : FALLBACK_ORIGIN; + + const client = getConvexServerClient(); + const shared = await client.query(chatSharesApi.getPublicByShareId, { + shareId: data.shareId, + }); + + return { + origin, + shareUrl: `${origin}/share/${data.shareId}`, + ogImageUrl: `${origin}/api/og/share/${data.shareId}`, + shared, + }; + }); + +export const Route = createFileRoute("/share/$shareId")({ + loader: ({ params }) => + getSharedChatPageData({ + data: { shareId: params.shareId }, + }), + head: ({ loaderData }) => { + const fallbackTitle = "Shared Chat - osschat"; + const title = loaderData?.shared?.title + ? `${loaderData.shared.title} - Shared Chat - osschat` + : fallbackTitle; + const description = loaderData?.shared + ? buildShareDescription({ + title: loaderData.shared.title, + firstUserPrompt: loaderData.shared.firstUserPrompt, + firstAssistantResponse: loaderData.shared.firstAssistantResponse, + }) + : "View a shared chat conversation from osschat."; + const ogImage = loaderData?.ogImageUrl ?? `${FALLBACK_ORIGIN}/og-image.png`; + const shareUrl = loaderData?.shareUrl ?? FALLBACK_ORIGIN; + + return { + meta: [ + { title }, + { name: "description", content: description }, + { name: "robots", content: "index, follow" }, + { property: "og:type", content: "article" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: shareUrl }, + { property: "og:image", content: ogImage }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: ogImage }, + ], + links: [{ rel: "canonical", href: shareUrl }], + }; + }, + component: SharedChatPage, +}); + +function SharedChatPage() { + const { shared } = Route.useLoaderData(); + + if (!shared) { + return ( +
+

Shared chat not found

+

+ This link may have expired, been revoked, or never existed. +

+ + + +
+ ); + } + + return ( +
+
+ + + Back to osschat + + + Open app + + +
+ +
+

Shared Chat

+

{shared.title}

+
+ +
+
+ {shared.messages.map((message) => ( + + + {message.content || ""} + + + ))} +
+
+
+ ); +} From ba6335c34448c4fc5a9f03fe07fbbdea8be1a918 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 10 Mar 2026 13:37:31 -0400 Subject: [PATCH 2/5] fix: address Greptile + Cubic AI + Macroscope review feedback (iteration 1) Co-authored-by: cubic[bot] Co-authored-by: greptile-apps[bot] Co-authored-by: macroscope-app[bot] --- apps/server/convex/_generated/api.d.ts | 2 + .../components/__tests__/app-sidebar.test.tsx | 4 + apps/web/src/components/app-sidebar.tsx | 4 + .../__tests__/chat-list-dialogs.test.tsx | 73 +++++++++- .../__tests__/use-sidebar-actions.test.ts | 131 +++++++++++++++++- .../components/sidebar/chat-list-dialogs.tsx | 73 +++++++--- .../components/sidebar/use-sidebar-actions.ts | 43 ++++-- apps/web/src/routeTree.gen.ts | 26 ++-- apps/web/src/routes/api/og/share/$shareId.ts | 8 +- apps/web/src/routes/share/$shareId.tsx | 16 +-- 10 files changed, 317 insertions(+), 63 deletions(-) diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index adf2dfad..bf01e3cd 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -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"; @@ -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; diff --git a/apps/web/src/components/__tests__/app-sidebar.test.tsx b/apps/web/src/components/__tests__/app-sidebar.test.tsx index ef73592c..af938e37 100644 --- a/apps/web/src/components/__tests__/app-sidebar.test.tsx +++ b/apps/web/src/components/__tests__/app-sidebar.test.tsx @@ -140,8 +140,12 @@ vi.mock('@/components/icons', () => ({ })) vi.mock('lucide-react', () => ({ + CopyIcon: () => , GitForkIcon: () => , + MailIcon: () => , + MessageCircleIcon: () => , PencilIcon: () => , + Share2Icon: () => , SparklesIcon: () => , Trash2Icon: () => , XIcon: () => , diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 465c438b..1c835c23 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -120,6 +120,7 @@ export function AppSidebar({ showShareDialog, setShowShareDialog, isGeneratingShare, + isRevokingShare, shareChatId, shareUrl, editingChatId, @@ -134,6 +135,7 @@ export function AppSidebar({ handleShareFromMenu, handleCopyShareLink, handleNativeShare, + handleRevokeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, @@ -295,9 +297,11 @@ export function AppSidebar({ 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} /> ); diff --git a/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx b/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx index e0449bb2..32cde8ee 100644 --- a/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx +++ b/apps/web/src/components/sidebar/__tests__/chat-list-dialogs.test.tsx @@ -14,11 +14,16 @@ vi.mock("lucide-react", () => ({ })); vi.mock("@/components/ui/button", () => ({ - Button: ({ children, onClick, disabled, variant, size, className }: any) => ( - - ), + Button: ({ children, onClick, disabled, className, asChild }: any) => { + if (asChild && children) { + return children; + } + return ( + + ); + }, })); vi.mock("@/components/ui/alert-dialog", () => ({ @@ -48,6 +53,7 @@ import { DeleteChatDialog, BulkDeleteDialog, BulkSelectionBar, + ShareChatDialog, } from "../chat-list-dialogs"; afterEach(() => { @@ -250,6 +256,63 @@ describe("DeleteChatDialog", () => { }); }); +describe("ShareChatDialog", () => { + const baseProps = { + open: true, + onOpenChange: vi.fn(), + chatTitle: "Shared Chat", + shareUrl: "https://osschat.dev/share/abc123", + isGenerating: false, + isRevoking: false, + canNativeShare: true, + onCopyLink: vi.fn().mockResolvedValue(undefined), + onNativeShare: vi.fn().mockResolvedValue(undefined), + onRevokeShare: vi.fn().mockResolvedValue(undefined), + }; + + it("renders the share actions", () => { + render(); + expect(screen.getByText("Copy link")).toBeDefined(); + expect(screen.getByText("Native share")).toBeDefined(); + expect(screen.getByText("Share to X")).toBeDefined(); + expect(screen.getByText("Share to WhatsApp")).toBeDefined(); + expect(screen.getByText("Share via Email")).toBeDefined(); + expect(screen.getByText("Stop sharing")).toBeDefined(); + }); + + it("shows the generating state", () => { + render(); + expect(screen.getByText("Generating share link...")).toBeDefined(); + expect(screen.getByText("Copy link").hasAttribute("disabled")).toBe(true); + }); + + it("calls the copy handler", () => { + const onCopyLink = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText("Copy link")); + expect(onCopyLink).toHaveBeenCalled(); + }); + + it("calls the native share handler", () => { + const onNativeShare = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText("Native share")); + expect(onNativeShare).toHaveBeenCalled(); + }); + + it("calls the revoke handler", () => { + const onRevokeShare = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText("Stop sharing")); + expect(onRevokeShare).toHaveBeenCalled(); + }); + + it("disables native share when unavailable", () => { + render(); + expect(screen.getByText("Native share").hasAttribute("disabled")).toBe(true); + }); +}); + describe("BulkDeleteDialog", () => { it("does not render when open is false", () => { render( diff --git a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts index 7b61e2de..35b8147d 100644 --- a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts +++ b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts @@ -33,7 +33,7 @@ vi.mock("@server/convex/_generated/api", () => ({ }, messages: { getFirstUserMessage: "messages:getFirstUserMessage" }, chats: { remove: "chats:remove", removeBulk: "chats:removeBulk" }, - chatShares: { createOrGet: "chatShares:createOrGet" }, + chatShares: { createOrGet: "chatShares:createOrGet", revoke: "chatShares:revoke" }, users: { hasOpenRouterKey: "users:hasOpenRouterKey" }, }, })); @@ -77,6 +77,8 @@ afterEach(() => { cleanup(); vi.clearAllMocks(); mockSelectedChatIds.clear(); + delete (navigator as any).clipboard; + delete (navigator as any).share; }); describe("useSidebarActions - initial state", () => { @@ -222,6 +224,133 @@ describe("handleRenameFromMenu", () => { }); }); +describe("share handlers", () => { + it("handleShareFromMenu creates a share link", async () => { + vi.mocked(convexClient.mutation).mockResolvedValueOnce({ shareId: "share-123" } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + + expect(vi.mocked(convexClient.mutation)).toHaveBeenCalledWith( + "chatShares:createOrGet", + expect.objectContaining({ chatId: "chat-1" }), + ); + expect(result.current.shareUrl).toBe(`${window.location.origin}/share/share-123`); + }); + + it("handleShareFromMenu shows an error toast on failure", async () => { + vi.mocked(convexClient.mutation).mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useSidebarActions(defaultParams)); + + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + + expect(vi.mocked(toast.error)).toHaveBeenCalledWith("Failed to create share link"); + }); + + it("handleCopyShareLink writes to clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + vi.mocked(convexClient.mutation).mockResolvedValueOnce({ shareId: "share-123" } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + await act(async () => { + await result.current.handleCopyShareLink(); + }); + + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/share/share-123`); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Share link copied"); + }); + + it("handleCopyShareLink shows a manual-copy error when clipboard fails", async () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + configurable: true, + }); + vi.mocked(convexClient.mutation).mockResolvedValueOnce({ shareId: "share-123" } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + await act(async () => { + await result.current.handleCopyShareLink(); + }); + + expect(vi.mocked(toast.error)).toHaveBeenCalledWith("Clipboard access failed. Copy the link manually."); + }); + + it("handleNativeShare calls navigator.share", async () => { + const share = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "share", { + value: share, + configurable: true, + }); + vi.mocked(convexClient.mutation).mockResolvedValueOnce({ shareId: "share-123" } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + await act(async () => { + await result.current.handleNativeShare(); + }); + + expect(share).toHaveBeenCalledWith( + expect.objectContaining({ url: `${window.location.origin}/share/share-123` }), + ); + }); + + it("handleNativeShare ignores AbortError", async () => { + const share = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError")); + Object.defineProperty(navigator, "share", { + value: share, + configurable: true, + }); + vi.mocked(convexClient.mutation).mockResolvedValueOnce({ shareId: "share-123" } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + await act(async () => { + await result.current.handleNativeShare(); + }); + + expect(vi.mocked(toast.error)).not.toHaveBeenCalledWith("Native share failed"); + }); + + it("handleRevokeShare clears the link", async () => { + vi.mocked(convexClient.mutation) + .mockResolvedValueOnce({ shareId: "share-123" } as any) + .mockResolvedValueOnce({ revoked: true } as any); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + await result.current.handleShareFromMenu("chat-1"); + }); + await act(async () => { + await result.current.handleRevokeShare(); + }); + + expect(vi.mocked(convexClient.mutation)).toHaveBeenLastCalledWith( + "chatShares:revoke", + expect.objectContaining({ chatId: "chat-1" }), + ); + expect(result.current.shareUrl).toBe(""); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Share link revoked"); + }); +}); + describe("handleStartEdit", () => { it("sets editingChatId, editValue, and editOriginal", () => { const { result } = renderHook(() => useSidebarActions(defaultParams)); diff --git a/apps/web/src/components/sidebar/chat-list-dialogs.tsx b/apps/web/src/components/sidebar/chat-list-dialogs.tsx index e3370ffd..11b7d435 100644 --- a/apps/web/src/components/sidebar/chat-list-dialogs.tsx +++ b/apps/web/src/components/sidebar/chat-list-dialogs.tsx @@ -1,4 +1,12 @@ -import { CopyIcon, MailIcon, MessageCircleIcon, PencilIcon, Share2Icon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { + CopyIcon, + MailIcon, + MessageCircleIcon, + PencilIcon, + Share2Icon, + SparklesIcon, + Trash2Icon, +} from "lucide-react"; import { Button } from "../ui/button"; import { AlertDialog, @@ -79,9 +87,11 @@ export interface ShareChatDialogProps { chatTitle: string; shareUrl: string; isGenerating: boolean; + isRevoking: boolean; canNativeShare: boolean; onCopyLink: () => Promise; onNativeShare: () => Promise; + onRevokeShare: () => Promise; } export function ShareChatDialog({ @@ -90,10 +100,13 @@ export function ShareChatDialog({ chatTitle, shareUrl, isGenerating, + isRevoking, canNativeShare, onCopyLink, onNativeShare, + onRevokeShare, }: ShareChatDialogProps) { + const canShare = Boolean(shareUrl) && !isGenerating; const encodedUrl = encodeURIComponent(shareUrl); const encodedTitle = encodeURIComponent(chatTitle || "Shared chat"); @@ -132,7 +145,7 @@ export function ShareChatDialog({ type="button" variant="default" className="justify-start gap-2" - disabled={isGenerating || !shareUrl} + disabled={!canShare} onClick={() => { void onCopyLink(); }} @@ -144,7 +157,7 @@ export function ShareChatDialog({ type="button" variant="outline" className="justify-start gap-2" - disabled={isGenerating || !shareUrl || !canNativeShare} + disabled={!canShare || !canNativeShare} onClick={() => { void onNativeShare(); }} @@ -152,16 +165,34 @@ export function ShareChatDialog({ Native share - {targets.map((target) => ( - + ) : ( + - ))} + + ), + )} + Close diff --git a/apps/web/src/components/sidebar/use-sidebar-actions.ts b/apps/web/src/components/sidebar/use-sidebar-actions.ts index 9660c8ce..a9c43642 100644 --- a/apps/web/src/components/sidebar/use-sidebar-actions.ts +++ b/apps/web/src/components/sidebar/use-sidebar-actions.ts @@ -8,8 +8,6 @@ import { useBulkSelectionStore } from "@/stores/bulk-selection"; import type { MouseEvent } from "react"; import type { ChatItem } from "./chat-list"; -const chatSharesApi = (api as any).chatShares; - const CONTEXT_MENU_PADDING = 12; interface UseSidebarActionsParams { @@ -45,6 +43,7 @@ export function useSidebarActions({ const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [showShareDialog, setShowShareDialog] = useState(false); const [isGeneratingShare, setIsGeneratingShare] = useState(false); + const [isRevokingShare, setIsRevokingShare] = useState(false); const [shareChatId, setShareChatId] = useState(null); const [shareUrl, setShareUrl] = useState(""); const [editingChatId, setEditingChatId] = useState(null); @@ -124,7 +123,7 @@ export function useSidebarActions({ setIsGeneratingShare(true); setShareUrl(""); try { - const result = await convexClient.mutation(chatSharesApi.createOrGet, { + const result = await convexClient.mutation(api.chatShares.createOrGet, { chatId: chatId as Id<"chats">, userId: convexUser._id, }); @@ -144,20 +143,11 @@ export function useSidebarActions({ const handleCopyShareLink = useCallback(async () => { if (!shareUrl) return; try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(shareUrl); - } else { - const input = document.createElement("textarea"); - input.value = shareUrl; - document.body.appendChild(input); - input.select(); - document.execCommand("copy"); - document.body.removeChild(input); - } + await navigator.clipboard.writeText(shareUrl); toast.success("Share link copied"); } catch (error) { console.warn("[Chat] Failed to copy share link:", error); - toast.error("Failed to copy share link"); + toast.error("Clipboard access failed. Copy the link manually."); } }, [shareUrl]); @@ -179,6 +169,29 @@ export function useSidebarActions({ } }, [chats, shareChatId, shareUrl]); + const handleRevokeShare = useCallback(async () => { + if (!shareChatId || !convexClient || !convexUser?._id) return; + + setIsRevokingShare(true); + try { + const result = await convexClient.mutation(api.chatShares.revoke, { + chatId: shareChatId as Id<"chats">, + userId: convexUser._id, + }); + if (!result.revoked) { + toast.error("Share link is no longer active"); + return; + } + setShareUrl(""); + toast.success("Share link revoked"); + } catch (error) { + console.warn("[Chat] Failed to revoke share link:", error); + toast.error("Failed to revoke share link"); + } finally { + setIsRevokingShare(false); + } + }, [convexUser?._id, shareChatId]); + const handleStartEdit = (chatId: string, title: string, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -421,6 +434,7 @@ export function useSidebarActions({ showShareDialog, setShowShareDialog, isGeneratingShare, + isRevokingShare, shareChatId, shareUrl, editingChatId, @@ -435,6 +449,7 @@ export function useSidebarActions({ handleShareFromMenu, handleCopyShareLink, handleNativeShare, + handleRevokeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0057b757..e4cd6009 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as ShareShareIdRouteImport } from './routes/share/$shareId' import { Route as OpenrouterCallbackRouteImport } from './routes/openrouter/callback' import { Route as CChatIdRouteImport } from './routes/c/$chatId' import { Route as AuthSignInRouteImport } from './routes/auth/sign-in' @@ -23,7 +24,6 @@ import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as ApiTypingRouteImport } from './routes/api/typing' import { Route as ApiOpenrouterKeyRouteImport } from './routes/api/openrouter-key' import { Route as ApiModelsRouteImport } from './routes/api/models' -import { Route as ShareShareIdRouteImport } from './routes/share/$shareId' import { Route as ApiWorkflowGenerateTitleRouteImport } from './routes/api/workflow/generate-title' import { Route as ApiWorkflowExportChatRouteImport } from './routes/api/workflow/export-chat' import { Route as ApiWorkflowDeleteAccountRouteImport } from './routes/api/workflow/delete-account' @@ -55,6 +55,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ShareShareIdRoute = ShareShareIdRouteImport.update({ + id: '/share/$shareId', + path: '/share/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const OpenrouterCallbackRoute = OpenrouterCallbackRouteImport.update({ id: '/openrouter/callback', path: '/openrouter/callback', @@ -100,11 +105,6 @@ const ApiModelsRoute = ApiModelsRouteImport.update({ path: '/api/models', getParentRoute: () => rootRouteImport, } as any) -const ShareShareIdRoute = ShareShareIdRouteImport.update({ - id: '/share/$shareId', - path: '/share/$shareId', - getParentRoute: () => rootRouteImport, -} as any) const ApiWorkflowGenerateTitleRoute = ApiWorkflowGenerateTitleRouteImport.update({ id: '/api/workflow/generate-title', @@ -329,6 +329,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/share/$shareId': { + id: '/share/$shareId' + path: '/share/$shareId' + fullPath: '/share/$shareId' + preLoaderRoute: typeof ShareShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/openrouter/callback': { id: '/openrouter/callback' path: '/openrouter/callback' @@ -343,13 +350,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CChatIdRouteImport parentRoute: typeof rootRouteImport } - '/share/$shareId': { - id: '/share/$shareId' - path: '/share/$shareId' - fullPath: '/share/$shareId' - preLoaderRoute: typeof ShareShareIdRouteImport - parentRoute: typeof rootRouteImport - } '/auth/sign-in': { id: '/auth/sign-in' path: '/auth/sign-in' diff --git a/apps/web/src/routes/api/og/share/$shareId.ts b/apps/web/src/routes/api/og/share/$shareId.ts index bcbc4ae6..0ae0d940 100644 --- a/apps/web/src/routes/api/og/share/$shareId.ts +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -9,7 +9,6 @@ import { const WIDTH = 1200; const HEIGHT = 630; -const chatSharesApi = (api as any).chatShares; function splitLines(value: string, maxChars: number, maxLines: number) { const words = value.split(/\s+/); @@ -23,10 +22,13 @@ function splitLines(value: string, maxChars: number, maxLines: number) { continue; } if (current) { + if (lines.length === maxLines - 1) { + lines.push(candidate); + return lines.map((line) => line.trim()).slice(0, maxLines); + } lines.push(current); } current = word; - if (lines.length >= maxLines) break; } if (lines.length < maxLines && current) { @@ -110,7 +112,7 @@ export const Route = createFileRoute("/api/og/share/$shareId")({ handlers: { GET: async ({ params }) => { const client = getConvexServerClient(); - const preview = await client.query(chatSharesApi.getPreviewByShareId, { + const preview = await client.query(api.chatShares.getPreviewByShareId, { shareId: params.shareId, }); diff --git a/apps/web/src/routes/share/$shareId.tsx b/apps/web/src/routes/share/$shareId.tsx index c6af3d47..03b47cb0 100644 --- a/apps/web/src/routes/share/$shareId.tsx +++ b/apps/web/src/routes/share/$shareId.tsx @@ -1,6 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; -import { getRequest } from "@tanstack/react-start/server"; import { api } from "@server/convex/_generated/api"; import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; @@ -9,7 +8,6 @@ import { getConvexServerClient } from "@/lib/convex-server"; import { buildShareDescription } from "@/lib/share-preview"; const FALLBACK_ORIGIN = "https://osschat.dev"; -const chatSharesApi = (api as any).chatShares; type LoaderData = { origin: string; @@ -32,17 +30,13 @@ type LoaderData = { const getSharedChatPageData = createServerFn({ method: "GET" }) .validator((input: { shareId: string }) => input) .handler(async ({ data }): Promise => { - const request = getRequest(); - const host = - request.headers.get("x-forwarded-host") || - request.headers.get("host"); - const proto = - request.headers.get("x-forwarded-proto") || - (host?.includes("localhost") ? "http" : "https"); - const origin = host ? `${proto}://${host}` : FALLBACK_ORIGIN; + const origin = + process.env.VITE_APP_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) || + FALLBACK_ORIGIN; const client = getConvexServerClient(); - const shared = await client.query(chatSharesApi.getPublicByShareId, { + const shared = await client.query(api.chatShares.getPublicByShareId, { shareId: data.shareId, }); From b13cac223316ad17ffabd83dcf672e5a53df9437 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 10 Mar 2026 13:46:04 -0400 Subject: [PATCH 3/5] fix: address greptile iteration 2 feedback --- .../components/sidebar/chat-list-dialogs.tsx | 21 +++++++------------ .../components/sidebar/use-sidebar-actions.ts | 4 ++-- apps/web/src/routes/api/og/share/$shareId.ts | 17 +++++++++------ 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/sidebar/chat-list-dialogs.tsx b/apps/web/src/components/sidebar/chat-list-dialogs.tsx index 11b7d435..a88091a8 100644 --- a/apps/web/src/components/sidebar/chat-list-dialogs.tsx +++ b/apps/web/src/components/sidebar/chat-list-dialogs.tsx @@ -109,6 +109,11 @@ export function ShareChatDialog({ const canShare = Boolean(shareUrl) && !isGenerating; const encodedUrl = encodeURIComponent(shareUrl); const encodedTitle = encodeURIComponent(chatTitle || "Shared chat"); + const getTargetIcon = (label: string) => { + if (label.includes("WhatsApp")) return ; + if (label.includes("Email")) return ; + return ; + }; const targets = [ { @@ -175,13 +180,7 @@ export function ShareChatDialog({ asChild > - {target.label.includes("WhatsApp") ? ( - - ) : target.label.includes("Email") ? ( - - ) : ( - - )} + {getTargetIcon(target.label)} {target.label} @@ -193,13 +192,7 @@ export function ShareChatDialog({ className="justify-start gap-2" disabled > - {target.label.includes("WhatsApp") ? ( - - ) : target.label.includes("Email") ? ( - - ) : ( - - )} + {getTargetIcon(target.label)} {target.label} ), diff --git a/apps/web/src/components/sidebar/use-sidebar-actions.ts b/apps/web/src/components/sidebar/use-sidebar-actions.ts index a9c43642..2a2dbaf9 100644 --- a/apps/web/src/components/sidebar/use-sidebar-actions.ts +++ b/apps/web/src/components/sidebar/use-sidebar-actions.ts @@ -137,7 +137,7 @@ export function useSidebarActions({ setIsGeneratingShare(false); } }, - [convexUser?._id], + [convexClient, convexUser?._id], ); const handleCopyShareLink = useCallback(async () => { @@ -190,7 +190,7 @@ export function useSidebarActions({ } finally { setIsRevokingShare(false); } - }, [convexUser?._id, shareChatId]); + }, [convexClient, convexUser?._id, shareChatId]); const handleStartEdit = (chatId: string, title: string, event: React.MouseEvent) => { event.preventDefault(); diff --git a/apps/web/src/routes/api/og/share/$shareId.ts b/apps/web/src/routes/api/og/share/$shareId.ts index 0ae0d940..8edd77ec 100644 --- a/apps/web/src/routes/api/og/share/$shareId.ts +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -21,14 +21,16 @@ function splitLines(value: string, maxChars: number, maxLines: number) { current = candidate; continue; } + if (lines.length === maxLines - 1) { + lines.push(truncatePreview(candidate, maxChars)); + return lines.map((line) => line.trim()).slice(0, maxLines); + } if (current) { - if (lines.length === maxLines - 1) { - lines.push(candidate); - return lines.map((line) => line.trim()).slice(0, maxLines); - } lines.push(current); + current = word; + continue; } - current = word; + lines.push(truncatePreview(word, maxChars)); } if (lines.length < maxLines && current) { @@ -81,7 +83,10 @@ function buildSvg({ 56, 3, ); - const description = buildShareDescription({ title, firstUserPrompt, firstAssistantResponse }); + const description = truncatePreview( + buildShareDescription({ title, firstUserPrompt, firstAssistantResponse }), + 96, + ); return ` From cbd2ab5c34f1c12468a425c68a0e17e99416c6e1 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 10 Mar 2026 13:52:39 -0400 Subject: [PATCH 4/5] fix: address remaining AI review findings --- apps/server/convex/chatShares.ts | 9 ++--- .../__tests__/use-sidebar-actions.test.ts | 35 +++++++++++++++++++ .../components/sidebar/use-sidebar-actions.ts | 10 ++++-- apps/web/src/routes/api/og/share/$shareId.ts | 2 +- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/server/convex/chatShares.ts b/apps/server/convex/chatShares.ts index a910a442..bbbe0f14 100644 --- a/apps/server/convex/chatShares.ts +++ b/apps/server/convex/chatShares.ts @@ -98,11 +98,10 @@ function buildPreview(messages: Array<{ role: string; content: string }>) { export const getByChat = query({ args: { chatId: v.id("chats"), - userId: v.id("users"), }, returns: v.union(shareDoc, v.null()), handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); + const userId = await requireAuthUserId(ctx); const chat = await assertOwnsChat(ctx, args.chatId, userId); if (!chat) return null; return getActiveShareForChat(ctx, userId, args.chatId); @@ -112,14 +111,13 @@ export const getByChat = query({ export const createOrGet = mutation({ args: { chatId: v.id("chats"), - userId: v.id("users"), }, returns: v.object({ shareId: v.string(), createdAt: v.number(), }), handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); + const userId = await requireAuthUserId(ctx); const chat = await assertOwnsChat(ctx, args.chatId, userId); if (!chat) { throw new Error("Chat not found"); @@ -154,11 +152,10 @@ export const createOrGet = mutation({ export const revoke = mutation({ args: { chatId: v.id("chats"), - userId: v.id("users"), }, returns: v.object({ revoked: v.boolean() }), handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); + const userId = await requireAuthUserId(ctx); const chat = await assertOwnsChat(ctx, args.chatId, userId); if (!chat) return { revoked: false }; diff --git a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts index 35b8147d..e4967393 100644 --- a/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts +++ b/apps/web/src/components/sidebar/__tests__/use-sidebar-actions.test.ts @@ -251,6 +251,41 @@ describe("share handlers", () => { expect(vi.mocked(toast.error)).toHaveBeenCalledWith("Failed to create share link"); }); + it("handleShareFromMenu ignores stale share responses", async () => { + let resolveFirst: ((value: { shareId: string }) => void) | undefined; + let resolveSecond: ((value: { shareId: string }) => void) | undefined; + + vi.mocked(convexClient.mutation) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve as (value: { shareId: string }) => void; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve as (value: { shareId: string }) => void; + }), + ); + + const { result } = renderHook(() => useSidebarActions(defaultParams)); + await act(async () => { + void result.current.handleShareFromMenu("chat-1"); + void result.current.handleShareFromMenu("chat-2"); + }); + + await act(async () => { + resolveFirst?.({ shareId: "share-old" }); + }); + expect(result.current.shareUrl).toBe(""); + + await act(async () => { + resolveSecond?.({ shareId: "share-new" }); + }); + expect(result.current.shareUrl).toBe(`${window.location.origin}/share/share-new`); + }); + it("handleCopyShareLink writes to clipboard", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { diff --git a/apps/web/src/components/sidebar/use-sidebar-actions.ts b/apps/web/src/components/sidebar/use-sidebar-actions.ts index 2a2dbaf9..aed1f50f 100644 --- a/apps/web/src/components/sidebar/use-sidebar-actions.ts +++ b/apps/web/src/components/sidebar/use-sidebar-actions.ts @@ -60,6 +60,7 @@ export function useSidebarActions({ const deselectAll = useBulkSelectionStore((s) => s.deselectAll); const getSelectedChatIds = useBulkSelectionStore((s) => s.getSelectedChatIds); const selectionAnchorRef = useRef(null); + const shareRequestIdRef = useRef(0); const deleteChat = useMemo( () => (deleteChatId ? chats.find((chat) => chat._id === deleteChatId) : null), @@ -116,6 +117,8 @@ export function useSidebarActions({ const handleShareFromMenu = useCallback( async (chatId: string) => { if (!convexClient || !convexUser?._id) return; + const requestId = shareRequestIdRef.current + 1; + shareRequestIdRef.current = requestId; setContextMenu(null); setShareChatId(chatId); @@ -125,8 +128,8 @@ export function useSidebarActions({ try { const result = await convexClient.mutation(api.chatShares.createOrGet, { chatId: chatId as Id<"chats">, - userId: convexUser._id, }); + if (shareRequestIdRef.current !== requestId) return; if (typeof window !== "undefined") { setShareUrl(`${window.location.origin}/share/${result.shareId}`); } @@ -134,7 +137,9 @@ export function useSidebarActions({ console.warn("[Chat] Failed to create share link:", error); toast.error("Failed to create share link"); } finally { - setIsGeneratingShare(false); + if (shareRequestIdRef.current === requestId) { + setIsGeneratingShare(false); + } } }, [convexClient, convexUser?._id], @@ -176,7 +181,6 @@ export function useSidebarActions({ try { const result = await convexClient.mutation(api.chatShares.revoke, { chatId: shareChatId as Id<"chats">, - userId: convexUser._id, }); if (!result.revoked) { toast.error("Share link is no longer active"); diff --git a/apps/web/src/routes/api/og/share/$shareId.ts b/apps/web/src/routes/api/og/share/$shareId.ts index 8edd77ec..30591ac5 100644 --- a/apps/web/src/routes/api/og/share/$shareId.ts +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -27,7 +27,7 @@ function splitLines(value: string, maxChars: number, maxLines: number) { } if (current) { lines.push(current); - current = word; + current = truncatePreview(word, maxChars); continue; } lines.push(truncatePreview(word, maxChars)); From 31d60340b4b4942adc8a7a59e9dbe3197404fead Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 13 Mar 2026 22:04:28 -0400 Subject: [PATCH 5/5] fix: address Greptile + Cubic AI + Macroscope review feedback (iteration 4) Co-authored-by: cubic[bot] Co-authored-by: greptile-apps[bot] Co-authored-by: macroscope-app[bot] --- apps/server/convex/chatShares.ts | 76 ++++++++++--------- .../components/sidebar/use-sidebar-actions.ts | 4 +- .../src/lib/__tests__/share-origin.test.ts | 35 +++++++++ apps/web/src/lib/share-origin.ts | 12 +++ apps/web/src/routes/api/og/share/$shareId.ts | 20 +++-- apps/web/src/routes/share/$shareId.tsx | 12 +-- 6 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/lib/__tests__/share-origin.test.ts create mode 100644 apps/web/src/lib/share-origin.ts diff --git a/apps/server/convex/chatShares.ts b/apps/server/convex/chatShares.ts index bbbe0f14..1e59ac63 100644 --- a/apps/server/convex/chatShares.ts +++ b/apps/server/convex/chatShares.ts @@ -18,7 +18,7 @@ const shareDoc = v.object({ const sharedMessageDoc = v.object({ id: v.string(), - role: v.string(), + role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), createdAt: v.number(), }); @@ -39,9 +39,15 @@ const sharedChatDoc = v.object({ }); const MAX_PUBLIC_MESSAGES = 300; +type SharedMessage = { + id: string; + role: "user" | "assistant"; + content: string; + createdAt: number; +}; function makeShareId() { - const token = crypto.randomUUID().replaceAll("-", ""); + const token = crypto.randomUUID().replace(/-/g, ""); return token.slice(0, 22); } @@ -62,13 +68,12 @@ async function getActiveShareForChat( userId: Id<"users">, chatId: Id<"chats">, ) { - return ctx.db + const shares = await ctx.db .query("chatShares") - .withIndex("by_user_chat_revoked_updated", (q) => - q.eq("userId", userId).eq("chatId", chatId).eq("revokedAt", undefined), - ) + .withIndex("by_user_chat_revoked_updated", (q) => q.eq("userId", userId).eq("chatId", chatId)) .order("desc") - .first(); + .collect(); + return shares.find((share) => !share.revokedAt) ?? null; } function normalizePreviewText(text: string) { @@ -95,6 +100,31 @@ function buildPreview(messages: Array<{ role: string; content: string }>) { 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((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"), @@ -187,20 +217,7 @@ export const getPreviewByShareId = query({ const chat = await ctx.db.get(share.chatId); if (!chat || chat.deletedAt) return null; - const messagesPage = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", chat._id).eq("deletedAt", undefined), - ) - .order("asc") - .paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES }); - - const messages = messagesPage.page - .filter((message) => message.role === "user" || message.role === "assistant") - .map((message) => ({ - role: message.role, - content: message.content, - })); + const messages = await getSharedMessages(ctx, chat._id); const preview = buildPreview(messages); return { @@ -227,22 +244,7 @@ export const getPublicByShareId = query({ const chat = await ctx.db.get(share.chatId); if (!chat || chat.deletedAt) return null; - const messagesPage = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", chat._id).eq("deletedAt", undefined), - ) - .order("asc") - .paginate({ cursor: null, numItems: MAX_PUBLIC_MESSAGES }); - - const messages = messagesPage.page - .filter((message) => message.role === "user" || message.role === "assistant") - .map((message) => ({ - id: message._id, - role: message.role, - content: message.content, - createdAt: message.createdAt, - })); + const messages = await getSharedMessages(ctx, chat._id); const preview = buildPreview(messages); return { diff --git a/apps/web/src/components/sidebar/use-sidebar-actions.ts b/apps/web/src/components/sidebar/use-sidebar-actions.ts index aed1f50f..fe498703 100644 --- a/apps/web/src/components/sidebar/use-sidebar-actions.ts +++ b/apps/web/src/components/sidebar/use-sidebar-actions.ts @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { convexClient } from "@/lib/convex"; +import { getShareOrigin } from "@/lib/share-origin"; import { useBulkSelectionStore } from "@/stores/bulk-selection"; import type { MouseEvent } from "react"; import type { ChatItem } from "./chat-list"; @@ -131,7 +132,8 @@ export function useSidebarActions({ }); if (shareRequestIdRef.current !== requestId) return; if (typeof window !== "undefined") { - setShareUrl(`${window.location.origin}/share/${result.shareId}`); + const shareOrigin = getShareOrigin({ windowOrigin: window.location.origin }); + setShareUrl(`${shareOrigin}/share/${result.shareId}`); } } catch (error) { console.warn("[Chat] Failed to create share link:", error); diff --git a/apps/web/src/lib/__tests__/share-origin.test.ts b/apps/web/src/lib/__tests__/share-origin.test.ts new file mode 100644 index 00000000..4fb0ff6a --- /dev/null +++ b/apps/web/src/lib/__tests__/share-origin.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FALLBACK_SHARE_ORIGIN, getShareOrigin } from "../share-origin"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("getShareOrigin", () => { + it("prefers VITE_APP_URL when configured", () => { + vi.stubEnv("VITE_APP_URL", "https://app.example.com"); + expect(getShareOrigin({ windowOrigin: "http://localhost:3000" })).toBe( + "https://app.example.com", + ); + }); + + it("falls back to Vercel preview origin", () => { + vi.stubEnv("VITE_APP_URL", ""); + vi.stubEnv("VERCEL_URL", "preview.example.vercel.app"); + expect(getShareOrigin()).toBe("https://preview.example.vercel.app"); + }); + + it("uses the current window origin when no env override exists", () => { + vi.stubEnv("VITE_APP_URL", ""); + vi.stubEnv("VERCEL_URL", ""); + expect(getShareOrigin({ windowOrigin: "http://localhost:3000" })).toBe( + "http://localhost:3000", + ); + }); + + it("uses the production fallback when no origin is available", () => { + vi.stubEnv("VITE_APP_URL", ""); + vi.stubEnv("VERCEL_URL", ""); + expect(getShareOrigin()).toBe(FALLBACK_SHARE_ORIGIN); + }); +}); diff --git a/apps/web/src/lib/share-origin.ts b/apps/web/src/lib/share-origin.ts new file mode 100644 index 00000000..8913b0ca --- /dev/null +++ b/apps/web/src/lib/share-origin.ts @@ -0,0 +1,12 @@ +export const FALLBACK_SHARE_ORIGIN = "https://osschat.dev"; + +export function getShareOrigin(options?: { windowOrigin?: string }) { + const configuredOrigin = import.meta.env.VITE_APP_URL; + if (configuredOrigin) return configuredOrigin; + + if (typeof process !== "undefined" && process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + + return options?.windowOrigin || FALLBACK_SHARE_ORIGIN; +} diff --git a/apps/web/src/routes/api/og/share/$shareId.ts b/apps/web/src/routes/api/og/share/$shareId.ts index 30591ac5..42f1cfde 100644 --- a/apps/web/src/routes/api/og/share/$shareId.ts +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -11,17 +11,18 @@ const WIDTH = 1200; const HEIGHT = 630; function splitLines(value: string, maxChars: number, maxLines: number) { - const words = value.split(/\s+/); + const words = value.split(/\s+/).filter(Boolean); const lines: string[] = []; let current = ""; - for (const word of words) { + for (const rawWord of words) { + const word = truncatePreview(rawWord, maxChars); const candidate = current ? `${current} ${word}` : word; if (candidate.length <= maxChars) { current = candidate; continue; } - if (lines.length === maxLines - 1) { + if (lines.length >= maxLines - 1) { lines.push(truncatePreview(candidate, maxChars)); return lines.map((line) => line.trim()).slice(0, maxLines); } @@ -99,15 +100,20 @@ function buildSvg({ + + + - Shared on osschat - ${renderLines(titleLines, 108, 188, 46)} - ${renderSmallLines(userLines, 108, 288, 36, "Prompt", "#FBBF24")} - ${renderSmallLines(assistantLines, 108, 436, 36, "AI Response", "#34D399")} + + Shared on osschat + ${renderLines(titleLines, 108, 188, 46)} + ${renderSmallLines(userLines, 108, 288, 36, "Prompt", "#FBBF24")} + ${renderSmallLines(assistantLines, 108, 436, 36, "AI Response", "#34D399")} + ${encodeSvgText(description)} `; } diff --git a/apps/web/src/routes/share/$shareId.tsx b/apps/web/src/routes/share/$shareId.tsx index 03b47cb0..baefa0cd 100644 --- a/apps/web/src/routes/share/$shareId.tsx +++ b/apps/web/src/routes/share/$shareId.tsx @@ -5,10 +5,9 @@ import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; import { getConvexServerClient } from "@/lib/convex-server"; +import { FALLBACK_SHARE_ORIGIN, getShareOrigin } from "@/lib/share-origin"; import { buildShareDescription } from "@/lib/share-preview"; -const FALLBACK_ORIGIN = "https://osschat.dev"; - type LoaderData = { origin: string; shareUrl: string; @@ -30,10 +29,7 @@ type LoaderData = { const getSharedChatPageData = createServerFn({ method: "GET" }) .validator((input: { shareId: string }) => input) .handler(async ({ data }): Promise => { - const origin = - process.env.VITE_APP_URL || - (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) || - FALLBACK_ORIGIN; + const origin = getShareOrigin(); const client = getConvexServerClient(); const shared = await client.query(api.chatShares.getPublicByShareId, { @@ -65,8 +61,8 @@ export const Route = createFileRoute("/share/$shareId")({ firstAssistantResponse: loaderData.shared.firstAssistantResponse, }) : "View a shared chat conversation from osschat."; - const ogImage = loaderData?.ogImageUrl ?? `${FALLBACK_ORIGIN}/og-image.png`; - const shareUrl = loaderData?.shareUrl ?? FALLBACK_ORIGIN; + const ogImage = loaderData?.ogImageUrl ?? `${FALLBACK_SHARE_ORIGIN}/og-image.png`; + const shareUrl = loaderData?.shareUrl ?? FALLBACK_SHARE_ORIGIN; return { meta: [