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/server/convex/chatShares.ts b/apps/server/convex/chatShares.ts new file mode 100644 index 00000000..1e59ac63 --- /dev/null +++ b/apps/server/convex/chatShares.ts @@ -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 { + 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; +} + +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((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(); + 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, + }; + }, +}); 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/__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 fd0186d5..1c835c23 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,12 @@ export function AppSidebar({ showBulkDeleteDialog, setShowBulkDeleteDialog, isBulkDeleting, + showShareDialog, + setShowShareDialog, + isGeneratingShare, + isRevokingShare, + shareChatId, + shareUrl, editingChatId, editValue, setEditValue, @@ -125,6 +132,10 @@ export function AppSidebar({ handleChatContextMenu, handleQuickDelete, handleRenameFromMenu, + handleShareFromMenu, + handleCopyShareLink, + handleNativeShare, + handleRevokeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, @@ -255,6 +266,7 @@ export function AppSidebar({ contextMenu={contextMenu} contextMenuElementRef={contextMenuElementRef} onRegenerateTitle={handleRegenerateTitle} + onShareFromMenu={handleShareFromMenu} onRenameFromMenu={handleRenameFromMenu} onDeleteFromMenu={handleDeleteFromMenu} /> @@ -276,6 +288,21 @@ export function AppSidebar({ selectedCount={selectedChatIds.size} onBulkDelete={handleBulkDelete} /> + + { + 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} + /> ); } 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..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 @@ -4,17 +4,26 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { createRef } from "react"; vi.mock("lucide-react", () => ({ + CopyIcon: () => , + MailIcon: () => , + MessageCircleIcon: () => , PencilIcon: () => , + Share2Icon: () => , SparklesIcon: () => , Trash2Icon: () => , })); 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", () => ({ @@ -44,6 +53,7 @@ import { DeleteChatDialog, BulkDeleteDialog, BulkSelectionBar, + ShareChatDialog, } from "../chat-list-dialogs"; afterEach(() => { @@ -55,6 +65,7 @@ describe("ChatContextMenu", () => { const baseProps = { contextMenuElementRef: createRef(), onRegenerateTitle: vi.fn(), + onShareFromMenu: vi.fn(), onRenameFromMenu: vi.fn(), onDeleteFromMenu: vi.fn(), }; @@ -74,6 +85,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 +116,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( @@ -231,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 76417ab0..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 @@ -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", revoke: "chatShares:revoke" }, users: { hasOpenRouterKey: "users:hasOpenRouterKey" }, }, })); @@ -76,6 +77,8 @@ afterEach(() => { cleanup(); vi.clearAllMocks(); mockSelectedChatIds.clear(); + delete (navigator as any).clipboard; + delete (navigator as any).share; }); describe("useSidebarActions - initial state", () => { @@ -221,6 +224,168 @@ 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("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", { + 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 9a6be786..a88091a8 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 { 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 +23,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 +32,7 @@ export function ChatContextMenu({ contextMenu, contextMenuElementRef, onRegenerateTitle, + onShareFromMenu, onRenameFromMenu, onDeleteFromMenu, }: ChatContextMenuProps) { @@ -43,6 +53,14 @@ export function ChatContextMenu({ Regenerate name + + + {targets.map((target) => + canShare ? ( + + ) : ( + + ), + )} + + + + + + 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..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"; @@ -41,6 +42,11 @@ 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 [isRevokingShare, setIsRevokingShare] = useState(false); + const [shareChatId, setShareChatId] = useState(null); + const [shareUrl, setShareUrl] = useState(""); const [editingChatId, setEditingChatId] = useState(null); const [editValue, setEditValue] = useState(""); const [editOriginal, setEditOriginal] = useState(""); @@ -55,6 +61,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), @@ -108,6 +115,89 @@ export function useSidebarActions({ setEditValue(chat.title); }; + const handleShareFromMenu = useCallback( + async (chatId: string) => { + if (!convexClient || !convexUser?._id) return; + const requestId = shareRequestIdRef.current + 1; + shareRequestIdRef.current = requestId; + + setContextMenu(null); + setShareChatId(chatId); + setShowShareDialog(true); + setIsGeneratingShare(true); + setShareUrl(""); + try { + const result = await convexClient.mutation(api.chatShares.createOrGet, { + chatId: chatId as Id<"chats">, + }); + if (shareRequestIdRef.current !== requestId) return; + if (typeof window !== "undefined") { + const shareOrigin = getShareOrigin({ windowOrigin: window.location.origin }); + setShareUrl(`${shareOrigin}/share/${result.shareId}`); + } + } catch (error) { + console.warn("[Chat] Failed to create share link:", error); + toast.error("Failed to create share link"); + } finally { + if (shareRequestIdRef.current === requestId) { + setIsGeneratingShare(false); + } + } + }, + [convexClient, convexUser?._id], + ); + + const handleCopyShareLink = useCallback(async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Share link copied"); + } catch (error) { + console.warn("[Chat] Failed to copy share link:", error); + toast.error("Clipboard access failed. Copy the link manually."); + } + }, [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 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">, + }); + 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); + } + }, [convexClient, convexUser?._id, shareChatId]); + const handleStartEdit = (chatId: string, title: string, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -347,6 +437,12 @@ export function useSidebarActions({ showBulkDeleteDialog, setShowBulkDeleteDialog, isBulkDeleting, + showShareDialog, + setShowShareDialog, + isGeneratingShare, + isRevokingShare, + shareChatId, + shareUrl, editingChatId, editValue, setEditValue, @@ -356,6 +452,10 @@ export function useSidebarActions({ handleChatContextMenu, handleQuickDelete, handleRenameFromMenu, + handleShareFromMenu, + handleCopyShareLink, + handleNativeShare, + handleRevokeShare, handleStartEdit, handleCancelEdit, handleSubmitEdit, 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/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..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' @@ -27,6 +28,7 @@ import { Route as ApiWorkflowGenerateTitleRouteImport } from './routes/api/workf 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', @@ -53,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', @@ -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' { @@ -303,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' @@ -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..42f1cfde --- /dev/null +++ b/apps/web/src/routes/api/og/share/$shareId.ts @@ -0,0 +1,146 @@ +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; + +function splitLines(value: string, maxChars: number, maxLines: number) { + const words = value.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + + 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) { + lines.push(truncatePreview(candidate, maxChars)); + return lines.map((line) => line.trim()).slice(0, maxLines); + } + if (current) { + lines.push(current); + current = truncatePreview(word, maxChars); + continue; + } + lines.push(truncatePreview(word, maxChars)); + } + + 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 = truncatePreview( + buildShareDescription({ title, firstUserPrompt, firstAssistantResponse }), + 96, + ); + + 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(api.chatShares.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..baefa0cd --- /dev/null +++ b/apps/web/src/routes/share/$shareId.tsx @@ -0,0 +1,141 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +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 { FALLBACK_SHARE_ORIGIN, getShareOrigin } from "@/lib/share-origin"; +import { buildShareDescription } from "@/lib/share-preview"; + +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 origin = getShareOrigin(); + + const client = getConvexServerClient(); + const shared = await client.query(api.chatShares.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_SHARE_ORIGIN}/og-image.png`; + const shareUrl = loaderData?.shareUrl ?? FALLBACK_SHARE_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 || ""} + + + ))} +
+
+
+ ); +}