From 16ff5959266d2ee52d76b661529995eba6928d11 Mon Sep 17 00:00:00 2001 From: Aidan Bleser <117548273+ieedan@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:23:26 -0500 Subject: [PATCH] shareable chats (#23) --- README.md | 2 +- src/lib/backend/convex/conversations.ts | 31 ++++++- src/lib/backend/convex/schema.ts | 1 + .../ui/copy-button/copy-button.svelte | 5 +- src/routes/chat/+layout.svelte | 81 +++++++++++++++++++ 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 10d03dc..108fd68 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ TODO: add instructions - [ ] Chat branching - [ ] Regenerate message - ~[ ] Image generation~ -- [ ] Chat sharing +- [x] Chat sharing - [ ] 404 page/redirect - ~[ ] Test link with free credits~ - [x] Cursor-like Rules (@ieedan's idea!) diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index 436b9e8..a12efe3 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -53,7 +53,7 @@ export const getById = query({ const conversation = await ctx.db.get(args.conversation_id); - if (!conversation || conversation.user_id !== session.userId) { + if (!conversation || (conversation.user_id !== session.userId && !conversation.public)) { throw new Error('Conversation not found or unauthorized'); } @@ -80,6 +80,7 @@ export const create = mutation({ user_id: session.userId as any, updated_at: Date.now(), generating: true, + public: false, }); return res; @@ -124,6 +125,7 @@ export const createAndAddMessage = mutation({ user_id: session.userId as any, updated_at: Date.now(), generating: true, + public: false, }); const messageId = await ctx.runMutation(api.messages.create, { @@ -210,9 +212,7 @@ export const updateCostUsd = mutation({ session_token: args.session_token, }); - if (!session) { - throw new Error('Unauthorized'); - } + if (!session) throw new Error('Unauthorized'); // Verify the conversation belongs to the user const conversation = await ctx.db.get(args.conversation_id); @@ -226,6 +226,29 @@ export const updateCostUsd = mutation({ }, }); +export const makePublic = mutation({ + args: { + conversation_id: v.id('conversations'), + session_token: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) throw new Error('Unauthorized'); + + const conversation = await ctx.db.get(args.conversation_id); + if (!conversation || conversation.user_id !== session.userId) { + throw new Error('Conversation not found or unauthorized'); + } + + await ctx.db.patch(args.conversation_id, { + public: true, + }); + }, +}); + export const togglePin = mutation({ args: { conversation_id: v.id('conversations'), diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index cf6dff3..ae218d8 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -52,6 +52,7 @@ export default defineSchema({ pinned: v.optional(v.boolean()), generating: v.optional(v.boolean()), cost_usd: v.optional(v.number()), + public: v.boolean(), }).index('by_user', ['user_id']), messages: defineTable({ conversation_id: v.string(), diff --git a/src/lib/components/ui/copy-button/copy-button.svelte b/src/lib/components/ui/copy-button/copy-button.svelte index 0ace4d2..770cef8 100644 --- a/src/lib/components/ui/copy-button/copy-button.svelte +++ b/src/lib/components/ui/copy-button/copy-button.svelte @@ -20,6 +20,7 @@ variant = 'ghost', size = 'icon', onCopy, + onclick, class: className, tabindex = -1, children, @@ -43,7 +44,9 @@ class={cn('flex items-center gap-2', className)} type="button" name="copy" - onclick={async () => { + onclick={async (e) => { + onclick?.(e); + const status = await clipboard.copy(text); onCopy?.(status); diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 8a46d42..8eea0e2 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -37,6 +37,13 @@ import ModelPicker from './model-picker.svelte'; import AppSidebar from '$lib/components/app-sidebar.svelte'; import { cn } from '$lib/utils/utils.js'; + import ShareIcon from '~icons/lucide/share'; + import { Result, ResultAsync } from 'neverthrow'; + import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js'; + import { scale } from 'svelte/transition'; + import CheckIcon from '~icons/lucide/check'; + import LockIcon from '~icons/lucide/lock'; + import LockOpenIcon from '~icons/lucide/lock-open'; const client = useConvexClient(); @@ -330,6 +337,36 @@ } } + const clipboard = new UseClipboard(); + + let sharingStatus = $derived(clipboard.status); + + async function shareConversation() { + if (currentConversationQuery.data?.public) { + clipboard.copy(page.url.toString()); + return; + } + + if (!page.params.id || !session.current?.session.token) return; + + const result = await ResultAsync.fromPromise( + client.mutation(api.conversations.makePublic, { + conversation_id: page.params.id as Id<'conversations'>, + session_token: session.current?.session.token ?? '', + }), + (e) => e + ); + + if (result.isErr()) { + sharingStatus = 'failure'; + setTimeout(() => { + sharingStatus = undefined; + }, 1000); + } + + clipboard.copy(page.url.toString()); + } + const textareaSize = new ElementSize(() => textarea); let textareaWrapper = $state(); @@ -368,8 +405,52 @@ {cmdOrCtrl} + B + + {#snippet trigger(tooltip)} +
+ {#if currentConversationQuery.data?.public} + + {:else} + + {/if} +
+ {/snippet} + {currentConversationQuery.data?.public ? 'Public' : 'Private'} +
+
+ {#if page.params.id} + + {#snippet trigger(tooltip)} + + {/snippet} + Share + + {/if} {#snippet trigger(tooltip)}