mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
- Added the ensure_publication function to create and verify the zero_publication if it is missing, ensuring idempotency during database initialization. - Integrated ensure_publication into the create_db_and_tables function to prevent zero-cache crash loops on startup. - Introduced a self-check script to validate the ensure_publication functionality on a create_all-bootstrapped database. - Updated various components to reflect the transition from search space to workspace, including adjustments in imports and routing paths.
257 lines
8.3 KiB
TypeScript
257 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useAtomValue } from "jotai";
|
|
import { Earth, User, Users } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
|
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useUpdateThreadVisibility } from "@/hooks/use-thread-mutations";
|
|
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ChatShareButtonProps {
|
|
thread: ThreadRecord | null;
|
|
onVisibilityChange?: (visibility: ChatVisibility) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const visibilityOptions: {
|
|
value: ChatVisibility;
|
|
label: string;
|
|
description: string;
|
|
icon: typeof User;
|
|
}[] = [
|
|
{
|
|
value: "PRIVATE",
|
|
label: "Private",
|
|
description: "Only you can access this chat",
|
|
icon: User,
|
|
},
|
|
{
|
|
value: "SEARCH_SPACE",
|
|
label: "Search Space",
|
|
description: "All members of this search space can access",
|
|
icon: Users,
|
|
},
|
|
];
|
|
|
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// Use Jotai atom for visibility (single source of truth)
|
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
|
const { mutateAsync: updateVisibility } = useUpdateThreadVisibility(thread?.workspace_id ?? 0);
|
|
|
|
// Snapshot creation mutation
|
|
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
|
|
createPublicChatSnapshotMutationAtom
|
|
);
|
|
|
|
// Permission check for public sharing
|
|
const { data: access } = useAtomValue(myAccessAtom);
|
|
const canCreatePublicLink =
|
|
!!access &&
|
|
(access.is_owner || (access.permissions?.includes("public_sharing:create") ?? false));
|
|
|
|
// Query to check if thread has public snapshots
|
|
const { data: snapshotsData } = useQuery({
|
|
queryKey: ["thread-snapshots", thread?.id],
|
|
queryFn: () => {
|
|
const id = thread?.id;
|
|
if (id == null) throw new Error("Missing thread id");
|
|
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
|
|
},
|
|
enabled: !!thread?.id,
|
|
staleTime: 30000, // Cache for 30 seconds
|
|
});
|
|
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
|
|
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop.
|
|
// Unknown visibility should not be presented as private while thread detail is still resolving.
|
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility;
|
|
|
|
const handleVisibilityChange = useCallback(
|
|
async (newVisibility: ChatVisibility) => {
|
|
if (!thread || newVisibility === currentVisibility) {
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const updatedThread = await updateVisibility({
|
|
thread,
|
|
visibility: newVisibility,
|
|
});
|
|
|
|
onVisibilityChange?.(updatedThread.visibility);
|
|
toast.success(
|
|
newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private"
|
|
);
|
|
setOpen(false);
|
|
} catch (error) {
|
|
console.error("Failed to update visibility:", error);
|
|
toast.error("Failed to update sharing settings");
|
|
}
|
|
},
|
|
[thread, currentVisibility, onVisibilityChange, updateVisibility]
|
|
);
|
|
|
|
const handleCreatePublicLink = useCallback(async () => {
|
|
if (!thread) return;
|
|
|
|
try {
|
|
await createSnapshot({ thread_id: thread.id });
|
|
// Refetch snapshots to show the globe indicator
|
|
await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] });
|
|
setOpen(false);
|
|
} catch (error) {
|
|
console.error("Failed to create public link:", error);
|
|
}
|
|
}, [thread, createSnapshot, queryClient]);
|
|
|
|
// Don't show if no thread (new chat that hasn't been created yet)
|
|
if (!thread || currentVisibility === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
|
|
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
|
|
|
return (
|
|
<div className={cn("flex items-center gap-1", className)}>
|
|
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
|
{hasPublicSnapshots && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() =>
|
|
router.push(`/dashboard/${thread.workspace_id}/workspace-settings/public-links`)
|
|
}
|
|
className="size-8 bg-muted/50 hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<Earth data-icon="inline-start" className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Manage public links</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-accent hover:text-accent-foreground border-0 select-none"
|
|
>
|
|
<CurrentIcon className="h-4 w-4" />
|
|
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent
|
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg select-none"
|
|
align="end"
|
|
sideOffset={8}
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="p-1.5 space-y-1">
|
|
{/* Visibility Options */}
|
|
{visibilityOptions.map((option) => {
|
|
const isSelected = currentVisibility === option.value;
|
|
const Icon = option.icon;
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
key={option.value}
|
|
onClick={() => handleVisibilityChange(option.value)}
|
|
className={cn(
|
|
"h-auto w-full justify-start gap-2.5 whitespace-normal px-2.5 py-2 font-normal",
|
|
isSelected && "bg-accent text-accent-foreground"
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
|
isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"block",
|
|
isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 text-left min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={cn(
|
|
"text-sm font-medium",
|
|
isSelected && "text-primary dark:text-white"
|
|
)}
|
|
>
|
|
{option.label}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
|
{option.description}
|
|
</p>
|
|
</div>
|
|
</Button>
|
|
);
|
|
})}
|
|
|
|
{canCreatePublicLink && (
|
|
<>
|
|
{/* Divider */}
|
|
<div className="border-t border-popover-border my-1" />
|
|
|
|
{/* Public Link Option */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={handleCreatePublicLink}
|
|
disabled={isCreatingSnapshot}
|
|
className={cn(
|
|
"h-auto w-full justify-start gap-2.5 whitespace-normal px-2.5 py-2 font-normal",
|
|
"disabled:cursor-not-allowed"
|
|
)}
|
|
>
|
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
|
|
<Earth className="block text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1 text-left min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm font-medium">
|
|
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
|
Creates a shareable snapshot of this chat
|
|
</p>
|
|
</div>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|