shareable chats (#23)

This commit is contained in:
Aidan Bleser 2025-06-18 11:23:26 -05:00 committed by GitHub
parent b1005d7df5
commit 16ff595926
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 6 deletions

View file

@ -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!)

View file

@ -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'),

View file

@ -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(),

View file

@ -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);

View file

@ -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<HTMLDivElement>();
@ -368,8 +405,52 @@
{cmdOrCtrl} + B
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<div
class="fixed top-3 left-10 z-50 flex size-9 items-center justify-center md:top-1 md:left-auto"
{...tooltip.trigger}
>
{#if currentConversationQuery.data?.public}
<LockOpenIcon class="size-4" />
{:else}
<LockIcon class="size-4" />
{/if}
</div>
{/snippet}
{currentConversationQuery.data?.public ? 'Public' : 'Private'}
</Tooltip>
<!-- header -->
<div class="md:bg-sidebar fixed top-2 right-2 z-50 flex rounded-bl-lg p-1 md:top-0 md:right-0">
{#if page.params.id}
<Tooltip>
{#snippet trigger(tooltip)}
<Button
onClickPromise={shareConversation}
variant="ghost"
size="icon"
class="bg-sidebar size-8"
{...tooltip.trigger}
>
{#if sharingStatus === 'success'}
<div in:scale={{ duration: 1000, start: 0.85 }}>
<CheckIcon tabindex={-1} />
<span class="sr-only">Copied</span>
</div>
{:else if sharingStatus === 'failure'}
<div in:scale={{ duration: 1000, start: 0.85 }}>
<XIcon tabindex={-1} />
<span class="sr-only">Failed to copy</span>
</div>
{:else}
<ShareIcon />
{/if}
</Button>
{/snippet}
Share
</Tooltip>
{/if}
<Tooltip>
{#snippet trigger(tooltip)}
<Button variant="ghost" size="icon" class="size-8" href="/account" {...tooltip.trigger}>