shareable chats (#23)
This commit is contained in:
parent
b1005d7df5
commit
16ff595926
5 changed files with 114 additions and 6 deletions
|
|
@ -62,7 +62,7 @@ TODO: add instructions
|
||||||
- [ ] Chat branching
|
- [ ] Chat branching
|
||||||
- [ ] Regenerate message
|
- [ ] Regenerate message
|
||||||
- ~[ ] Image generation~
|
- ~[ ] Image generation~
|
||||||
- [ ] Chat sharing
|
- [x] Chat sharing
|
||||||
- [ ] 404 page/redirect
|
- [ ] 404 page/redirect
|
||||||
- ~[ ] Test link with free credits~
|
- ~[ ] Test link with free credits~
|
||||||
- [x] Cursor-like Rules (@ieedan's idea!)
|
- [x] Cursor-like Rules (@ieedan's idea!)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const getById = query({
|
||||||
|
|
||||||
const conversation = await ctx.db.get(args.conversation_id);
|
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');
|
throw new Error('Conversation not found or unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +80,7 @@ export const create = mutation({
|
||||||
user_id: session.userId as any,
|
user_id: session.userId as any,
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
generating: true,
|
generating: true,
|
||||||
|
public: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|
@ -124,6 +125,7 @@ export const createAndAddMessage = mutation({
|
||||||
user_id: session.userId as any,
|
user_id: session.userId as any,
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
generating: true,
|
generating: true,
|
||||||
|
public: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageId = await ctx.runMutation(api.messages.create, {
|
const messageId = await ctx.runMutation(api.messages.create, {
|
||||||
|
|
@ -210,9 +212,7 @@ export const updateCostUsd = mutation({
|
||||||
session_token: args.session_token,
|
session_token: args.session_token,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) throw new Error('Unauthorized');
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the conversation belongs to the user
|
// Verify the conversation belongs to the user
|
||||||
const conversation = await ctx.db.get(args.conversation_id);
|
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({
|
export const togglePin = mutation({
|
||||||
args: {
|
args: {
|
||||||
conversation_id: v.id('conversations'),
|
conversation_id: v.id('conversations'),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export default defineSchema({
|
||||||
pinned: v.optional(v.boolean()),
|
pinned: v.optional(v.boolean()),
|
||||||
generating: v.optional(v.boolean()),
|
generating: v.optional(v.boolean()),
|
||||||
cost_usd: v.optional(v.number()),
|
cost_usd: v.optional(v.number()),
|
||||||
|
public: v.boolean(),
|
||||||
}).index('by_user', ['user_id']),
|
}).index('by_user', ['user_id']),
|
||||||
messages: defineTable({
|
messages: defineTable({
|
||||||
conversation_id: v.string(),
|
conversation_id: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
variant = 'ghost',
|
variant = 'ghost',
|
||||||
size = 'icon',
|
size = 'icon',
|
||||||
onCopy,
|
onCopy,
|
||||||
|
onclick,
|
||||||
class: className,
|
class: className,
|
||||||
tabindex = -1,
|
tabindex = -1,
|
||||||
children,
|
children,
|
||||||
|
|
@ -43,7 +44,9 @@
|
||||||
class={cn('flex items-center gap-2', className)}
|
class={cn('flex items-center gap-2', className)}
|
||||||
type="button"
|
type="button"
|
||||||
name="copy"
|
name="copy"
|
||||||
onclick={async () => {
|
onclick={async (e) => {
|
||||||
|
onclick?.(e);
|
||||||
|
|
||||||
const status = await clipboard.copy(text);
|
const status = await clipboard.copy(text);
|
||||||
|
|
||||||
onCopy?.(status);
|
onCopy?.(status);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@
|
||||||
import ModelPicker from './model-picker.svelte';
|
import ModelPicker from './model-picker.svelte';
|
||||||
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||||
import { cn } from '$lib/utils/utils.js';
|
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();
|
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);
|
const textareaSize = new ElementSize(() => textarea);
|
||||||
|
|
||||||
let textareaWrapper = $state<HTMLDivElement>();
|
let textareaWrapper = $state<HTMLDivElement>();
|
||||||
|
|
@ -368,8 +405,52 @@
|
||||||
{cmdOrCtrl} + B
|
{cmdOrCtrl} + B
|
||||||
</Tooltip>
|
</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 -->
|
<!-- 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">
|
<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>
|
<Tooltip>
|
||||||
{#snippet trigger(tooltip)}
|
{#snippet trigger(tooltip)}
|
||||||
<Button variant="ghost" size="icon" class="size-8" href="/account" {...tooltip.trigger}>
|
<Button variant="ghost" size="icon" class="size-8" href="/account" {...tooltip.trigger}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue