feat: add URL-driven workspace state and streamed Codex research

This commit is contained in:
2026-09-28 20:08:28 +09:00
parent 85d23a328b
commit 8085ad2f90
207 changed files with 15287 additions and 16026 deletions
+75
View File
@@ -0,0 +1,75 @@
:root {
--canvas: var(--color-canvas);
--surface: var(--color-paper-2);
--paper: var(--color-paper);
--ink: var(--color-ink);
--accent-hover: var(--color-accent-hover);
--control-border: var(--color-control-rule);
--error: var(--color-error);
color: var(--ink);
background: var(--canvas);
color-scheme: dark;
font-family: var(--font-body);
font-size: 14px;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
overflow-x: clip;
background: var(--canvas);
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
}
a {
color: var(--primary);
text-underline-offset: 0.18em;
}
a:active {
color: var(--accent-hover);
}
:focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 2px;
}
h1 {
margin: 0;
min-width: 0;
font-family: var(--font-display);
font-size: var(--text-display-s);
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
}
.handle {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.workspace .reader {
overflow: hidden;
}
.sidebar-note {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* Respect reduced motion over registry component animation utilities. */
animation-duration: 0.01ms !important;
/* Respect reduced motion over registry component transition utilities. */
transition-duration: 0.01ms !important;
}
}
+30 -51
View File
@@ -1,5 +1,5 @@
import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react'
import { type CSSProperties, type ReactNode, useState } from 'react'
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import {
Sidebar,
SidebarInset,
@@ -8,16 +8,16 @@ import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
} from "#/components/ui/sidebar";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
type ShellProps = {
hasTemporaryDecks: boolean
sidebar: ReactNode
researchChat: (closeMobileSidebar: () => void) => ReactNode
children: ReactNode
}
hasTemporaryDecks: boolean;
sidebar: ReactNode;
researchChat: (closeMobileSidebar: () => void) => ReactNode;
children: ReactNode;
};
export function AppShell(props: ShellProps) {
return (
@@ -26,29 +26,24 @@ export function AppShell(props: ShellProps) {
className="workspace h-dvh min-h-0 overflow-hidden"
style={
{
'--sidebar-width': '25rem',
'--sidebar-width-icon': '4rem',
"--sidebar-width": "25rem",
"--sidebar-width-icon": "4rem",
} as CSSProperties
}
>
<Workspace {...props} />
</SidebarProvider>
</TooltipProvider>
)
);
}
function Workspace({
sidebar,
researchChat,
children,
hasTemporaryDecks,
}: ShellProps) {
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar()
function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) {
const [panel, setPanel] = useState<"chat" | "decks">("chat");
const { setOpen, setOpenMobile, isMobile } = useSidebar();
const navigation = [
{ id: 'chat', title: 'Research chat', icon: MessageSquare },
{ id: 'decks', title: 'Deck manager', icon: Columns3 },
] as const
{ id: "chat", title: "Research chat", icon: MessageSquare },
{ id: "decks", title: "Deck manager", icon: Columns3 },
] as const;
return (
<>
@@ -62,10 +57,8 @@ function Workspace({
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Close sidebar"
tooltip={{ children: 'Close sidebar', hidden: false }}
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
@@ -80,9 +73,9 @@ function Workspace({
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
onClick={() => {
setPanel(item.id)
if (isMobile) setOpenMobile(true)
else setOpen(true)
setPanel(item.id);
if (isMobile) setOpenMobile(true);
else setOpen(true);
}}
className="size-10 justify-center p-0"
>
@@ -92,23 +85,14 @@ function Workspace({
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar
collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
>
<div
className={
panel === 'chat' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'
}
>
<Sidebar collapsible="none" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="Deck manager"
className={
panel === 'decks'
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: 'hidden'
panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
}
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
@@ -120,17 +104,12 @@ function Workspace({
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">
Research workspace
</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header>
<section
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
>
<section className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks">
{children}
</section>
</SidebarInset>
</>
)
);
}
+11 -11
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn'
import { Icon } from './icon'
import { Button } from './ui/button'
import { cn } from "cn";
import { Icon } from "./icon";
import { Button } from "./ui/button";
import {
DialogClose,
DialogContent,
DialogHeader,
Dialog as DialogRoot,
DialogTitle,
} from './ui/dialog'
} from "./ui/dialog";
export function Dialog({
title,
@@ -15,21 +15,21 @@ export function Dialog({
children,
className,
}: {
title: string
className?: string
onClose: () => void
children: React.ReactNode
title: string;
className?: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<DialogRoot
open
onOpenChange={(open) => {
if (!open) onClose()
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)}
className={cn("max-h-[calc(100dvh-2rem)] overflow-y-auto", className)}
aria-describedby={undefined}
>
<DialogHeader>
@@ -50,5 +50,5 @@ export function Dialog({
{children}
</DialogContent>
</DialogRoot>
)
);
}
+14 -14
View File
@@ -1,17 +1,17 @@
const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16',
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01',
plus: 'M12 5v14 M5 12h14',
close: 'M6 6l12 12 M18 6 6 18',
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
arrow: 'M7 17 17 7 M7 7h10v10',
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
} as const
columns: "M3 4h7v16H3z M14 4h7v16h-7z",
hashtag: "M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16",
search: "M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0",
user: "M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0",
list: "M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01",
plus: "M12 5v14 M5 12h14",
close: "M6 6l12 12 M18 6 6 18",
refresh: "M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6",
more: "M5 12h.01 M12 12h.01 M19 12h.01",
arrow: "M7 17 17 7 M7 7h10v10",
edit: "m16 3 5 5-12 12H4v-5z M14 5l5 5",
trash: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7",
} as const;
export function Icon({ name }: { name: keyof typeof paths }) {
return (
@@ -28,5 +28,5 @@ export function Icon({ name }: { name: keyof typeof paths }) {
>
<path d={paths[name]} />
</svg>
)
);
}
+25 -30
View File
@@ -1,49 +1,44 @@
import type { ReactNode } from 'react'
import type { ReactNode } from "react";
const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const URL = /(https?:\/\/[^\s]+)/g;
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi;
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
"&amp;": "&",
"&apos;": "'",
"&gt;": ">",
"&lt;": "<",
"&quot;": '"',
};
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity;
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16);
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
});
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = []
let cursor = 0
const decoded = decodeHtmlEntities(text);
const parts: ReactNode[] = [];
let cursor = 0;
for (const match of decoded.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
const href = match[0];
const start = match.index;
if (start > cursor) parts.push(decoded.slice(cursor, start));
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
<a href={href} key={`link-${start}`} rel="noreferrer noopener" target="_blank">
{href}
</a>,
)
cursor = start + href.length
);
cursor = start + href.length;
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts
if (cursor < decoded.length) parts.push(decoded.slice(cursor));
return parts;
}
export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</>
return <>{linkedText(text)}</>;
}
+25 -41
View File
@@ -1,106 +1,90 @@
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import { cn } from 'cn'
import type * as React from 'react'
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "cn";
import type * as React from "react";
function Avatar({
className,
size = 'default',
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
)
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
'aspect-square size-full rounded-full object-cover',
className,
)}
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
)
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
)
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
)
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
)
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
)
);
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
+20 -23
View File
@@ -1,40 +1,37 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline:
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function Badge({
className,
variant = 'default',
variant = "default",
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
@@ -42,10 +39,10 @@ function Badge({
),
render,
state: {
slot: 'badge',
slot: "badge",
variant,
},
})
});
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+44 -48
View File
@@ -1,54 +1,54 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
);
}
const bubbleVariants = cva(
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function Bubble({
variant = 'default',
align = 'start',
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<'div'> &
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end'
align?: "start" | "end";
}) {
return (
<div
@@ -58,20 +58,16 @@ function Bubble({
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className,
),
},
@@ -79,39 +75,39 @@ function BubbleContent({
),
render,
state: {
slot: 'bubble-content',
slot: "bubble-content",
},
})
});
}
const bubbleReactionsVariants = cva(
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: 'left-3',
end: 'right-3',
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
side: "bottom",
align: "end",
},
},
)
);
function BubbleReactions({
side = 'bottom',
align = 'end',
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<'div'> & {
align?: 'start' | 'end'
side?: 'top' | 'bottom'
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
@@ -121,7 +117,7 @@ function BubbleReactions({
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
);
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions };
+23 -23
View File
@@ -1,48 +1,48 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function Button({
className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
@@ -51,7 +51,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+6 -6
View File
@@ -1,13 +1,13 @@
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
import { cn } from 'cn'
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "cn";
import { CheckIcon } from "lucide-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
+5 -9
View File
@@ -1,19 +1,15 @@
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+30 -48
View File
@@ -1,39 +1,36 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
"fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -42,7 +39,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -50,7 +47,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
@@ -59,13 +56,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
@@ -73,17 +64,13 @@ function DialogContent({
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
);
}
function DialogFooter({
@@ -91,52 +78,47 @@ function DialogFooter({
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-base leading-none font-medium', className)}
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
)
);
}
export {
@@ -150,4 +132,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+40 -54
View File
@@ -1,34 +1,31 @@
'use client'
"use client";
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { cn } from 'cn'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import type * as React from 'react'
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "cn";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import type * as React from "react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = 'start',
align = "start",
alignOffset = 0,
side = 'bottom',
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
@@ -41,18 +38,18 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
@@ -60,29 +57,29 @@ function DropdownMenuLabel({
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
)
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
@@ -95,11 +92,11 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -108,7 +105,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
@@ -123,13 +120,13 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
);
}
function DropdownMenuSubContent({
align = 'start',
align = "start",
alignOffset = -3,
side = 'right',
side = "right",
sideOffset = 0,
className,
...props
@@ -138,7 +135,7 @@ function DropdownMenuSubContent({
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
@@ -147,7 +144,7 @@ function DropdownMenuSubContent({
sideOffset={sideOffset}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -157,7 +154,7 @@ function DropdownMenuCheckboxItem({
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
@@ -180,16 +177,11 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
@@ -198,7 +190,7 @@ function DropdownMenuRadioItem({
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
@@ -220,36 +212,30 @@ function DropdownMenuRadioItem({
</span>
{children}
</MenuPrimitive.RadioItem>
)
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
)
);
}
export {
@@ -268,4 +254,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
};
+25 -32
View File
@@ -1,49 +1,49 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
)
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn('flex max-w-sm flex-col items-center gap-2', className)}
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
);
}
const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-transparent',
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function EmptyMedia({
className,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
@@ -51,50 +51,43 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn('text-sm font-medium tracking-tight', className)}
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
)
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
)
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className,
)}
{...props}
/>
)
);
}
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
}
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
+51 -64
View File
@@ -1,24 +1,24 @@
'use client'
"use client";
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Textarea } from '#/components/ui/textarea.tsx'
import { Button } from "#/components/ui/button.tsx";
import { Input } from "#/components/ui/input.tsx";
import { Textarea } from "#/components/ui/textarea.tsx";
function InputGroup({ className, ...props }: React.ComponentProps<'fieldset'>) {
function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="input-group"
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
)
);
}
const inputGroupAddonVariants = cva(
@@ -26,72 +26,65 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: 'inline-start',
align: "inline-start",
},
},
)
);
function InputGroupAddon({
className,
align = 'inline-start',
align = "inline-start",
...props
}: React.ComponentProps<'fieldset'> &
VariantProps<typeof inputGroupAddonVariants>) {
}: React.ComponentProps<"fieldset"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: Pointer convenience only; the text control remains independently keyboard-focusable.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- Pointer convenience only; the text control remains independently keyboard-focusable.
<fieldset
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
)
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
)
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset'
type?: "button" | "submit" | "reset";
}) {
return (
<Button
@@ -101,10 +94,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
@@ -113,39 +106,33 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
)}
{...props}
/>
)
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
)
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
)
);
}
export {
@@ -155,4 +142,4 @@ export {
InputGroupInput,
InputGroupText,
InputGroupTextarea,
}
};
+7 -7
View File
@@ -1,19 +1,19 @@
import { Input as InputPrimitive } from '@base-ui/react/input'
import { cn } from 'cn'
import type * as React from 'react'
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "cn";
import type * as React from "react";
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+8 -8
View File
@@ -1,20 +1,20 @@
'use client'
"use client";
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function Label({ className, ...props }: React.ComponentProps<'label'>) {
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: Association and content are supplied by the caller through props.
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- Association and content are supplied by the caller through props.
<label
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+22 -25
View File
@@ -1,32 +1,32 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: '',
default: "",
separator:
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
border: 'border-b border-border pb-2',
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
},
)
);
function Marker({
className,
variant = 'default',
variant = "default",
render,
...props
}: useRender.ComponentProps<'div'> & VariantProps<typeof markerVariants>) {
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
@@ -34,37 +34,34 @@ function Marker({
),
render,
state: {
slot: 'marker',
slot: "marker",
variant,
},
})
});
}
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
/>
)
);
}
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
)
);
}
export { Marker, MarkerContent, MarkerIcon, markerVariants }
export { Marker, MarkerContent, MarkerIcon, markerVariants };
+23 -23
View File
@@ -1,20 +1,20 @@
'use client'
"use client";
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller'
import { cn } from 'cn'
import { ArrowDownIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
} from "@shadcn/react/message-scroller";
import { cn } from "cn";
import { ArrowDownIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
@@ -25,12 +25,12 @@ function MessageScroller({
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerViewport({
@@ -41,12 +41,12 @@ function MessageScrollerViewport({
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible',
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerContent({
@@ -56,10 +56,10 @@ function MessageScrollerContent({
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col gap-6', className)}
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
);
}
function MessageScrollerItem({
@@ -72,24 +72,24 @@ function MessageScrollerItem({
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerButton({
direction = 'end',
direction = "end",
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
@@ -98,7 +98,7 @@ function MessageScrollerButton({
data-size={size}
direction={direction}
className={cn(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className,
)}
render={render ?? <Button variant={variant} size={size} />}
@@ -108,12 +108,12 @@ function MessageScrollerButton({
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
);
}
export {
@@ -126,4 +126,4 @@ export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
};
+22 -29
View File
@@ -1,91 +1,84 @@
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) {
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
);
}
function Message({
className,
align = 'start',
align = "start",
...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse',
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className,
)}
{...props}
/>
)
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) {
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8',
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className,
)}
{...props}
/>
)
);
}
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) {
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end',
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className,
)}
{...props}
/>
)
);
}
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) {
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0',
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className,
)}
{...props}
/>
)
);
}
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) {
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end',
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className,
)}
{...props}
/>
)
);
}
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
}
export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
+16 -26
View File
@@ -1,20 +1,16 @@
import { cn } from 'cn'
import { ChevronDownIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & {
size?: 'sm' | 'default'
}
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
};
function NativeSelect({
className,
size = 'default',
...props
}: NativeSelectProps) {
function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
return (
<div
className={cn(
'group/native-select relative w-fit has-[select:disabled]:opacity-50',
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className,
)}
data-slot="native-select-wrapper"
@@ -32,33 +28,27 @@ function NativeSelect({
data-slot="native-select-icon"
/>
</div>
)
);
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<'option'>) {
function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
);
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<'optgroup'>) {
function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
+6 -10
View File
@@ -1,22 +1,18 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
import { cn } from 'cn'
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "cn";
function Separator({
className,
orientation = 'horizontal',
...props
}: SeparatorPrimitive.Props) {
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+30 -39
View File
@@ -1,23 +1,23 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
@@ -25,25 +25,25 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
"fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
)
);
}
function SheetContent({
className,
children,
side = 'right',
side = "right",
showCloseButton = true,
keepMounted = false,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
keepMounted?: boolean
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
keepMounted?: boolean;
}) {
return (
<SheetPortal keepMounted={keepMounted}>
@@ -52,7 +52,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
@@ -61,13 +61,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
@@ -75,50 +69,47 @@ function SheetContent({
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)}
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-base font-medium text-foreground', className)}
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
);
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@@ -130,4 +121,4 @@ export {
SheetHeader,
SheetTitle,
SheetTrigger,
}
};
+205 -238
View File
@@ -1,51 +1,47 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Separator } from '#/components/ui/separator.tsx'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { Button } from "#/components/ui/button.tsx";
import { Input } from "#/components/ui/input.tsx";
import { Separator } from "#/components/ui/separator.tsx";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '#/components/ui/sheet.tsx'
import { Skeleton } from '#/components/ui/skeleton.tsx'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '#/components/ui/tooltip.tsx'
import { useIsMobile } from '#/hooks/use-mobile.ts'
} from "#/components/ui/sheet.tsx";
import { Skeleton } from "#/components/ui/skeleton.tsx";
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
import { useIsMobile } from "#/hooks/use-mobile.ts";
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = 'min(25rem, 100vw)'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "min(25rem, 100vw)";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -56,54 +52,51 @@ function SidebarProvider({
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
},
[setOpenProp, open],
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -116,7 +109,7 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -124,13 +117,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
@@ -138,37 +131,37 @@ function SidebarProvider({
{children}
</div>
</SidebarContext.Provider>
)
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -183,28 +176,26 @@ function Sidebar({
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Workspace</SheetTitle>
<SheetDescription>
Research chat and deck management
</SheetDescription>
<SheetDescription>Research chat and deck management</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
@@ -213,23 +204,23 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
@@ -243,15 +234,11 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -261,19 +248,19 @@ function SidebarTrigger({
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
@@ -284,118 +271,112 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -403,23 +384,23 @@ function SidebarGroupLabel({
),
render,
state: {
slot: 'sidebar-group-label',
sidebar: 'group-label',
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -427,87 +408,84 @@ function SidebarGroupAction({
),
render,
state: {
slot: 'sidebar-group-action',
sidebar: 'group-action',
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function SidebarMenuButton({
render,
isActive = false,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
@@ -515,21 +493,21 @@ function SidebarMenuButton({
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: 'sidebar-menu-button',
sidebar: 'menu-button',
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
});
if (!tooltip) {
return comp
return comp;
}
if (typeof tooltip === 'string') {
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -538,11 +516,11 @@ function SidebarMenuButton({
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -550,18 +528,18 @@ function SidebarMenuAction({
render,
showOnHover = false,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
showOnHover?: boolean
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
},
@@ -569,112 +547,101 @@ function SidebarMenuAction({
),
render,
state: {
slot: 'sidebar-menu-action',
sidebar: 'menu-action',
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
});
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
render,
size = 'md',
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<'a'> &
React.ComponentProps<'a'> & {
size?: 'sm' | 'md'
isActive?: boolean
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
),
},
@@ -682,12 +649,12 @@ function SidebarMenuSubButton({
),
render,
state: {
slot: 'sidebar-menu-sub-button',
sidebar: 'menu-sub-button',
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
});
}
export {
@@ -715,4 +682,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};
+5 -5
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn'
import { cn } from "cn";
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)}
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
+6 -6
View File
@@ -1,17 +1,17 @@
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };
+13 -25
View File
@@ -1,42 +1,30 @@
'use client'
"use client";
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from 'cn'
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "cn";
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = 'top',
side = "top",
sideOffset = 4,
align = 'center',
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
@@ -49,7 +37,7 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
@@ -59,7 +47,7 @@ function TooltipContent({
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+38 -48
View File
@@ -1,15 +1,7 @@
import { Link, useLocation, useRouteContext } from '@tanstack/react-router'
import {
Activity,
House,
Inbox,
Layers3,
MessagesSquare,
NotebookPen,
Search,
} from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import { Link, useLocation, useRouteContext } from "@tanstack/react-router";
import { Activity, House, Inbox, Layers3, MessagesSquare, NotebookPen, Search } from "lucide-react";
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
import {
Sidebar,
SidebarContent,
@@ -21,62 +13,62 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '#/components/ui/sidebar'
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog'
} from "#/components/ui/sidebar";
import { ConnectionManagerDialog } from "#/features/connections/connection-manager-dialog";
import { useUrlState } from "#/features/navigation/use-url-state";
type WorkspaceNavigationProps = {
children?: ReactNode
footer?: ReactNode
hasTemporaryDecks?: boolean
}
children?: ReactNode;
footer?: ReactNode;
hasTemporaryDecks?: boolean;
};
export function WorkspaceNavigation({
children,
footer,
hasTemporaryDecks,
}: WorkspaceNavigationProps) {
const { auth } = useRouteContext({ from: '__root__' })
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const { auth } = useRouteContext({ from: "__root__" });
const [search, updateSearch] = useUrlState();
const managingAccounts = search.accounts;
const setManagingAccounts = (open: boolean) => updateSearch({ accounts: open || undefined });
const pathname = useLocation({ select: (location) => location.pathname });
const navigation = [
{ title: 'Home', to: '/', icon: House, active: pathname === '/' },
{ title: "Home", to: "/", icon: House, active: pathname === "/" },
{
title: 'Vitals',
to: '/vitals',
title: "Vitals",
to: "/vitals",
icon: Activity,
active: pathname === '/vitals',
active: pathname === "/vitals",
},
{
title: 'Messages',
to: '/support',
title: "Messages",
to: "/support",
icon: MessagesSquare,
active: pathname === '/support',
active: pathname === "/support",
},
{
title: 'Journal',
to: '/journal',
title: "Journal",
to: "/journal",
icon: NotebookPen,
active: pathname === '/journal',
active: pathname === "/journal",
},
{
title: 'Reader',
to: '/inbox',
title: "Reader",
to: "/inbox",
icon: Inbox,
active: pathname === '/inbox',
active: pathname === "/inbox",
},
{
title: 'Research',
to: '/deck',
title: "Research",
to: "/deck",
icon: Search,
active: pathname === '/deck',
active: pathname === "/deck",
},
] as const
] as const;
return (
<Sidebar
collapsible="none"
className="workspace-navigation w-16! shrink-0 border-r"
>
<Sidebar collapsible="none" className="w-16! shrink-0 border-r">
<SidebarHeader className="items-center py-4">
<div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
@@ -94,7 +86,7 @@ export function WorkspaceNavigation({
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
aria-label={item.title}
aria-current={item.active ? 'page' : undefined}
aria-current={item.active ? "page" : undefined}
tooltip={{ children: item.title, hidden: false }}
isActive={item.active}
render={<Link to={item.to} />}
@@ -125,14 +117,12 @@ export function WorkspaceNavigation({
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Manage connected accounts"
tooltip={{ children: 'Manage connected accounts', hidden: false }}
tooltip={{ children: "Manage connected accounts", hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
>
<Avatar className="size-8">
<AvatarFallback>
{auth.owner?.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
<AvatarFallback>{auth.owner?.name.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -145,5 +135,5 @@ export function WorkspaceNavigation({
/>
)}
</Sidebar>
)
);
}
+24 -27
View File
@@ -1,45 +1,42 @@
type AccessConfig = { origin: string } & (
| { mode: 'tailscale'; allowedLogin: string }
| { mode: 'none' }
)
| { mode: "tailscale"; allowedLogin: string }
| { mode: "none" }
);
export function readAccessConfig(): AccessConfig | null {
const origin = process.env.TWITTER_LITE_ORIGIN
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale'
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null
const origin = process.env.TWITTER_LITE_ORIGIN;
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? "tailscale";
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN;
if (!origin || (mode !== "tailscale" && mode !== "none")) return null;
try {
const url = new URL(origin)
const secure = url.protocol === 'https:'
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
if ((!secure && !local) || url.origin !== origin) return null
if (mode === 'none') return { origin, mode }
if (!allowedLogin?.trim()) return null
return { origin, mode, allowedLogin }
const url = new URL(origin);
const secure = url.protocol === "https:";
const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
if ((!secure && !local) || url.origin !== origin) return null;
if (mode === "none") return { origin, mode };
if (!allowedLogin?.trim()) return null;
return { origin, mode, allowedLogin };
} catch {
return null
return null;
}
}
/** Use loopback behind Serve for identity, or a private network for mode none. */
export function checkAccess(
request: Request,
config: AccessConfig | null,
): Response | null {
export function checkAccess(request: Request, config: AccessConfig | null): Response | null {
if (!config) {
return new Response('Access configuration is required.', { status: 503 })
return new Response("Access configuration is required.", { status: 503 });
}
if (
config.mode === 'tailscale' &&
request.headers.get('Tailscale-User-Login') !== config.allowedLogin
config.mode === "tailscale" &&
request.headers.get("Tailscale-User-Login") !== config.allowedLogin
) {
return new Response('Forbidden', { status: 403 })
return new Response("Forbidden", { status: 403 });
}
if (
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
request.headers.get('Origin') !== config.origin
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
request.headers.get("Origin") !== config.origin
) {
return new Response('Forbidden', { status: 403 })
return new Response("Forbidden", { status: 403 });
}
return null
return null;
}
+131 -155
View File
@@ -1,171 +1,147 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { checkAccess, readAccessConfig } from './policy.server'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkAccess, readAccessConfig } from "./policy.server";
const config = {
mode: 'tailscale' as const,
origin: 'https://deck.invalid',
allowedLogin: '[email protected]',
}
mode: "tailscale" as const,
origin: "https://deck.invalid",
allowedLogin: "[email protected]",
};
beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined))
afterEach(() => vi.unstubAllEnvs())
beforeEach(() => vi.stubEnv("TWITTER_LITE_AUTH_MODE", undefined));
afterEach(() => vi.unstubAllEnvs());
describe('access boundary', () => {
it.each([
undefined,
'tailscale',
])('requires an owner in identity mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
})
describe("access boundary", () => {
it.each([undefined, "tailscale"])("requires an owner in identity mode: %s", (mode) => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
expect(readAccessConfig()).toBeNull();
});
it.each([
'',
'off',
'NONE',
])('fails closed for an unknown auth mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(
checkAccess(new Request(config.origin), readAccessConfig())?.status,
).toBe(503)
})
it.each(["", "off", "NONE"])("fails closed for an unknown auth mode: %s", (mode) => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(checkAccess(new Request(config.origin), readAccessConfig())?.status).toBe(503);
});
it('allows private-proxy navigation without an identity only when explicitly configured', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
const access = readAccessConfig()
expect(access).toEqual({ origin: config.origin, mode: 'none' })
expect(checkAccess(new Request(config.origin), access)).toBeNull()
})
it("allows private-proxy navigation without an identity only when explicitly configured", () => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
const access = readAccessConfig();
expect(access).toEqual({ origin: config.origin, mode: "none" });
expect(checkAccess(new Request(config.origin), access)).toBeNull();
});
it('still requires a configured origin without identity authentication', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
expect(readAccessConfig()).toBeNull()
})
it("still requires a configured origin without identity authentication", () => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv("TWITTER_LITE_ORIGIN", "");
expect(readAccessConfig()).toBeNull();
});
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin without identity authentication for %s', (method) => {
const access = { origin: config.origin, mode: 'none' as const }
expect(
checkAccess(new Request(config.origin, { method }), access)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
},
}),
access,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { Origin: config.origin },
}),
access,
),
).toBeNull()
})
it.each(["POST", "PUT", "PATCH", "DELETE"])(
"requires exact Origin without identity authentication for %s",
(method) => {
const access = { origin: config.origin, mode: "none" as const };
expect(checkAccess(new Request(config.origin, { method }), access)?.status).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: {
Origin: "https://other.invalid",
"Sec-Fetch-Site": "same-origin",
},
}),
access,
)?.status,
).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { Origin: config.origin },
}),
access,
),
).toBeNull();
},
);
it('fails closed when the deployment is not configured', () => {
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503)
})
it("fails closed when the deployment is not configured", () => {
vi.stubEnv("TWITTER_LITE_ORIGIN", "");
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
expect(readAccessConfig()).toBeNull();
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503);
});
it.each([
'',
'https://deck.invalid/path',
'http://deck.invalid',
'not a URL',
])('rejects an invalid configured origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toBeNull()
})
it.each(["", "https://deck.invalid/path", "http://deck.invalid", "not a URL"])(
"rejects an invalid configured origin: %s",
(origin) => {
vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(readAccessConfig()).toBeNull();
},
);
it.each([
'https://deck.invalid',
'http://127.0.0.1:4173',
])('accepts an explicit deployment origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toEqual({ ...config, origin })
})
it.each(["https://deck.invalid", "http://127.0.0.1:4173"])(
"accepts an explicit deployment origin: %s",
(origin) => {
vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(readAccessConfig()).toEqual({ ...config, origin });
},
);
it.each([
undefined,
'[email protected]',
'[email protected], [email protected]',
])('rejects absent, foreign, or ambiguous identities: %s', (login) => {
const headers = new Headers()
if (login) headers.set('Tailscale-User-Login', login)
expect(
checkAccess(new Request(config.origin, { headers }), config)?.status,
).toBe(403)
})
it.each([undefined, "[email protected]", "[email protected], [email protected]"])(
"rejects absent, foreign, or ambiguous identities: %s",
(login) => {
const headers = new Headers();
if (login) headers.set("Tailscale-User-Login", login);
expect(checkAccess(new Request(config.origin, { headers }), config)?.status).toBe(403);
},
);
it('permits owner navigation back from an OAuth provider without Origin', () => {
const request = new Request(
`${config.origin}/oauth/mastodon/callback?code=code`,
{
headers: {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'cross-site',
},
it("permits owner navigation back from an OAuth provider without Origin", () => {
const request = new Request(`${config.origin}/oauth/mastodon/callback?code=code`, {
headers: {
"Tailscale-User-Login": config.allowedLogin,
"Sec-Fetch-Site": "cross-site",
},
)
expect(checkAccess(request, config)).toBeNull()
})
});
expect(checkAccess(request, config)).toBeNull();
});
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => {
const headers = {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'same-origin',
}
expect(
checkAccess(new Request(config.origin, { method, headers }), config)
?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: 'https://other.invalid' },
}),
config,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: config.origin },
}),
config,
),
).toBeNull()
})
})
it.each(["POST", "PUT", "PATCH", "DELETE"])(
"requires exact Origin for %s even with same-origin Fetch Metadata",
(method) => {
const headers = {
"Tailscale-User-Login": config.allowedLogin,
"Sec-Fetch-Site": "same-origin",
};
expect(checkAccess(new Request(config.origin, { method, headers }), config)?.status).toBe(
403,
);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: "https://other.invalid" },
}),
config,
)?.status,
).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: config.origin },
}),
config,
),
).toBeNull();
},
);
});
+22 -22
View File
@@ -1,34 +1,34 @@
export type SafeOwner = {
id: number
email: string
name: string
onboardingCompletedAt: number | null
}
id: number;
email: string;
name: string;
onboardingCompletedAt: number | null;
};
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null };
type AuthInput =
| { action: 'login'; email: string; password: string }
| { action: "login"; email: string; password: string }
| {
action: 'setup'
email: string
password: string
name: string
setupToken: string
action: "setup";
email: string;
password: string;
name: string;
setupToken: string;
}
| { action: 'logout' }
| { action: 'onboard'; name: string }
| { action: "logout" }
| { action: "onboard"; name: string };
export async function authRequest(input: AuthInput): Promise<AuthState> {
const response = await fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
const response = await fetch("/api/auth", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
const result = await response.json()
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Something went wrong. Please try again.')
throw new Error(result.error || "Something went wrong. Please try again.");
}
return result
return result;
}
+51 -64
View File
@@ -1,10 +1,10 @@
import { ArrowRight, LockKeyhole } from 'lucide-react'
import { type FormEvent, type ReactNode, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import './auth.css'
import { ArrowRight, LockKeyhole } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { safeReturnTo } from "./return-to";
export function AuthFrame({ children }: { children: ReactNode }) {
return (
@@ -17,78 +17,70 @@ export function AuthFrame({ children }: { children: ReactNode }) {
{children}
</div>
</main>
)
);
}
export function LoginPage() {
return <CredentialsForm setup={false} />
return <CredentialsForm setup={false} />;
}
export function SetupPage() {
return <CredentialsForm setup />
return <CredentialsForm setup />;
}
function formText(data: FormData, name: string) {
const value = data.get(name);
return typeof value === "string" ? value : "";
}
function CredentialsForm({ setup }: { setup: boolean }) {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (pending) return
const data = new FormData(event.currentTarget)
const email = String(data.get('email') ?? '').trim()
const password = String(data.get('password') ?? '')
if (setup && password !== data.get('confirmPassword')) {
setError('Passwords do not match.')
return
event.preventDefault();
if (pending) return;
const data = new FormData(event.currentTarget);
const email = formText(data, "email").trim();
const password = formText(data, "password");
if (setup && password !== data.get("confirmPassword")) {
setError("Passwords do not match.");
return;
}
setPending(true)
setError('')
setPending(true);
setError("");
try {
const state = await authRequest(
setup
? {
action: 'setup',
action: "setup",
email,
password,
name: String(data.get('name') ?? '').trim(),
setupToken: String(data.get('setupToken') ?? '').trim(),
name: formText(data, "name").trim(),
setupToken: formText(data, "setupToken").trim(),
}
: { action: 'login', email, password },
)
: { action: "login", email, password },
);
if (!state.owner?.onboardingCompletedAt) {
window.location.assign('/onboarding')
return
window.location.assign("/onboarding");
return;
}
const returnTo = new URLSearchParams(window.location.search).get(
'returnTo',
)
const destination =
returnTo &&
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
returnTo,
)
? returnTo
: '/'
window.location.assign(destination)
const returnTo = new URLSearchParams(window.location.search).get("returnTo");
window.location.assign(safeReturnTo(returnTo));
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign in. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign in. Please try again.");
setPending(false);
}
}
return (
<AuthFrame>
<header className="auth-heading">
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1>
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
<p>
{setup
? 'Create the owner account for your personal workspace.'
: 'Sign in to your personal workspace.'}
? "Create the owner account for your personal workspace."
: "Sign in to your personal workspace."}
</p>
</header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
@@ -123,11 +115,11 @@ function CredentialsForm({ setup }: { setup: boolean }) {
id="password"
name="password"
type="password"
autoComplete={setup ? 'new-password' : 'current-password'}
autoComplete={setup ? "new-password" : "current-password"}
required
minLength={setup ? 15 : undefined}
maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined}
aria-describedby={setup ? "password-hint" : undefined}
disabled={pending}
/>
{setup && (
@@ -173,27 +165,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
{error}
</p>
)}
<Button
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
<Button type="submit" size="lg" disabled={pending} className="auth-submit">
{pending
? setup
? 'Creating account…'
: 'Signing in…'
? "Creating account…"
: "Signing in…"
: setup
? 'Create account'
: 'Sign in'}
? "Create account"
: "Sign in"}
<ArrowRight aria-hidden="true" />
</Button>
</form>
<p className="auth-footnote">
{setup
? 'This workspace has one owner. Connections can be added later.'
: 'A private workspace. Access is limited to its owner.'}
? "This workspace has one owner. Connections can be added later."
: "A private workspace. Access is limited to its owner."}
</p>
</AuthFrame>
)
);
}
+90 -122
View File
@@ -1,93 +1,83 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute } from 'node:path'
import { eq, lte } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import type { SafeOwner } from './auth-client'
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute } from "node:path";
import { eq, lte } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import type { SafeOwner } from "./auth-client";
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const derive = (password: string, salt: string) =>
new Promise<Buffer>((resolve, reject) =>
scrypt(password, salt, 64, scryptOptions, (error, key) =>
error ? reject(error) : resolve(key),
),
)
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
const throttleWindow = 15 * 60_000
);
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const throttleWindow = 15 * 60_000;
export class AuthError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
this.name = 'AuthError'
super(message);
this.name = "AuthError";
}
}
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const equal = (left: string, right: string) =>
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)))
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)));
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
return {
id: owner.id,
email: owner.email,
name: owner.name,
onboardingCompletedAt: owner.onboardingCompletedAt,
}
};
}
export function readAuthState(database = getDatabase()) {
return {
needsSetup: !database
.select({ id: workspaceOwner.id })
.from(workspaceOwner)
.get(),
}
needsSetup: !database.select({ id: workspaceOwner.id }).from(workspaceOwner).get(),
};
}
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
export function getSetupToken() {
const configured = process.env.WORKSPACE_SETUP_TOKEN
const configured = process.env.WORKSPACE_SETUP_TOKEN;
if (configured) {
if (configured.length < 32)
throw new AuthError(
503,
'The setup token must contain at least 32 characters.',
)
return configured
throw new AuthError(503, "The setup token must contain at least 32 characters.");
return configured;
}
const dbPath = process.env.TWITTER_LITE_DB_PATH
const dbPath = process.env.TWITTER_LITE_DB_PATH;
if (!dbPath || !isAbsolute(dbPath))
throw new AuthError(503, 'Configure a workspace database or setup token.')
const path = `${dbPath}.setup-token`
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
throw new AuthError(503, "Configure a workspace database or setup token.");
const path = `${dbPath}.setup-token`;
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try {
writeFileSync(path, randomBytes(32).toString('base64url'), {
writeFileSync(path, randomBytes(32).toString("base64url"), {
mode: 0o600,
flag: 'wx',
})
flag: "wx",
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const token = readFileSync(path, 'utf8').trim()
if (token.length < 32)
throw new AuthError(503, 'The workspace setup token is invalid.')
return token
const token = readFileSync(path, "utf8").trim();
if (token.length < 32) throw new AuthError(503, "The workspace setup token is invalid.");
return token;
}
function consumeAttempt(database: AppDatabase) {
database.transaction(
(tx) => {
const now = Date.now()
const current = tx.select().from(authThrottle).get()
const now = Date.now();
const current = tx.select().from(authThrottle).get();
if (current && current.resetAt > now) {
if (current.attempts >= 10)
throw new AuthError(
429,
'Too many attempts. Please try again in 15 minutes.',
)
throw new AuthError(429, "Too many attempts. Please try again in 15 minutes.");
tx.update(authThrottle)
.set({ attempts: current.attempts + 1 })
.where(eq(authThrottle.id, 1))
.run()
.run();
} else {
tx.insert(authThrottle)
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
@@ -95,21 +85,18 @@ function consumeAttempt(database: AppDatabase) {
target: authThrottle.id,
set: { attempts: 1, resetAt: now + throttleWindow },
})
.run()
.run();
}
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
function session(
database: Pick<AppDatabase, 'insert' | 'delete'>,
database: Pick<AppDatabase, "insert" | "delete">,
owner: typeof workspaceOwner.$inferSelect,
) {
const sessionToken = randomBytes(32).toString('base64url')
database
.delete(authSessions)
.where(lte(authSessions.expiresAt, Date.now()))
.run()
const sessionToken = randomBytes(32).toString("base64url");
database.delete(authSessions).where(lte(authSessions.expiresAt, Date.now())).run();
database
.insert(authSessions)
.values({
@@ -117,125 +104,106 @@ function session(
ownerId: owner.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
})
.run()
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run()
return { sessionToken, owner: safeOwner(owner) }
.run();
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run();
return { sessionToken, owner: safeOwner(owner) };
}
function cleanName(name: string) {
const cleaned = name.trim()
const cleaned = name.trim();
if (!cleaned || cleaned.length > 80)
throw new AuthError(400, 'Enter a name of up to 80 characters.')
return cleaned
throw new AuthError(400, "Enter a name of up to 80 characters.");
return cleaned;
}
export async function createOwner(
input: { email: string; password: string; name: string; setupToken: string },
database = getDatabase(),
) {
if (!readAuthState(database).needsSetup)
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
consumeAttempt(database)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
consumeAttempt(database);
if (!equal(input.setupToken, getSetupToken()))
throw new AuthError(401, 'Invalid setup credentials.')
const email = input.email.trim().toLowerCase()
throw new AuthError(401, "Invalid setup credentials.");
const email = input.email.trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new AuthError(400, 'Enter a valid email address.')
throw new AuthError(400, "Enter a valid email address.");
if (input.password.length < 15 || input.password.length > 128)
throw new AuthError(400, 'Use a password between 15 and 128 characters.')
const name = cleanName(input.name)
const salt = randomBytes(16).toString('hex')
const derived = (await derive(input.password, salt)) as Buffer
throw new AuthError(400, "Use a password between 15 and 128 characters.");
const name = cleanName(input.name);
const salt = randomBytes(16).toString("hex");
const derived = (await derive(input.password, salt)) as Buffer;
return database.transaction(
(tx) => {
if (tx.select().from(workspaceOwner).get())
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
const owner = {
id: 1,
email,
name,
passwordHash: `${salt}:${derived.toString('hex')}`,
passwordHash: `${salt}:${derived.toString("hex")}`,
onboardingCompletedAt: null,
createdAt: Date.now(),
}
tx.insert(workspaceOwner).values(owner).run()
return session(tx, owner)
};
tx.insert(workspaceOwner).values(owner).run();
return session(tx, owner);
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export async function signIn(
input: { email: string; password: string },
database = getDatabase(),
) {
consumeAttempt(database)
const owner = database.select().from(workspaceOwner).get()
export async function signIn(input: { email: string; password: string }, database = getDatabase()) {
consumeAttempt(database);
const owner = database.select().from(workspaceOwner).get();
if (input.password.length > 128 || input.email.length > 254)
throw new AuthError(401, 'Invalid email or password.')
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [
'0'.repeat(32),
'0'.repeat(128),
]
const actual = (await derive(input.password, salt)) as Buffer
throw new AuthError(401, "Invalid email or password.");
const [salt = "", expected = ""] = owner?.passwordHash.split(":") ?? [
"0".repeat(32),
"0".repeat(128),
];
const actual = (await derive(input.password, salt)) as Buffer;
if (
!owner ||
!equal(input.email.trim().toLowerCase(), owner.email) ||
!timingSafeEqual(actual, Buffer.from(expected, 'hex'))
!timingSafeEqual(actual, Buffer.from(expected, "hex"))
)
throw new AuthError(401, 'Invalid email or password.')
return session(database, owner)
throw new AuthError(401, "Invalid email or password.");
return session(database, owner);
}
export function getSession(
token: string | undefined,
database = getDatabase(),
): SafeOwner | null {
if (!token || token.length > 128) return null
export function getSession(token: string | undefined, database = getDatabase()): SafeOwner | null {
if (!token || token.length > 128) return null;
const found = database
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.get()
if (!found) return null
.get();
if (!found) return null;
if (found.expiresAt <= Date.now()) {
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, found.tokenHash))
.run()
return null
database.delete(authSessions).where(eq(authSessions.tokenHash, found.tokenHash)).run();
return null;
}
const owner = database
.select()
.from(workspaceOwner)
.where(eq(workspaceOwner.id, found.ownerId))
.get()
return owner ? safeOwner(owner) : null
.get();
return owner ? safeOwner(owner) : null;
}
export function signOut(token: string | undefined, database = getDatabase()) {
if (token)
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.run()
.run();
}
export function completeOnboarding(
token: string,
input: { name: string },
database = getDatabase(),
): SafeOwner {
const owner = getSession(token, database)
if (!owner) throw new AuthError(401, 'Please sign in.')
const owner = getSession(token, database);
if (!owner) throw new AuthError(401, "Please sign in.");
const update = {
name: cleanName(input.name),
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
}
database
.update(workspaceOwner)
.set(update)
.where(eq(workspaceOwner.id, owner.id))
.run()
return { ...owner, ...update }
};
database.update(workspaceOwner).set(update).where(eq(workspaceOwner.id, owner.id)).run();
return { ...owner, ...update };
}
+102 -119
View File
@@ -1,10 +1,10 @@
// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import {
completeOnboarding,
createOwner,
@@ -14,137 +14,120 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
let database: ReturnType<typeof openDatabase>
let database: ReturnType<typeof openDatabase>;
const credentials = {
email: '[email protected]',
password: 'correct horse battery staple',
name: 'Owner',
setupToken: 's'.repeat(32),
}
email: "[email protected]",
password: "correct horse battery staple",
name: "Owner",
setupToken: "s".repeat(32),
};
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken)
})
database = openDatabase(":memory:");
vi.stubEnv("WORKSPACE_SETUP_TOKEN", credentials.setupToken);
});
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('owner authentication', () => {
it('requires a private setup token and stores only derived credentials and session tokens', async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true })
database.$client.close();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("owner authentication", () => {
it("requires a private setup token and stores only derived credentials and session tokens", async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true });
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
expect(readAuthState(database).needsSetup).toBe(true)
const result = await createOwner(credentials, database)
expect(readAuthState(database)).toEqual({ needsSetup: false })
expect(result.owner.email).toBe(credentials.email)
expect(result.owner).not.toHaveProperty('passwordHash')
expect(
database.select().from(workspaceOwner).get()?.passwordHash,
).not.toContain(credentials.password)
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(
result.sessionToken,
)
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
expect(readAuthState(database).needsSetup).toBe(true);
const result = await createOwner(credentials, database);
expect(readAuthState(database)).toEqual({ needsSetup: false });
expect(result.owner.email).toBe(credentials.email);
expect(result.owner).not.toHaveProperty("passwordHash");
expect(database.select().from(workspaceOwner).get()?.passwordHash).not.toContain(
credentials.password,
);
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(result.sessionToken);
expect(getSession(result.sessionToken, database)).toEqual(result.owner);
await expect(createOwner(credentials, database)).rejects.toMatchObject({
status: 409,
})
})
it('only allows one owner even when setup requests race', async () => {
});
});
it("only allows one owner even when setup requests race", async () => {
const results = await Promise.allSettled([
createOwner(credentials, database),
createOwner(credentials, database),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(database.select().from(workspaceOwner).all()).toHaveLength(1)
})
it('checks the email and password and revokes logged-out sessions', async () => {
await createOwner(credentials, database)
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(database.select().from(workspaceOwner).all()).toHaveLength(1);
});
it("checks the email and password and revokes logged-out sessions", async () => {
await createOwner(credentials, database);
await expect(signIn({ ...credentials, password: "wrong" }, database)).rejects.toMatchObject({
status: 401,
message: "Invalid email or password.",
});
await expect(
signIn({ ...credentials, password: 'wrong' }, database),
signIn({ ...credentials, email: "[email protected]" }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
await expect(
signIn({ ...credentials, email: '[email protected]' }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
const result = await signIn(
{ ...credentials, email: '[email protected]' },
database,
)
expect(database.select().from(authThrottle).all()).toHaveLength(0)
expect(getSession('invented', database)).toBeNull()
expect(getSession(result.sessionToken, database)).not.toBeNull()
signOut(result.sessionToken, database)
expect(getSession(result.sessionToken, database)).toBeNull()
})
it('expires sessions and stores onboarding completion', async () => {
const result = await createOwner(credentials, database)
expect(result.owner.onboardingCompletedAt).toBeNull()
const owner = completeOnboarding(
result.sessionToken,
{ name: 'Yuta' },
database,
)
expect(owner.name).toBe('Yuta')
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
expect(getSession(result.sessionToken, database)).toEqual(owner)
expect(() =>
completeOnboarding('invalid', { name: 'Other' }, database),
).toThrow('Please sign in.')
vi.spyOn(Date, 'now').mockReturnValue(
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
)
expect(getSession(result.sessionToken, database)).toBeNull()
expect(database.select().from(authSessions).all()).toHaveLength(0)
})
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
message: "Invalid email or password.",
});
const result = await signIn({ ...credentials, email: "[email protected]" }, database);
expect(database.select().from(authThrottle).all()).toHaveLength(0);
expect(getSession("invented", database)).toBeNull();
expect(getSession(result.sessionToken, database)).not.toBeNull();
signOut(result.sessionToken, database);
expect(getSession(result.sessionToken, database)).toBeNull();
});
it("expires sessions and stores onboarding completion", async () => {
const result = await createOwner(credentials, database);
expect(result.owner.onboardingCompletedAt).toBeNull();
const owner = completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
expect(owner.name).toBe("Yuta");
expect(owner.onboardingCompletedAt).toBeTypeOf("number");
expect(getSession(result.sessionToken, database)).toEqual(owner);
expect(() => completeOnboarding("invalid", { name: "Other" }, database)).toThrow(
"Please sign in.",
);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1);
expect(getSession(result.sessionToken, database)).toBeNull();
expect(database.select().from(authSessions).all()).toHaveLength(0);
});
it("bounds setup and login attempts persistently and permits retry after cooldown", async () => {
for (let index = 0; index < 10; index++)
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
await expect(signIn(credentials, database)).rejects.toMatchObject({
status: 429,
})
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1)
await expect(createOwner(credentials, database)).resolves.toHaveProperty(
'sessionToken',
)
})
it('rejects short passwords and invalid identity fields', async () => {
});
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 15 * 60_000 + 1);
await expect(createOwner(credentials, database)).resolves.toHaveProperty("sessionToken");
});
it("rejects short passwords and invalid identity fields", async () => {
await expect(
createOwner({ ...credentials, password: 'short' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, email: 'invalid' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, name: ' ' }, database),
).rejects.toMatchObject({ status: 400 })
})
it('creates a stable local setup token with private file permissions', () => {
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-'))
createOwner({ ...credentials, password: "short" }, database),
).rejects.toMatchObject({ status: 400 });
await expect(createOwner({ ...credentials, email: "invalid" }, database)).rejects.toMatchObject(
{ status: 400 },
);
await expect(createOwner({ ...credentials, name: " " }, database)).rejects.toMatchObject({
status: 400,
});
});
it("creates a stable local setup token with private file permissions", () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-setup-"));
try {
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '')
const path = join(directory, 'workspace.sqlite')
vi.stubEnv('TWITTER_LITE_DB_PATH', path)
const token = getSetupToken()
expect(token.length).toBeGreaterThanOrEqual(32)
expect(getSetupToken()).toBe(token)
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token)
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600)
vi.stubEnv("WORKSPACE_SETUP_TOKEN", "");
const path = join(directory, "workspace.sqlite");
vi.stubEnv("TWITTER_LITE_DB_PATH", path);
const token = getSetupToken();
expect(token.length).toBeGreaterThanOrEqual(32);
expect(getSetupToken()).toBe(token);
expect(readFileSync(`${path}.setup-token`, "utf8")).toBe(token);
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600);
} finally {
rmSync(directory, { recursive: true, force: true })
rmSync(directory, { recursive: true, force: true });
}
})
})
});
});
+25 -24
View File
@@ -1,36 +1,37 @@
import { getSession, readAuthState } from './auth.server'
import { sessionToken } from './http.server'
import { getSession, readAuthState } from "./auth.server";
import { sessionToken } from "./http.server";
import { safeReturnTo } from "./return-to";
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
const publicPaths = new Set(["/login", "/setup", "/api/auth"]);
export function checkSessionAccess(request: Request): Response | null {
const url = new URL(request.url)
if (publicPaths.has(url.pathname)) return null
const owner = getSession(sessionToken(request))
if (owner?.onboardingCompletedAt) return null
if (owner && url.pathname === '/onboarding') return null
const url = new URL(request.url);
if (publicPaths.has(url.pathname)) return null;
const owner = getSession(sessionToken(request));
if (owner?.onboardingCompletedAt) return null;
if (owner && url.pathname === "/onboarding") return null;
const document =
request.method === 'GET' &&
request.headers.get('accept')?.includes('text/html') &&
!url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/_serverFn/')
request.method === "GET" &&
request.headers.get("accept")?.includes("text/html") &&
!url.pathname.startsWith("/api/") &&
!url.pathname.startsWith("/_serverFn/");
if (document) {
const location = owner
? '/onboarding'
: readAuthState().needsSetup
? '/setup'
: '/login'
const location = owner ? "/onboarding" : readAuthState().needsSetup ? "/setup" : "/login";
return new Response(null, {
status: 303,
headers: { location, 'cache-control': 'no-store' },
})
headers: {
location:
location === "/login" && (url.pathname !== "/" || !!url.search)
? `/login?${new URLSearchParams({ returnTo: safeReturnTo(url.pathname + url.search) })}`
: location,
"cache-control": "no-store",
},
});
}
return Response.json(
{
error: owner
? 'Complete onboarding to continue.'
: 'Sign in to continue.',
error: owner ? "Complete onboarding to continue." : "Sign in to continue.",
},
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
)
{ status: owner ? 403 : 401, headers: { "cache-control": "no-store" } },
);
}
+48 -60
View File
@@ -7,97 +7,85 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
const SESSION_COOKIE = 'workspace_session'
const SESSION_COOKIE = "workspace_session";
export function sessionToken(request: Request) {
return request.headers
.get('cookie')
?.split(';')
.get("cookie")
?.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1)
?.slice(SESSION_COOKIE.length + 1);
}
export function authState(request: Request) {
return { ...readAuthState(), owner: getSession(sessionToken(request)) }
return { ...readAuthState(), owner: getSession(sessionToken(request)) };
}
function cookie(token: string, clear = false) {
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:')
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith("https:");
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? "; Secure" : ""}`;
}
export async function authEndpoint(request: Request) {
const headers = new Headers({
'cache-control': 'no-store',
vary: 'Cookie',
'content-type': 'application/json',
})
"cache-control": "no-store",
vary: "Cookie",
"content-type": "application/json",
});
try {
if (request.method === 'GET')
return Response.json(authState(request), { headers })
if (request.method !== 'POST')
return new Response(null, { status: 405, headers })
if (!request.headers.get('content-type')?.startsWith('application/json'))
throw new AuthError(400, 'Use a JSON request.')
const body = await request.text()
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
let data: Record<string, unknown>
if (request.method === "GET") return Response.json(authState(request), { headers });
if (request.method !== "POST") return new Response(null, { status: 405, headers });
if (!request.headers.get("content-type")?.startsWith("application/json"))
throw new AuthError(400, "Use a JSON request.");
const body = await request.text();
if (body.length > 8192) throw new AuthError(413, "Request is too large.");
let data: Record<string, unknown>;
try {
const parsed = JSON.parse(body)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('invalid body')
data = parsed
const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("invalid body");
data = parsed;
} catch {
throw new AuthError(400, 'Check the form and try again.')
throw new AuthError(400, "Check the form and try again.");
}
const text = (key: string) =>
typeof data[key] === 'string' ? data[key] : ''
const oldToken = sessionToken(request)
const text = (key: string) => (typeof data[key] === "string" ? data[key] : "");
const oldToken = sessionToken(request);
switch (data.action) {
case 'setup':
case 'login': {
case "setup":
case "login": {
const result =
data.action === 'setup'
data.action === "setup"
? await createOwner({
email: text('email'),
password: text('password'),
name: text('name'),
setupToken: text('setupToken'),
email: text("email"),
password: text("password"),
name: text("name"),
setupToken: text("setupToken"),
})
: await signIn({ email: text('email'), password: text('password') })
signOut(oldToken)
headers.set('set-cookie', cookie(result.sessionToken))
return Response.json(
{ needsSetup: false, owner: result.owner },
{ headers },
)
: await signIn({ email: text("email"), password: text("password") });
signOut(oldToken);
headers.set("set-cookie", cookie(result.sessionToken));
return Response.json({ needsSetup: false, owner: result.owner }, { headers });
}
case 'onboard': {
const owner = completeOnboarding(oldToken ?? '', { name: text('name') })
return Response.json({ needsSetup: false, owner }, { headers })
case "onboard": {
const owner = completeOnboarding(oldToken ?? "", { name: text("name") });
return Response.json({ needsSetup: false, owner }, { headers });
}
case 'logout':
signOut(oldToken)
headers.set('set-cookie', cookie('', true))
return Response.json(
{ needsSetup: readAuthState().needsSetup, owner: null },
{ headers },
)
case "logout":
signOut(oldToken);
headers.set("set-cookie", cookie("", true));
return Response.json({ needsSetup: readAuthState().needsSetup, owner: null }, { headers });
default:
throw new AuthError(400, 'Unknown action.')
throw new AuthError(400, "Unknown action.");
}
} catch (error) {
if (error instanceof AuthError)
return Response.json(
{ error: error.message },
{ status: error.status, headers },
)
return Response.json({ error: error.message }, { status: error.status, headers });
return Response.json(
{ error: 'Unable to complete the request. Please try again.' },
{ error: "Unable to complete the request. Please try again." },
{ status: 500, headers },
)
);
}
}
+33 -46
View File
@@ -1,55 +1,47 @@
import { useRouteContext } from '@tanstack/react-router'
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import { AuthFrame } from './auth-page'
import { useRouteContext } from "@tanstack/react-router";
import { ArrowRight, BookOpen, House, MessagesSquare } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { AuthFrame } from "./auth-page";
export function OnboardingPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [name, setName] = useState(auth.owner?.name ?? '')
const [step, setStep] = useState(1)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const [name, setName] = useState(auth.owner?.name ?? "");
const [step, setStep] = useState(1);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function finish() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'onboard', name: name.trim() })
window.location.assign('/')
await authRequest({ action: "onboard", name: name.trim() });
window.location.assign("/");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to save. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to save. Please try again.");
setPending(false);
}
}
async function signOut() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
await authRequest({ action: "logout" });
window.location.assign("/login");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign out. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign out. Please try again.");
setPending(false);
}
}
function next(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) return
setStep(2)
event.preventDefault();
if (!name.trim()) return;
setStep(2);
}
return (
@@ -116,20 +108,15 @@ export function OnboardingPage() {
</li>
</ul>
<p className="auth-prototype">
Home, contacts, notes, reading, and vitals currently use sample
data. You can add connections from your profile menu whenever you’re
ready.
Home, contacts, notes, reading, and vitals currently use sample data. You can add
connections from your profile menu whenever you’re ready.
</p>
<div className="auth-actions">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
Back
</Button>
<Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'}
{pending ? "Saving…" : "Open workspace"}
<ArrowRight aria-hidden="true" />
</Button>
</div>
@@ -147,5 +134,5 @@ export function OnboardingPage() {
</Button>
</footer>
</AuthFrame>
)
);
}
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { safeReturnTo } from "./return-to";
it("keeps the selected view through login", () => {
expect(safeReturnTo("/inbox?q=agents&article=browser#read")).toBe(
"/inbox?q=agents&article=browser#read",
);
});
it.each([
"https://outside.invalid",
"//outside.invalid",
"/\\outside.invalid",
"/api/auth",
"/login",
"/inbox\n",
undefined,
])("rejects an unsafe destination: %s", (value) => {
expect(safeReturnTo(value)).toBe("/");
});
+15
View File
@@ -0,0 +1,15 @@
const paths = new Set(["/", "/deck", "/support", "/journal", "/inbox", "/vitals"]);
export function safeReturnTo(value: unknown): string {
if (
typeof value !== "string" ||
!value.startsWith("/") ||
value.startsWith("//") ||
/[\\\r\n]/.test(value)
)
return "/";
const url = new URL(value, "https://workspace.invalid");
return url.origin === "https://workspace.invalid" && paths.has(url.pathname)
? `${url.pathname}${url.search}${url.hash}`
: "/";
}
+9 -9
View File
@@ -1,14 +1,14 @@
import { createIsomorphicFn } from '@tanstack/react-start'
import type { AuthState } from './auth-client'
import { createIsomorphicFn } from "@tanstack/react-start";
import type { AuthState } from "./auth-client";
export const loadAuthState = createIsomorphicFn()
.server(async (): Promise<AuthState> => {
const { getRequest } = await import('@tanstack/react-start/server')
const { authState } = await import('./http.server')
return authState(getRequest())
const { getRequest } = await import("@tanstack/react-start/server");
const { authState } = await import("./http.server");
return authState(getRequest());
})
.client(async (): Promise<AuthState> => {
const response = await fetch('/api/auth', { cache: 'no-store' })
if (!response.ok) throw new Error('Unable to check your session.')
return response.json()
})
const response = await fetch("/api/auth", { cache: "no-store" });
if (!response.ok) throw new Error("Unable to check your session.");
return response.json();
});
@@ -1,60 +1,50 @@
import { useQuery } from '@tanstack/react-query'
import { useRouteContext } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { authRequest } from '#/features/auth/auth-client'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { useState } from "react";
import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button";
import { authRequest } from "#/features/auth/auth-client";
import { ConnectionManager } from "./connection-manager";
import { loadConnections } from "./server-functions";
export function ConnectionManagerDialog({
onClose,
hasTemporaryDecks,
}: {
onClose: () => void
hasTemporaryDecks?: boolean
onClose: () => void;
hasTemporaryDecks?: boolean;
}) {
const { auth } = useRouteContext({ from: '__root__' })
const [signingOut, setSigningOut] = useState(false)
const [error, setError] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const [signingOut, setSigningOut] = useState(false);
const [error, setError] = useState("");
async function logout() {
setSigningOut(true)
setError('')
setSigningOut(true);
setError("");
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
await authRequest({ action: "logout" });
window.location.assign("/login");
} catch {
setError('Unable to sign out. Please try again.')
setSigningOut(false)
setError("Unable to sign out. Please try again.");
setSigningOut(false);
}
}
const fetchConnections = useServerFn(loadConnections)
const fetchConnections = useServerFn(loadConnections);
const connections = useQuery({
queryKey: ['connections'],
queryKey: ["connections"],
queryFn: () => fetchConnections(),
retry: false,
})
});
return (
<Dialog
title="Connected accounts"
onClose={onClose}
className="sm:max-w-2xl"
>
<Dialog title="Connected accounts" onClose={onClose} className="sm:max-w-2xl">
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
<div className="min-w-0">
<p className="truncate font-medium">{auth.owner?.name}</p>
<p className="truncate text-sm text-muted-foreground">
{auth.owner?.email}
</p>
<p className="truncate text-sm text-muted-foreground">{auth.owner?.email}</p>
</div>
<Button
variant="outline"
disabled={signingOut}
onClick={() => void logout()}
>
{signingOut ? 'Signing out…' : 'Sign out'}
<Button variant="outline" disabled={signingOut} onClick={() => void logout()}>
{signingOut ? "Signing out…" : "Sign out"}
</Button>
</div>
{error && <p role="alert">{error}</p>}
@@ -69,9 +59,7 @@ export function ConnectionManagerDialog({
</div>
) : (
<>
{connections.data.relayError && (
<p role="alert">{connections.data.relayError}</p>
)}
{connections.data.relayError && <p role="alert">{connections.data.relayError}</p>}
<ConnectionManager
connections={connections.data.connections}
onChanged={() => connections.refetch()}
@@ -80,5 +68,5 @@ export function ConnectionManagerDialog({
</>
)}
</Dialog>
)
);
}
@@ -1,46 +1,44 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { ConnectionManager } from './connection-manager'
import type { Connection } from './model'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { ConnectionManager } from "./connection-manager";
import type { Connection } from "./model";
const actions = vi.hoisted(() => ({
load: vi.fn(),
authorize: vi.fn(),
disconnect: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('../mastodon/server-functions', () => ({
load: vi.fn<typeof import("../mastodon/server-functions").loadMastodonInstances>(),
authorize: vi.fn<typeof import("../mastodon/server-functions").startMastodonOAuth>(),
disconnect: vi.fn<typeof import("../mastodon/server-functions").disconnectMastodonAccount>(),
}));
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("../mastodon/server-functions", () => ({
loadMastodonInstances: actions.load,
startMastodonOAuth: actions.authorize,
disconnectMastodonAccount: actions.disconnect,
}))
}));
const account: Connection = {
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
}
id: "mastodon-first",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "1",
displayName: "@first",
status: "connected",
};
beforeEach(() => {
vi.resetAllMocks()
vi.resetAllMocks();
actions.load.mockResolvedValue({
origins: ['https://mastodon.invalid', 'https://second.invalid'],
})
})
origins: ["https://mastodon.invalid", "https://second.invalid"],
});
});
function show(
connections: Connection[] = [account],
onChanged = vi.fn(),
onChanged = vi.fn<() => Promise<void> | void>(),
hasTemporaryDecks = false,
) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ConnectionManager
connections={connections}
@@ -48,92 +46,77 @@ function show(
hasTemporaryDecks={hasTemporaryDecks}
/>
</QueryClientProvider>,
)
);
}
it('shows per-account status and warns about temporary decks without blocking actions', async () => {
it("shows per-account status and warns about temporary decks without blocking actions", async () => {
show(
[
account,
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
],
[account, { ...account, id: "expired", displayName: "@expired", status: "expired" }],
vi.fn(),
true,
)
expect(screen.getByText('Connected')).toBeVisible()
expect(screen.getByText('Reconnect required')).toBeVisible()
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible()
expect(
await screen.findByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
);
expect(screen.getByText("Connected")).toBeVisible();
expect(screen.getByText("Reconnect required")).toBeVisible();
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible();
expect(await screen.findByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
});
it('uses the selected instance and reports a safe error when authorization fails', async () => {
actions.authorize.mockRejectedValue(
new Error('private server credential detail'),
)
show()
fireEvent.change(await screen.findByLabelText('Mastodon server'), {
target: { value: 'https://second.invalid' },
})
fireEvent.click(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
)
it("uses the selected instance and reports a safe error when authorization fails", async () => {
actions.authorize.mockRejectedValue(new Error("private server credential detail"));
show();
fireEvent.change(await screen.findByLabelText("Mastodon server"), {
target: { value: "https://second.invalid" },
});
fireEvent.click(screen.getByRole("button", { name: "Authorize with Mastodon" }));
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' },
data: { origin: "https://second.invalid" },
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not start Mastodon authorization',
)
expect(screen.queryByText(/private server/)).toBeNull()
expect(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not start Mastodon authorization",
);
expect(screen.queryByText(/private server/)).toBeNull();
expect(screen.getByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
});
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline'))
show()
await screen.findByLabelText('Mastodon server')
fireEvent.click(screen.getByRole('button', { name: 'Reconnect @first' }))
it("binds reconnect to the selected account rather than whichever instance is selected for addition", async () => {
actions.authorize.mockRejectedValue(new Error("offline"));
show();
await screen.findByLabelText("Mastodon server");
fireEvent.click(screen.getByRole("button", { name: "Reconnect @first" }));
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id },
}),
)
expect(await screen.findByRole('alert')).toBeVisible()
})
);
expect(await screen.findByRole("alert")).toBeVisible();
});
it('refreshes the account list after successful disconnect', async () => {
const onChanged = vi.fn().mockResolvedValue(undefined)
actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
it("refreshes the account list after successful disconnect", async () => {
const onChanged = vi.fn<() => Promise<void> | void>().mockResolvedValue(undefined);
actions.disconnect.mockResolvedValue({ disconnected: true });
show([account], onChanged);
fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id },
})
})
});
});
it('keeps account controls available after a failed disconnect', async () => {
actions.disconnect.mockRejectedValue(new Error('private token'))
const onChanged = vi.fn()
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not disconnect the account',
)
expect(onChanged).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: 'Disconnect @first' }),
).toBeEnabled()
})
it("keeps account controls available after a failed disconnect", async () => {
actions.disconnect.mockRejectedValue(new Error("private token"));
const onChanged = vi.fn<() => Promise<void> | void>();
show([account], onChanged);
fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not disconnect the account");
expect(onChanged).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Disconnect @first" })).toBeEnabled();
});
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodon server')
expect(screen.queryByRole('button', { name: 'Reconnect @first' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Disconnect @first' })).toBeNull()
})
it("does not offer Mastodon reconnect or revoke for Twitter relay accounts", async () => {
show([{ ...account, platform: "twitter", id: "twitter-first" }]);
await screen.findByLabelText("Mastodon server");
expect(screen.queryByRole("button", { name: "Reconnect @first" })).toBeNull();
expect(screen.queryByRole("button", { name: "Disconnect @first" })).toBeNull();
});
+51 -69
View File
@@ -1,88 +1,80 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useId, useState } from 'react'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { useId, useState } from "react";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import {
disconnectMastodonAccount,
loadMastodonInstances,
startMastodonOAuth,
} from '../mastodon/server-functions'
import type { Connection } from './model'
import './connection-manager.css'
} from "../mastodon/server-functions";
import type { Connection } from "./model";
const statusNames = {
connected: 'Connected',
disconnected: 'Disconnected',
expired: 'Reconnect required',
}
connected: "Connected",
disconnected: "Disconnected",
expired: "Reconnect required",
};
export function ConnectionManager({
connections,
onChanged,
hasTemporaryDecks = false,
}: {
connections: Connection[]
onChanged: () => unknown
hasTemporaryDecks?: boolean
connections: Connection[];
onChanged: () => unknown;
hasTemporaryDecks?: boolean;
}) {
const fetchInstances = useServerFn(loadMastodonInstances)
const authorize = useServerFn(startMastodonOAuth)
const disconnect = useServerFn(disconnectMastodonAccount)
const fetchInstances = useServerFn(loadMastodonInstances);
const authorize = useServerFn(startMastodonOAuth);
const disconnect = useServerFn(disconnectMastodonAccount);
const instances = useQuery({
queryKey: ['mastodon-instances'],
queryKey: ["mastodon-instances"],
queryFn: () => fetchInstances(),
retry: false,
})
const [selected, setSelected] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const selectId = useId()
const origin = selected || instances.data?.origins[0] || ''
});
const [selected, setSelected] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const selectId = useId();
const origin = selected || instances.data?.origins[0] || "";
async function connect(origin: string, connectionId?: string) {
setBusy(true)
setError(undefined)
setBusy(true);
setError(undefined);
try {
const result = await authorize({
data: { origin, ...(connectionId ? { connectionId } : {}) },
})
window.location.assign(result.authorizationUrl)
});
window.location.assign(result.authorizationUrl);
} catch {
setError(
'Could not start Mastodon authorization. Check the server and try again.',
)
setError("Could not start Mastodon authorization. Check the server and try again.");
} finally {
setBusy(false)
setBusy(false);
}
}
async function remove(connectionId: string) {
setBusy(true)
setError(undefined)
setBusy(true);
setError(undefined);
try {
await disconnect({ data: { connectionId } })
await disconnect({ data: { connectionId } });
try {
await onChanged()
await onChanged();
} catch {
setError(
'Account disconnected, but the list could not be refreshed. Reload the page.',
)
setError("Account disconnected, but the list could not be refreshed. Reload the page.");
}
} catch {
setError('Could not disconnect the account. Try again later.')
setError("Could not disconnect the account. Try again later.");
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<section
className="connection-manager"
aria-label="Connected account management"
>
<section className="connection-manager" aria-label="Connected account management">
<p className="connection-manager-description">
Connected accounts are available in columns on all your devices.
</p>
@@ -96,31 +88,25 @@ export function ConnectionManager({
<div className="connection-manager-account">
<strong>{connection.displayName}</strong>
<span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
{new URL(connection.origin).hostname}
</span>
<Badge
variant={
connection.status === 'connected' ? 'secondary' : 'outline'
}
>
<Badge variant={connection.status === "connected" ? "secondary" : "outline"}>
{statusNames[connection.status]}
</Badge>
</div>
{connection.platform === 'mastodon' && (
{connection.platform === "mastodon" && (
<div className="connection-manager-actions">
<Button
variant="outline"
type="button"
disabled={busy}
aria-label={`Reconnect ${connection.displayName}`}
onClick={() =>
void connect(connection.origin, connection.id)
}
onClick={() => void connect(connection.origin, connection.id)}
>
Reconnect
</Button>
{connection.status !== 'disconnected' && (
{connection.status !== "disconnected" && (
<Button
variant="destructive"
type="button"
@@ -140,8 +126,8 @@ export function ConnectionManager({
<form
className="connection-manager-add"
onSubmit={(event) => {
event.preventDefault()
if (origin) void connect(origin)
event.preventDefault();
if (origin) void connect(origin);
}}
>
<h3>Add a Mastodon account</h3>
@@ -150,11 +136,7 @@ export function ConnectionManager({
) : instances.isError ? (
<p role="alert">
Could not load servers.
<Button
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
<Button variant="outline" type="button" onClick={() => void instances.refetch()}>
Retry
</Button>
</p>
@@ -183,12 +165,12 @@ export function ConnectionManager({
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
Temporary decks will be lost when you leave to authorize. Save any
decks you want to keep first.
Temporary decks will be lost when you leave to authorize. Save any decks you want to
keep first.
</p>
)}
{busy && <p role="status">Working…</p>}
</form>
</section>
)
);
}
+42 -53
View File
@@ -1,11 +1,6 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { z } from "zod";
const envelopeSchema = z
.object({
@@ -15,83 +10,77 @@ const envelopeSchema = z
tag: z.string(),
ciphertext: z.string(),
})
.strict()
.strict();
function decodeBase64(value: string): Buffer {
const result = Buffer.from(value, 'base64')
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
return result
const result = Buffer.from(value, "base64");
if (result.toString("base64") !== value) throw new Error("Invalid encoding");
return result;
}
function loadKey() {
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
let encoded: string
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE;
if (!path) throw new Error("TWITTER_LITE_CREDENTIAL_KEY_FILE is required.");
let encoded: string;
try {
encoded = readFileSync(path, 'utf8').trim()
encoded = readFileSync(path, "utf8").trim();
} catch {
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
throw new Error("Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.");
}
let key: Buffer
let key: Buffer;
try {
key = decodeBase64(encoded)
if (key.length !== 32) throw new Error('Invalid key length')
key = decodeBase64(encoded);
if (key.length !== 32) throw new Error("Invalid key length");
} catch {
throw new Error(
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
)
"TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.",
);
}
return {
key,
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
}
keyId: createHash("sha256").update(key).digest("hex").slice(0, 32),
};
}
function associatedData(context: string, keyId: string) {
if (!context) throw new Error('A credential record and purpose are required.')
return Buffer.from(
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
)
if (!context) throw new Error("A credential record and purpose are required.");
return Buffer.from(JSON.stringify(["twitter-lite-credential", 1, keyId, context]));
}
/** Context must identify both record and purpose; persist the returned opaque JSON. */
export function encryptCredential(plaintext: string, context: string): string {
const { key, keyId } = loadKey()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
cipher.setAAD(associatedData(context, keyId))
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
const { key, keyId } = loadKey();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(associatedData(context, keyId));
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return JSON.stringify({
version: 1,
keyId,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
})
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
});
}
export function decryptCredential(serialized: string, context: string): string {
const { key, keyId } = loadKey()
const { key, keyId } = loadKey();
try {
const envelope = envelopeSchema.parse(JSON.parse(serialized))
if (envelope.keyId !== keyId) throw new Error('Different key')
const iv = decodeBase64(envelope.iv)
const tag = decodeBase64(envelope.tag)
if (iv.length !== 12 || tag.length !== 16)
throw new Error('Invalid envelope')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAAD(associatedData(context, keyId))
decipher.setAuthTag(tag)
const envelope = envelopeSchema.parse(JSON.parse(serialized));
if (envelope.keyId !== keyId) throw new Error("Different key");
const iv = decodeBase64(envelope.iv);
const tag = decodeBase64(envelope.tag);
if (iv.length !== 12 || tag.length !== 16) throw new Error("Invalid envelope");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAAD(associatedData(context, keyId));
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(decodeBase64(envelope.ciphertext)),
decipher.final(),
]).toString('utf8')
]).toString("utf8");
} catch {
throw new Error(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
"Credential could not be decrypted. Check the stored credential and encryption key.",
);
}
}
+97 -104
View File
@@ -1,126 +1,119 @@
// @vitest-environment node
import { randomBytes } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential, encryptCredential } from './credentials.server'
import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { decryptCredential, encryptCredential } from "./credentials.server";
let directory: string
let keyPath: string
const context = 'connection:first:access-token'
const secret = 'sensitive-token-秘密'
let directory: string;
let keyPath: string;
const context = "connection:first:access-token";
const secret = "sensitive-token-秘密";
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-'))
keyPath = join(directory, 'key')
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, {
directory = mkdtempSync(join(tmpdir(), "twitter-lite-credentials-"));
keyPath = join(directory, "key");
writeFileSync(keyPath, `${randomBytes(32).toString("base64")}\n`, {
mode: 0o600,
})
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath)
})
});
vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", keyPath);
});
afterEach(() => {
vi.unstubAllEnvs()
rmSync(directory, { recursive: true, force: true })
})
vi.unstubAllEnvs();
rmSync(directory, { recursive: true, force: true });
});
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => {
const first = encryptCredential(secret, context)
const second = encryptCredential(secret, context)
expect(first).not.toBe(second)
expect(first).not.toContain(secret)
expect(first).not.toContain(Buffer.from(secret).toString('base64'))
it("round-trips credentials with distinct randomized ciphertext and no plaintext", () => {
const first = encryptCredential(secret, context);
const second = encryptCredential(secret, context);
expect(first).not.toBe(second);
expect(first).not.toContain(secret);
expect(first).not.toContain(Buffer.from(secret).toString("base64"));
expect(JSON.parse(first)).toMatchObject({
version: 1,
keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
})
expect(decryptCredential(first, context)).toBe(secret)
expect(decryptCredential(second, context)).toBe(secret)
})
});
expect(decryptCredential(first, context)).toBe(secret);
expect(decryptCredential(second, context)).toBe(secret);
});
it.each([
'connection:second:access-token',
'oauth-app:first:client-secret',
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => {
const stored = encryptCredential(secret, context)
expect(() => decryptCredential(stored, otherContext)).toThrow(
'Credential could not be decrypted.',
)
})
it.each(["connection:second:access-token", "oauth-app:first:client-secret"])(
"rejects ciphertext moved to another record or purpose: %s",
(otherContext) => {
const stored = encryptCredential(secret, context);
expect(() => decryptCredential(stored, otherContext)).toThrow(
"Credential could not be decrypted.",
);
},
);
it.each([
'iv',
'tag',
'ciphertext',
'keyId',
'version',
])('rejects tampering with %s without disclosing the secret', (field) => {
const envelope = JSON.parse(encryptCredential(secret, context))
envelope[field] = 'tampered'
it.each(["iv", "tag", "ciphertext", "keyId", "version"])(
"rejects tampering with %s without disclosing the secret",
(field) => {
const envelope = JSON.parse(encryptCredential(secret, context));
envelope[field] = "tampered";
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
"Credential could not be decrypted. Check the stored credential and encryption key.",
);
},
);
it("rejects a validly encoded altered authentication tag", () => {
const envelope = JSON.parse(encryptCredential(secret, context));
const tag = Buffer.from(envelope.tag, "base64");
tag[0] = (tag[0] ?? 0) ^ 1;
envelope.tag = tag.toString("base64");
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
})
"Credential could not be decrypted.",
);
});
it('rejects a validly encoded altered authentication tag', () => {
const envelope = JSON.parse(encryptCredential(secret, context))
const tag = Buffer.from(envelope.tag, 'base64')
tag[0] = (tag[0] ?? 0) ^ 1
envelope.tag = tag.toString('base64')
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted.',
)
})
it("requires the original key after restart or restore", () => {
const originalKey = readFileSync(keyPath);
const stored = encryptCredential(secret, context);
writeFileSync(keyPath, randomBytes(32).toString("base64"));
expect(() => decryptCredential(stored, context)).toThrow("Credential could not be decrypted.");
writeFileSync(keyPath, originalKey);
expect(decryptCredential(stored, context)).toBe(secret);
});
it('requires the original key after restart or restore', () => {
const originalKey = readFileSync(keyPath)
const stored = encryptCredential(secret, context)
writeFileSync(keyPath, randomBytes(32).toString('base64'))
expect(() => decryptCredential(stored, context)).toThrow(
'Credential could not be decrypted.',
)
writeFileSync(keyPath, originalKey)
expect(decryptCredential(stored, context)).toBe(secret)
})
it.each(["not-json", "{}", '{"version":2}'])(
"rejects malformed or unsupported stored credentials: %s",
(serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
"Credential could not be decrypted.",
);
},
);
it.each([
'not-json',
'{}',
'{"version":2}',
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires an explicitly configured credential key', () => {
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
it("requires an explicitly configured credential key", () => {
vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", "");
expect(() => encryptCredential(secret, context)).toThrow(
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.',
)
})
"TWITTER_LITE_CREDENTIAL_KEY_FILE is required.",
);
});
it('reports an unreadable key without including the path or plaintext', () => {
rmSync(keyPath)
it("reports an unreadable key without including the path or plaintext", () => {
rmSync(keyPath);
expect(() => encryptCredential(secret, context)).toThrow(
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.',
)
})
"Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.",
);
});
it.each([
'',
'this is not base64',
Buffer.alloc(16).toString('base64'),
])('rejects invalid key material', (encoded) => {
writeFileSync(keyPath, encoded)
expect(() => encryptCredential(secret, context)).toThrow(
'must contain 32 random bytes encoded as base64.',
)
})
it.each(["", "this is not base64", Buffer.alloc(16).toString("base64")])(
"rejects invalid key material",
(encoded) => {
writeFileSync(keyPath, encoded);
expect(() => encryptCredential(secret, context)).toThrow(
"must contain 32 random bytes encoded as base64.",
);
},
);
it('refuses encryption without record binding', () => {
expect(() => encryptCredential(secret, '')).toThrow(
'A credential record and purpose are required.',
)
})
it("refuses encryption without record binding", () => {
expect(() => encryptCredential(secret, "")).toThrow(
"A credential record and purpose are required.",
);
});
+5 -5
View File
@@ -1,12 +1,12 @@
import { z } from 'zod'
import { z } from "zod";
const connectionSchema = z.object({
id: z.string().min(1),
platform: z.enum(['twitter', 'mastodon']),
platform: z.enum(["twitter", "mastodon"]),
origin: z.string().url(),
accountId: z.string().nullable(),
displayName: z.string(),
status: z.enum(['connected', 'disconnected', 'expired']),
})
status: z.enum(["connected", "disconnected", "expired"]),
});
export type Connection = z.infer<typeof connectionSchema>
export type Connection = z.infer<typeof connectionSchema>;
+40 -49
View File
@@ -1,73 +1,68 @@
import { randomUUID } from 'node:crypto'
import { asc, eq } from 'drizzle-orm'
import { ProfileUnavailableError } from '../profiles/errors'
import { fetchProfileNames } from '../profiles/profile-service.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import type { Connection } from './model'
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { ProfileUnavailableError } from "../profiles/errors";
import { fetchProfileNames } from "../profiles/profile-service.server";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections } from "../storage/schema";
import type { Connection } from "./model";
function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured)
throw new ProfileUnavailableError('Set TWITTER_RELAY_BASE_URL.')
return new URL(configured).origin
const configured = process.env.TWITTER_RELAY_BASE_URL;
if (!configured) throw new ProfileUnavailableError("Set TWITTER_RELAY_BASE_URL.");
return new URL(configured).origin;
}
async function syncTwitterConnections(
database: AppDatabase,
fetchProfiles: typeof fetchProfileNames,
) {
const origin = relayOrigin()
const origin = relayOrigin();
const profiles = new Set(
await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError(
'Could not verify connection profiles. Check the relay connection and try again.',
)
"Could not verify connection profiles. Check the relay connection and try again.",
);
}),
)
);
database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(eq(connections.platform, 'twitter'))
.all()
const now = Date.now()
const existing = tx.select().from(connections).where(eq(connections.platform, "twitter")).all();
const now = Date.now();
for (const connection of existing) {
const status =
connection.origin === origin &&
connection.relayProfile !== null &&
profiles.has(connection.relayProfile)
? 'connected'
: 'disconnected'
? "connected"
: "disconnected";
if (connection.status !== status) {
tx.update(connections)
.set({ status, updatedAt: now })
.where(eq(connections.id, connection.id))
.run()
.run();
}
}
const known = new Set(
existing
.filter((connection) => connection.origin === origin)
.map((connection) => connection.relayProfile),
)
);
for (const profile of profiles) {
if (known.has(profile)) continue
if (known.has(profile)) continue;
tx.insert(connections)
.values({
id: randomUUID(),
platform: 'twitter',
platform: "twitter",
origin,
relayProfile: profile,
displayName: profile,
status: 'connected',
status: "connected",
createdAt: now,
updatedAt: now,
})
.run()
.run();
}
})
return origin
});
return origin;
}
const publicFields = {
@@ -77,25 +72,25 @@ const publicFields = {
accountId: connections.accountId,
displayName: connections.displayName,
status: connections.status,
}
};
export async function listConnections(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<{ connections: Connection[]; relayError?: string }> {
let relayError: string | undefined
let relayError: string | undefined;
try {
await syncTwitterConnections(database, fetchProfiles)
await syncTwitterConnections(database, fetchProfiles);
} catch (error) {
if (!(error instanceof ProfileUnavailableError)) throw error
relayError = error.message
if (!(error instanceof ProfileUnavailableError)) throw error;
relayError = error.message;
}
const saved = database
.select(publicFields)
.from(connections)
.orderBy(asc(connections.createdAt), asc(connections.id))
.all()
return { connections: saved, ...(relayError ? { relayError } : {}) }
.all();
return { connections: saved, ...(relayError ? { relayError } : {}) };
}
export async function requireTwitterConnection(
@@ -103,21 +98,17 @@ export async function requireTwitterConnection(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<string> {
const origin = await syncTwitterConnections(database, fetchProfiles)
const connection = database
.select()
.from(connections)
.where(eq(connections.id, id))
.get()
const origin = await syncTwitterConnections(database, fetchProfiles);
const connection = database.select().from(connections).where(eq(connections.id, id)).get();
if (
connection?.platform !== 'twitter' ||
connection?.platform !== "twitter" ||
connection.origin !== origin ||
connection.status !== 'connected' ||
connection.status !== "connected" ||
!connection.relayProfile
) {
throw new ProfileUnavailableError(
'This connection is unavailable. Check the connected account for this column.',
)
"This connection is unavailable. Check the connected account for this column.",
);
}
return connection.relayProfile
return connection.relayProfile;
}
+100 -123
View File
@@ -1,157 +1,134 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { listConnections, requireTwitterConnection } from './repository.server'
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections } from "../storage/schema";
import { listConnections, requireTwitterConnection } from "./repository.server";
let database: AppDatabase
let database: AppDatabase;
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/')
})
database = openDatabase(":memory:");
vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://relay.invalid/");
});
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
})
database.$client.close();
vi.unstubAllEnvs();
});
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => {
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first'])
const { connections: first } = await listConnections(database, fetchProfiles)
const { connections: second } = await listConnections(database, fetchProfiles)
expect(first).toHaveLength(2)
expect(new Set(first.map((connection) => connection.id)).size).toBe(2)
expect(second).toEqual(first)
expect(
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles),
).toBe(first[0]?.displayName)
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid')
})
it("retains stable distinct IDs for multiple relay profiles across repeated discovery", async () => {
const fetchProfiles = vi
.fn<() => Promise<string[]>>()
.mockResolvedValue(["first", "second", "first"]);
const { connections: first } = await listConnections(database, fetchProfiles);
const { connections: second } = await listConnections(database, fetchProfiles);
expect(first).toHaveLength(2);
expect(new Set(first.map((connection) => connection.id)).size).toBe(2);
expect(second).toEqual(first);
expect(await requireTwitterConnection(first[0]?.id ?? "", database, fetchProfiles)).toBe(
first[0]?.displayName,
);
expect(fetchProfiles).toHaveBeenCalledWith("https://relay.invalid");
});
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
const id = original[0]?.id ?? ''
expect((await listConnections(database, async () => [])).connections).toEqual(
[expect.objectContaining({ id, status: 'disconnected' })],
)
await expect(
requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('This connection is unavailable')
expect(
(await listConnections(database, async () => ['first'])).connections,
).toEqual(original)
})
it("preserves unavailable bindings and reconnects the original ID when a profile returns", async () => {
const { connections: original } = await listConnections(database, async () => ["first"]);
const id = original[0]?.id ?? "";
expect((await listConnections(database, async () => [])).connections).toEqual([
expect.objectContaining({ id, status: "disconnected" }),
]);
await expect(requireTwitterConnection(id, database, async () => [])).rejects.toThrow(
"This connection is unavailable",
);
expect((await listConnections(database, async () => ["first"])).connections).toEqual(original);
});
it('does not reuse an old relay binding for the same profile at another origin', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
const { connections: current } = await listConnections(database, async () => [
'first',
])
expect(current).toHaveLength(2)
it("does not reuse an old relay binding for the same profile at another origin", async () => {
const { connections: original } = await listConnections(database, async () => ["first"]);
vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://another-relay.invalid");
const { connections: current } = await listConnections(database, async () => ["first"]);
expect(current).toHaveLength(2);
expect(current).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }),
expect.objectContaining({ id: original[0]?.id, status: "disconnected" }),
expect.objectContaining({
origin: 'https://another-relay.invalid',
status: 'connected',
origin: "https://another-relay.invalid",
status: "connected",
}),
]),
)
);
await expect(
requireTwitterConnection(original[0]?.id ?? '', database, async () => [
'first',
]),
).rejects.toThrow('This connection is unavailable')
})
requireTwitterConnection(original[0]?.id ?? "", database, async () => ["first"]),
).rejects.toThrow("This connection is unavailable");
});
it('does not convert a relay outage into persisted account removal', async () => {
const original = await listConnections(database, async () => ['first'])
it("does not convert a relay outage into persisted account removal", async () => {
const original = await listConnections(database, async () => ["first"]);
const unavailable = vi
.fn()
.mockRejectedValue(new Error('private network detail'))
const failed = await listConnections(database, unavailable)
expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain(
'Check the relay connection and try again.',
)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(await listConnections(database, async () => ['first'])).toEqual(
original,
)
})
.fn<() => Promise<string[]>>()
.mockRejectedValue(new Error("private network detail"));
const failed = await listConnections(database, unavailable);
expect(failed.connections).toEqual(original.connections);
expect(failed.relayError).toContain("Check the relay connection and try again.");
expect(database.select().from(connections).get()?.status).toBe("connected");
expect(await listConnections(database, async () => ["first"])).toEqual(original);
});
it('returns public metadata for all platforms without joining or exposing credentials', async () => {
it("returns public metadata for all platforms without joining or exposing credentials", async () => {
database
.insert(connections)
.values({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
id: "mastodon-account",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "42",
displayName: "@owner",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
.run();
database
.insert(connectionCredentials)
.values({
connectionId: 'mastodon-account',
encryptedToken: 'stored-secret-envelope',
connectionId: "mastodon-account",
encryptedToken: "stored-secret-envelope",
updatedAt: 1,
})
.run()
const { connections: discovered } = await listConnections(
database,
async () => ['first'],
)
.run();
const { connections: discovered } = await listConnections(database, async () => ["first"]);
expect(discovered[0]).toEqual({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
})
id: "mastodon-account",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "42",
displayName: "@owner",
status: "connected",
});
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
'accountId',
'displayName',
'id',
'origin',
'platform',
'status',
])
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope')
"accountId",
"displayName",
"id",
"origin",
"platform",
"status",
]);
expect(JSON.stringify(discovered)).not.toContain("stored-secret-envelope");
const outage = await listConnections(database, async () => {
throw new Error('offline')
})
expect(outage.connections).toEqual(discovered)
expect(outage.relayError).toBeDefined()
throw new Error("offline");
});
expect(outage.connections).toEqual(discovered);
expect(outage.relayError).toBeDefined();
await expect(
requireTwitterConnection('mastodon-account', database, async () => [
'first',
]),
).rejects.toThrow('This connection is unavailable')
requireTwitterConnection("mastodon-account", database, async () => ["first"]),
).rejects.toThrow("This connection is unavailable");
expect(
database
.select()
.from(connections)
.where(eq(connections.id, 'mastodon-account'))
.get()?.status,
).toBe('connected')
})
database.select().from(connections).where(eq(connections.id, "mastodon-account")).get()?.status,
).toBe("connected");
});
it('rejects an unknown connection without treating its ID as a relay profile', async () => {
await expect(
requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('This connection is unavailable')
})
it("rejects an unknown connection without treating its ID as a relay profile", async () => {
await expect(requireTwitterConnection("first", database, async () => ["first"])).rejects.toThrow(
"This connection is unavailable",
);
});
+5 -7
View File
@@ -1,8 +1,6 @@
import { createServerFn } from '@tanstack/react-start'
import { createServerFn } from "@tanstack/react-start";
export const loadConnections = createServerFn({ method: 'GET' }).handler(
async () => {
const { listConnections } = await import('./repository.server')
return listConnections()
},
)
export const loadConnections = createServerFn({ method: "GET" }).handler(async () => {
const { listConnections } = await import("./repository.server");
return listConnections();
});
+128 -138
View File
@@ -1,177 +1,167 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { Connection } from '../connections/model'
import { ColumnEditor } from './column-editor'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import type { Connection } from "../connections/model";
import { ColumnEditor } from "./column-editor";
const lists = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('#/features/platforms/mastodon-server-functions', () => ({
const lists = vi.hoisted(() =>
vi.fn<typeof import("../platforms/mastodon-server-functions").loadMastodonLists>(),
);
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("#/features/platforms/mastodon-server-functions", () => ({
loadMastodonLists: lists,
}))
vi.mock('#/features/posts/use-list-choices', () => ({
}));
vi.mock("#/features/posts/use-list-choices", () => ({
useListChoices: () => ({
isPending: false,
isError: false,
data: [],
refetch: vi.fn(),
refetch: vi.fn<() => void>(),
}),
}))
}));
const connections: Connection[] = [
{
id: 'twitter',
platform: 'twitter',
origin: 'https://relay.invalid',
id: "twitter",
platform: "twitter",
origin: "https://relay.invalid",
accountId: null,
displayName: 'Twitter account',
status: 'connected',
displayName: "Twitter account",
status: "connected",
},
{
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
id: "mastodon-first",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "1",
displayName: "@first",
status: "connected",
},
{
id: 'mastodon-second',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '2',
displayName: '@second',
status: 'connected',
id: "mastodon-second",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "2",
displayName: "@second",
status: "connected",
},
]
];
beforeEach(() => {
lists
.mockReset()
.mockImplementation(
async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === 'mastodon-first' ? '10' : '20',
name: data.connectionId,
isPrivate: true,
},
],
}),
)
})
lists.mockReset().mockImplementation(async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === "mastodon-first" ? "10" : "20",
name: data.connectionId,
isPrivate: true,
},
],
}));
});
function show() {
const onSave = vi.fn().mockResolvedValue(true)
const onSave = vi
.fn<(column: import("./model").DeckColumn) => Promise<boolean>>()
.mockResolvedValue(true);
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ColumnEditor
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
<ColumnEditor connections={connections} onSave={onSave} onCancel={vi.fn<() => void>()} />
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('Column name'), {
target: { value: '調査' },
})
return onSave
);
fireEvent.change(screen.getByLabelText("Column name"), {
target: { value: "調査" },
});
return onSave;
}
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('Sort by')).toBeNull()
expect(screen.queryByLabelText('Following only')).toBeNull()
expect(screen.getByText(/Zero results/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodon search query'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
it("creates a Mastodon search without Twitter ordering or following fields", async () => {
const save = show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
expect(screen.queryByLabelText("Sort by")).toBeNull();
expect(screen.queryByLabelText("Following only")).toBeNull();
expect(screen.getByText(/Zero results/)).toBeVisible();
fireEvent.change(screen.getByLabelText("Mastodon search query"), {
target: { value: "WebMCP" },
});
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'mastodon-first',
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' },
connectionId: "mastodon-first",
source: { platform: "mastodon", kind: "search", query: "WebMCP" },
}),
),
)
})
);
});
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('Hashtag'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
it("creates hashtags and resets unsupported source kind when changing to Twitter", async () => {
const save = show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: "hashtag" },
});
fireEvent.change(screen.getByLabelText("Hashtag"), {
target: { value: "WebMCP" },
});
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
source: { platform: "mastodon", kind: "hashtag", target: "WebMCP" },
}),
),
)
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'twitter' },
})
expect(screen.getByLabelText('Column type')).toHaveValue('search')
expect(screen.getByLabelText('Twitter search query')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull()
})
);
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "twitter" },
});
expect(screen.getByLabelText("Column type")).toHaveValue("search");
expect(screen.getByLabelText("Twitter search query")).toHaveValue("");
expect(screen.queryByRole("option", { name: "Hashtag" })).toBeNull();
});
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('List')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('List')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('List')).toHaveValue('20')
it("loads lists for each account and clears the selected list when the binding changes", async () => {
show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: "list" },
});
fireEvent.click(await screen.findByRole("button", { name: "mastodon-first" }));
expect(screen.getByLabelText("List")).toHaveValue("10");
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-second" },
});
expect(screen.getByLabelText("List")).toHaveValue("");
fireEvent.click(await screen.findByRole("button", { name: "mastodon-second" }));
expect(screen.getByLabelText("List")).toHaveValue("20");
expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' },
})
})
data: { connectionId: "mastodon-second" },
});
});
it('preserves title and Twitter search options after a rejected save', async () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitter search query'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('Sort by'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('Column name')).toHaveValue('調査')
expect(screen.getByLabelText('Twitter search query')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('Sort by')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
it("preserves title and Twitter search options after a rejected save", async () => {
const save = show();
save.mockResolvedValue(false);
fireEvent.change(screen.getByLabelText("Twitter search query"), {
target: { value: "from:owner WebMCP" },
});
fireEvent.change(screen.getByLabelText("Sort by"), {
target: { value: "Top" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Following only" }));
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => expect(save).toHaveBeenCalledOnce());
expect(screen.getByLabelText("Column name")).toHaveValue("調査");
expect(screen.getByLabelText("Twitter search query")).toHaveValue("from:owner WebMCP");
expect(screen.getByLabelText("Sort by")).toHaveValue("Top");
expect(screen.getByRole("checkbox", { name: "Following only" })).toBeChecked();
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => expect(save).toHaveBeenCalledTimes(2));
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source);
});
+40 -53
View File
@@ -1,15 +1,15 @@
import { useId, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { Connection } from '#/features/connections/model'
import { useId, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { Connection } from "#/features/connections/model";
import {
ColumnSourceEditor,
defaultColumnSource,
rebindColumnSource,
} from './column-source-editor'
import { columnSchema, type DeckColumn } from './model'
} from "./column-source-editor";
import { columnSchema, type DeckColumn } from "./model";
export function ColumnEditor({
column,
@@ -17,50 +17,41 @@ export function ColumnEditor({
onSave,
onCancel,
}: {
column?: DeckColumn
connections: Connection[]
onSave: (column: DeckColumn) => Promise<boolean>
onCancel: () => void
column?: DeckColumn;
connections: Connection[];
onSave: (column: DeckColumn) => Promise<boolean>;
onCancel: () => void;
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const formId = useId()
const [error, setError] = useState<string>()
const [connectionId, setConnectionId] = useState(
column?.connectionId ?? available[0]?.id ?? '',
)
const available = connections.filter((connection) => connection.status === "connected");
const formId = useId();
const [error, setError] = useState<string>();
const [connectionId, setConnectionId] = useState(column?.connectionId ?? available[0]?.id ?? "");
const [source, setSource] = useState(
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'),
)
const validConnection = available.some(
(connection) => connection.id === connectionId,
)
column?.source ?? defaultColumnSource(available[0]?.platform ?? "twitter"),
);
const validConnection = available.some((connection) => connection.id === connectionId);
return (
<form
className="deck-editor"
aria-label={column ? 'Edit column' : 'Add column'}
aria-label={column ? "Edit column" : "Add column"}
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
const data = new FormData(event.currentTarget)
event.preventDefault();
setError(undefined);
const data = new FormData(event.currentTarget);
const parsed = columnSchema.safeParse({
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
id: column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join("-"),
title: data.get("title"),
connectionId,
source,
})
});
if (!parsed.success || !validConnection) {
setError(
parsed.success
? 'Select a connection profile.'
: parsed.error.issues[0]?.message,
)
return
parsed.success ? "Select a connection profile." : parsed.error.issues[0]?.message,
);
return;
}
await onSave(parsed.data)
await onSave(parsed.data);
}}
>
<div className="deck-editor-fields">
@@ -69,7 +60,7 @@ export function ColumnEditor({
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
defaultValue={column?.title ?? ""}
maxLength={80}
placeholder="e.g. Announcements"
required
@@ -83,12 +74,10 @@ export function ColumnEditor({
value={connectionId}
required
onChange={(event) => {
const connection = available.find(
(value) => value.id === event.target.value,
)
if (!connection) return
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
const connection = available.find((value) => value.id === event.target.value);
if (!connection) return;
setConnectionId(connection.id);
setSource(rebindColumnSource(source, connection.platform));
}}
>
<NativeSelectOption value="" disabled>
@@ -102,7 +91,7 @@ export function ColumnEditor({
{available.map((connection) => (
<NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
{connection.platform === "mastodon" ? " · Mastodon" : ""}
</NativeSelectOption>
))}
</NativeSelect>
@@ -110,12 +99,10 @@ export function ColumnEditor({
</div>
<ColumnSourceEditor
source={source}
connectionId={validConnection ? connectionId : ''}
connectionId={validConnection ? connectionId : ""}
onChange={setSource}
/>
<p className="deck-help">
This column uses the selected connection profile.
</p>
<p className="deck-help">This column uses the selected connection profile.</p>
{error && (
<p role="alert" className="deck-error">
{error}
@@ -123,12 +110,12 @@ export function ColumnEditor({
)}
<div className="deck-actions">
<Button type="submit" disabled={!validConnection}>
{column ? 'Save changes' : 'Add and search'}
{column ? "Save changes" : "Add and search"}
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
)
);
}
+26 -45
View File
@@ -1,41 +1,30 @@
import type { ReactElement } from 'react'
import type { Connection } from '../connections/model'
import {
defaultMastodonSource,
MastodonSourceEditor,
} from './mastodon-source-editor'
import type { DeckColumn } from './model'
import {
defaultTwitterSource,
TwitterSourceEditor,
} from './twitter-source-editor'
import type { ReactElement } from "react";
import type { Connection } from "../connections/model";
import { defaultMastodonSource, MastodonSourceEditor } from "./mastodon-source-editor";
import type { DeckColumn } from "./model";
import { defaultTwitterSource, TwitterSourceEditor } from "./twitter-source-editor";
type Source = DeckColumn['source']
type Source = DeckColumn["source"];
export function defaultColumnSource(platform: Connection['platform']): Source {
export function defaultColumnSource(platform: Connection["platform"]): Source {
switch (platform) {
case 'twitter':
return defaultTwitterSource()
case 'mastodon':
return defaultMastodonSource()
case "twitter":
return defaultTwitterSource();
case "mastodon":
return defaultMastodonSource();
}
}
/** Account-local targets never carry across bindings; compatible searches may. */
export function rebindColumnSource(
source: Source,
platform: Connection['platform'],
): Source {
export function rebindColumnSource(source: Source, platform: Connection["platform"]): Source {
if (source.platform === platform) {
return source.kind === 'search' ? source : { ...source, target: '' }
return source.kind === "search" ? source : { ...source, target: "" };
}
switch (platform) {
case 'twitter':
return defaultTwitterSource(
source.kind === 'hashtag' ? 'search' : source.kind,
)
case 'mastodon':
return defaultMastodonSource(source.kind)
case "twitter":
return defaultTwitterSource(source.kind === "hashtag" ? "search" : source.kind);
case "mastodon":
return defaultMastodonSource(source.kind);
}
}
@@ -44,26 +33,18 @@ export function ColumnSourceEditor({
connectionId,
onChange,
}: {
source: Source
connectionId: string
onChange: (source: Source) => void
source: Source;
connectionId: string;
onChange: (source: Source) => void;
}): ReactElement {
switch (source.platform) {
case 'twitter':
case "twitter":
return (
<TwitterSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'mastodon':
<TwitterSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
);
case "mastodon":
return (
<MastodonSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
<MastodonSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
);
}
}
+55 -69
View File
@@ -1,117 +1,103 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import {
flattenResearchPages,
type useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
import { useEffect, useRef } from "react";
import { useWebMCP } from "usewebmcp";
import { z } from "zod";
import { flattenResearchPages, type useResearchFeed } from "#/features/platforms/use-research-feed";
import { PostLoadError } from "#/features/posts/use-post-feed";
import type { DeckColumn } from "./model";
import { useWebMCPSupported } from "./use-webmcp-supported";
import { toolResult } from "./webmcp-contracts";
type ColumnFeed = {
column: DeckColumn
query: ReturnType<typeof useResearchFeed>
}
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
column: DeckColumn;
query: ReturnType<typeof useResearchFeed>;
};
export type ColumnRegistry = Map<string, { current: ColumnFeed }>;
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof useResearchFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
const entry = useRef({ column, query });
useEffect(() => {
registry.set(column.id, entry)
entry.current = { column, query };
}, [column, query]);
useEffect(() => {
registry.set(column.id, entry);
return () => {
if (registry.get(column.id) === entry) registry.delete(column.id)
}
}, [registry, column.id])
if (registry.get(column.id) === entry) registry.delete(column.id);
};
}, [registry, column.id]);
}
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
const columnInput = z.object({ columnId: z.string().min(1) }).strict();
const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20),
})
});
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenResearchPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
const posts = flattenResearchPages(feed.query.data?.pages ?? []);
const selected = posts.slice(offset, offset + limit);
return {
column: feed.column,
status: feed.query.isPending
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
status: feed.query.isPending ? "loading" : feed.query.isError ? "error" : "ready",
loading: feed.query.isFetching,
error:
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
error: feed.query.error instanceof PostLoadError ? feed.query.error.detail : null,
posts: selected,
loadedCount: posts.length,
offset,
nextOffset:
offset + selected.length < posts.length ? offset + selected.length : null,
nextOffset: offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage),
}
};
}
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported()
const enabled = supported && ready
const supported = useWebMCPSupported();
const enabled = supported && ready;
function requireColumn(id: string) {
const entry = registry.get(id)
const entry = registry.get(id);
if (!entry)
throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
)
return entry
"Column is not loaded in the active deck. Select its deck and wait for it to render.",
);
return entry;
}
useWebMCP({
name: 'get_column_posts',
name: "get_column_posts",
description:
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
"Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.",
inputSchema: readInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input)
return read(requireColumn(columnId).current, offset, limit)
const { columnId, offset, limit } = readInput.parse(input);
return read(requireColumn(columnId).current, offset, limit);
}),
})
});
useWebMCP({
name: 'load_more_column',
name: "load_more_column",
description:
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
"Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.",
inputSchema: columnInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
const { columnId } = columnInput.parse(input)
const entry = requireColumn(columnId)
const { column, query } = entry.current
const identity = JSON.stringify(column)
const { columnId } = columnInput.parse(input);
const entry = requireColumn(columnId);
const { column, query } = entry.current;
const identity = JSON.stringify(column);
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.')
if (query.isError && !query.isFetchNextPageError) throw query.error
const offset = flattenResearchPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
registry.get(columnId) !== entry ||
JSON.stringify(entry.current.column) !== identity
)
throw new Error(
'The deck or column changed while loading. Read the current deck first.',
)
if (result.isError) throw result.error
return read({ column, query: result }, offset)
throw new Error("Wait for the column to finish loading.");
if (query.isError && !query.isFetchNextPageError) throw query.error;
const offset = flattenResearchPages(query.data?.pages ?? []).length;
if (!query.hasNextPage) return read(entry.current, offset);
const result = await query.fetchNextPage({ cancelRefetch: false });
if (registry.get(columnId) !== entry || JSON.stringify(entry.current.column) !== identity)
throw new Error("The deck or column changed while loading. Read the current deck first.");
if (result.isError) throw result.error;
return read({ column, query: result }, offset);
}),
})
});
}
+64 -59
View File
@@ -1,63 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import type { CitationTarget } from '../research/use-citation-navigation'
import { ResearchColumn } from './deck-column'
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { ResearchPost } from "../platforms/types";
import type { CitationTarget } from "../research/use-citation-navigation";
import { ResearchColumn } from "./deck-column";
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() }))
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({
...(await importOriginal<typeof import('../platforms/use-research-feed')>()),
const feed = vi.hoisted(() => ({
use: vi.fn<
() => {
data: { pages: readonly { posts: readonly ResearchPost[]; nextCursor?: string }[] };
hasNextPage?: boolean;
fetchNextPage?: () => void;
}
>(),
next: vi.fn<() => void>(),
}));
vi.mock("../platforms/use-research-feed", async (importOriginal) => ({
...(await importOriginal<typeof import("../platforms/use-research-feed")>()),
useResearchFeed: feed.use,
}))
vi.mock('./research-post-card', () => ({
}));
vi.mock("./research-post-card", () => ({
ResearchPostCard: ({
post,
citationSnapshot,
}: {
post: ResearchPost
citationSnapshot?: boolean
post: ResearchPost;
citationSnapshot?: boolean;
}) => (
<article>
{post.text}
{citationSnapshot ? 'Post saved during research' : ''}
{citationSnapshot ? "Post saved during research" : ""}
</article>
),
}))
}));
const citation: CitationTarget = {
deckId: 'deck',
deckId: "deck",
request: 1,
column: {
id: 'column',
title: 'Research',
connectionId: 'account',
id: "column",
title: "Research",
connectionId: "account",
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
post: {
key: 'twitter:123',
platform: 'twitter',
nativeId: '123',
url: 'https://x.com/alice/status/123',
text: 'Snapshot',
author: { name: 'Alice', handle: 'alice' },
key: "twitter:123",
platform: "twitter",
nativeId: "123",
url: "https://x.com/alice/status/123",
text: "Snapshot",
author: { name: "Alice", handle: "alice" },
},
}
};
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => {
it("temporarily renders missing evidence without inserting it into feed pages or changing pagination", () => {
const page = Object.freeze({
posts: Object.freeze([]),
nextCursor: 'original-cursor',
})
nextCursor: "original-cursor",
});
feed.use.mockReturnValue({
data: { pages: [page] },
hasNextPage: true,
fetchNextPage: feed.next,
})
});
render(
<ResearchColumn
column={citation.column}
@@ -65,21 +74,19 @@ it('temporarily renders missing evidence without inserting it into feed pages or
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
onEdit={vi.fn<() => void>()}
onMove={vi.fn<() => void>()}
onRemove={vi.fn<() => void>()}
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'SnapshotPost saved during research',
)
expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
);
expect(screen.getByRole("article")).toHaveTextContent("SnapshotPost saved during research");
expect(page.posts).toHaveLength(0);
expect(page.nextCursor).toBe("original-cursor");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false });
});
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => {
it("keeps the live post instead of a duplicate snapshot with a Twitter URL alias", () => {
feed.use.mockReturnValue({
data: {
pages: [
@@ -87,14 +94,14 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
posts: [
{
...citation.post,
url: 'https://mobile.twitter.com/alice/status/123',
text: 'Live',
url: "https://mobile.twitter.com/alice/status/123",
text: "Live",
},
],
},
],
},
})
});
render(
<ResearchColumn
column={citation.column}
@@ -102,14 +109,12 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
onEdit={vi.fn<() => void>()}
onMove={vi.fn<() => void>()}
onRemove={vi.fn<() => void>()}
/>,
)
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|Post saved during research/),
).not.toBeInTheDocument()
})
);
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(screen.getByRole("article")).toHaveTextContent("Live");
expect(screen.queryByText(/Snapshot|Post saved during research/)).not.toBeInTheDocument();
});
+37 -47
View File
@@ -1,25 +1,22 @@
import { Icon } from '#/components/icon'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Icon } from "#/components/icon";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu'
import {
flattenResearchPages,
useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import { citationUrlKey } from '#/features/research/citation-url'
import type { CitationTarget } from '#/features/research/use-citation-navigation'
import { PlatformIcon } from '../platforms/platform-icon'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
import { describeSource } from './source-description'
} from "#/components/ui/dropdown-menu";
import { flattenResearchPages, useResearchFeed } from "#/features/platforms/use-research-feed";
import { PostLoadError } from "#/features/posts/use-post-feed";
import { citationUrlKey } from "#/features/research/citation-url";
import type { CitationTarget } from "#/features/research/use-citation-navigation";
import { PlatformIcon } from "../platforms/platform-icon";
import { type ColumnRegistry, useRegisterColumn } from "./column-tools";
import type { DeckColumn } from "./model";
import { ResearchPostCard } from "./research-post-card";
import { describeSource } from "./source-description";
export function ResearchColumn({
column,
@@ -32,30 +29,30 @@ export function ResearchColumn({
registry,
citation,
}: {
column: DeckColumn
connectionLabel?: string
onEdit: () => void
onRemove: () => void
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
citation?: CitationTarget
column: DeckColumn;
connectionLabel?: string;
onEdit: () => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
first: boolean;
last: boolean;
registry: ColumnRegistry;
citation?: CitationTarget;
}) {
const query = useResearchFeed(column)
useRegisterColumn(registry, column, query)
const posts = flattenResearchPages(query.data?.pages ?? [])
const citedKey = citation ? citationUrlKey(citation.post.url) : null
const query = useResearchFeed(column);
useRegisterColumn(registry, column, query);
const posts = flattenResearchPages(query.data?.pages ?? []);
const citedKey = citation ? citationUrlKey(citation.post.url) : null;
const visiblePosts =
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
? [citation.post, ...posts]
: posts
: posts;
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: 'Unable to load posts.', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
: { message: "Unable to load posts.", retryable: true };
const headingId = `column-${column.id}`;
const sourceDescription = describeSource(column.source);
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
@@ -114,10 +111,8 @@ export function ResearchColumn({
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`}
>
{connectionLabel ?? 'Connection not found'}
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
{connectionLabel ?? "Connection not found"}
</span>
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
@@ -126,12 +121,12 @@ export function ResearchColumn({
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`}
{query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`Posts in ${column.title}`}
>
@@ -140,9 +135,7 @@ export function ResearchColumn({
key={post.key}
post={post}
citationRequest={
citation && citationUrlKey(post.url) === citedKey
? citation.request
: undefined
citation && citationUrlKey(post.url) === citedKey ? citation.request : undefined
}
citationSnapshot={citation?.post === post && !posts.includes(post)}
/>
@@ -181,13 +174,10 @@ export function ResearchColumn({
</Button>
</div>
) : null}
{!query.isPending &&
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
{!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
<p className="deck-message">No more posts.</p>
) : null}
</section>
</section>
)
);
}
+99 -127
View File
@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useLocation } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { useQuery } from "@tanstack/react-query";
import { useLocation } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { useState } from "react";
import { AppShell } from "#/components/app-shell";
import { Dialog } from "#/components/dialog";
import { Icon } from "#/components/icon";
import { Button } from "#/components/ui/button";
import {
Empty,
EmptyContent,
@@ -13,36 +13,34 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
} from "#/components/ui/empty";
import { Input } from "#/components/ui/input";
import { loadConnections } from "#/features/connections/server-functions";
import { syncResearchDeck } from "#/features/research/research-deck-sync";
import { ResearchPanel } from "#/features/research/research-panel";
import {
sameCitationSource,
useCitationNavigation,
} from '#/features/research/use-citation-navigation'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
import { DeckSwitcher } from './deck-switcher'
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
import { useDeck } from './use-deck'
import { useDeckTools } from './webmcp-tools'
import './decks.css'
} from "#/features/research/use-citation-navigation";
import { ColumnEditor } from "./column-editor";
import { type ColumnRegistry, useColumnTools } from "./column-tools";
import { ResearchColumn } from "./deck-column";
import { DeckSwitcher } from "./deck-switcher";
import { type DeckColumn, MAX_COLUMNS, moveColumn } from "./model";
import { useDeck } from "./use-deck";
import { useDeckTools } from "./webmcp-tools";
export function DeckPage() {
const oauthResult = useLocation({
select: (location) =>
new URLSearchParams(location.searchStr).get('mastodon'),
})
const registry = useRef<ColumnRegistry>(new Map()).current
const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null
select: (location) => new URLSearchParams(location.searchStr).get("mastodon"),
});
const [registry] = useState<ColumnRegistry>(() => new Map());
const [researchDeckBindings] = useState(() => new Map<string, string>());
const [editing, setEditing] = useState<{ id: string } | "new" | null>(null);
const [renaming, setRenaming] = useState(false);
const [deckDialog, setDeckDialog] = useState<"create" | "delete" | null>(null);
const [newDeckTitle, setNewDeckTitle] = useState("");
const switcherEditing = deckDialog !== null;
const {
deck,
workspace,
@@ -59,24 +57,24 @@ export function DeckPage() {
select,
remove,
getWorkspace,
} = useDeck(editing !== null || renaming || switcherEditing)
} = useDeck(editing !== null || renaming || switcherEditing);
const citationNavigation = useCitationNavigation({
registry,
getWorkspace,
select,
createTemporary,
})
const fetchProfiles = useServerFn(loadConnections)
});
const fetchProfiles = useServerFn(loadConnections);
const profiles = useQuery({
queryKey: ['connections'],
queryKey: ["connections"],
queryFn: () => fetchProfiles(),
retry: false,
})
useColumnTools(registry, ready)
});
useColumnTools(registry, ready);
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
column: DeckColumn;
index: number;
}>();
useDeckTools({
ready,
@@ -90,28 +88,26 @@ export function DeckPage() {
persist,
connections: profiles.data?.connections,
onApplied: clearEditors,
})
});
function clearEditors() {
setDeckDialog(null)
setNewDeckTitle('')
setEditing(null)
setRenaming(false)
setRemoved(undefined)
setDeckDialog(null);
setNewDeckTitle("");
setEditing(null);
setRenaming(false);
setRemoved(undefined);
}
async function saveColumn(column: DeckColumn) {
const saved = await save({
...deck,
columns:
editing === 'new'
editing === "new"
? [...deck.columns, column]
: deck.columns.map((current) =>
current.id === column.id ? column : current,
),
})
if (saved) setEditing(null)
return saved
: deck.columns.map((current) => (current.id === column.id ? column : current)),
});
if (saved) setEditing(null);
return saved;
}
const storageErrorNotice = storageError ? (
@@ -122,14 +118,14 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
clearEditors();
void refresh(true);
}}
>
Reload latest deck
</Button>
</p>
) : null
) : null;
return (
<ResearchPanel
@@ -147,8 +143,8 @@ export function DeckPage() {
createTemporary,
save,
select,
})
if (activate) clearEditors()
});
if (activate) clearEditors();
}}
>
{(renderResearchPanel) => (
@@ -167,22 +163,22 @@ export function DeckPage() {
newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => {
setDeckDialog(dialog)
if (dialog !== 'create') setNewDeckTitle('')
setDeckDialog(dialog);
if (dialog !== "create") setNewDeckTitle("");
}}
onSelect={(id) => {
select(id)
clearEditors()
select(id);
clearEditors();
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
const saved = await create(title);
if (saved) clearEditors();
return saved;
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
const saved = await remove(deck.id);
if (saved) clearEditors();
return saved;
}}
/>
<Button
@@ -190,18 +186,16 @@ export function DeckPage() {
aria-label="Add column"
title="Add column"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
}
onClick={() => setEditing('new')}
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing("new")}
>
<Icon name="plus" />
<span>Add column</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'Saved to server · Shared across devices'
: 'Temporary view · This tab only'}
? "Saved to server · Shared across devices"
: "Temporary view · This tab only"}
</p>
</>
}
@@ -220,8 +214,8 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title} copy`, deck.columns)
clearEditors()
createTemporary(`${deck.title} copy`, deck.columns);
clearEditors();
}}
>
Create temporary copy
@@ -238,7 +232,6 @@ export function DeckPage() {
)}
<Button
variant="outline"
className="icon-button"
aria-label="Rename"
title="Rename"
type="button"
@@ -275,22 +268,17 @@ export function DeckPage() {
{profiles.isError ? (
<p role="alert" className="deck-error">
Unable to load connection profiles.
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
Retry
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
{oauthResult === "failed" ? (
<p className="deck-error" role="alert">
Could not complete Mastodon authorization. Try again from
Connected accounts.
Could not complete Mastodon authorization. Try again from Connected accounts.
</p>
) : null}
{oauthResult === 'connected' ? (
{oauthResult === "connected" ? (
<p className="deck-save-status" role="status">
Mastodon account connected. You can now use it in a column.
</p>
@@ -300,23 +288,18 @@ export function DeckPage() {
{profiles.data.relayError}
</p>
) : null}
{profiles.isPending ? (
<p role="status">Loading connection profiles…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{profiles.isPending ? <p role="status">Loading connection profiles…</p> : null}
{!renaming && editing === null && !switcherEditing ? storageErrorNotice : null}
{renaming ? (
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
event.preventDefault();
const rawTitle = new FormData(event.currentTarget).get("title");
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (!title) return;
if (await save({ ...deck, title })) setRenaming(false);
}}
>
<label htmlFor="deck-title">
@@ -338,14 +321,14 @@ export function DeckPage() {
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'Add column' : 'Edit column'}
title={editing === "new" ? "Add column" : "Edit column"}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
key={`${deck.id}-${editing === "new" ? "new" : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
editing === "new"
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
@@ -361,13 +344,11 @@ export function DeckPage() {
<Button
variant="outline"
type="button"
disabled={
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
disabled={deck.columns.length >= MAX_COLUMNS || editing === "new"}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
const columns = [...deck.columns];
columns.splice(removed.index, 0, removed.column);
if (await save({ ...deck, columns })) setRemoved(undefined);
}}
>
Undo
@@ -384,13 +365,12 @@ export function DeckPage() {
<h2>Start a deck with a question</h2>
</EmptyTitle>
<EmptyDescription>
Tell the assistant what you want to research to create
relevant columns. You can also add search, user posts, and
list columns yourself.
Tell the assistant what you want to research to create relevant columns. You can
also add search, user posts, and list columns yourself.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
<Button type="button" onClick={() => setEditing("new")}>
Add column manually
</Button>
</EmptyContent>
@@ -400,7 +380,7 @@ export function DeckPage() {
<section
className="deck-board"
aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
tabIndex={0}
>
{deck.columns.map((column, index) => (
@@ -423,22 +403,14 @@ export function DeckPage() {
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onMove={(direction) => save(moveColumn(deck, column.id, direction))}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null)
columns: deck.columns.filter((current) => current.id !== column.id),
});
if (saved) setRemoved({ column, index });
if (typeof editing === "object" && editing?.id === column.id) setEditing(null);
}}
/>
))}
@@ -447,5 +419,5 @@ export function DeckPage() {
</AppShell>
)}
</ResearchPanel>
)
);
}
+34 -44
View File
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { DeckView } from './model'
import type { ReactNode } from "react";
import { Dialog } from "#/components/dialog";
import { Icon } from "#/components/icon";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { DeckView } from "./model";
export function DeckSwitcher({
decks,
@@ -20,17 +20,17 @@ export function DeckSwitcher({
onNewTitleChange,
errorNotice,
}: {
decks: DeckView[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
dialog: 'create' | 'delete' | null
onDialogChange: (dialog: 'create' | 'delete' | null) => void
newTitle: string
onNewTitleChange: (title: string) => void
errorNotice?: ReactNode
decks: DeckView[];
activeId: string;
ready: boolean;
onSelect: (id: string) => void;
onCreate: (title: string) => Promise<boolean>;
onDelete: () => Promise<boolean>;
dialog: "create" | "delete" | null;
onDialogChange: (dialog: "create" | "delete" | null) => void;
newTitle: string;
onNewTitleChange: (title: string) => void;
errorNotice?: ReactNode;
}) {
return (
<div className="deck-workspaces">
@@ -42,26 +42,25 @@ export function DeckSwitcher({
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
onDialogChange(null)
onSelect(event.target.value);
onDialogChange(null);
}}
>
{decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : ' (temporary)'}
{deck.persisted ? "" : " (temporary)"}
</NativeSelectOption>
))}
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="Create deck"
title="Create deck"
type="button"
disabled={!ready}
onClick={() => {
onDialogChange('create')
onDialogChange("create");
}}
>
<Icon name="plus" />
@@ -69,28 +68,27 @@ export function DeckSwitcher({
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="Delete deck"
title="Delete deck"
type="button"
disabled={!ready}
onClick={() => {
onDialogChange('delete')
onDialogChange("delete");
}}
>
<Icon name="trash" />
<span>Delete deck</span>
</Button>
</div>
{dialog === 'create' ? (
{dialog === "create" ? (
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = newTitle.trim()
if (!title) return
if (await onCreate(title)) onDialogChange(null)
event.preventDefault();
const title = newTitle.trim();
if (!title) return;
if (await onCreate(title)) onDialogChange(null);
}}
>
<Label htmlFor="new-deck-title">
@@ -105,18 +103,14 @@ export function DeckSwitcher({
/>
</Label>
<Button type="submit">Create</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
<Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
Cancel
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{dialog === 'delete' ? (
{dialog === "delete" ? (
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>Delete this deck and its column settings.</p>
@@ -124,16 +118,12 @@ export function DeckSwitcher({
variant="destructive"
type="button"
onClick={async () => {
if (await onDelete()) onDialogChange(null)
if (await onDelete()) onDialogChange(null);
}}
>
Delete
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
<Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
Cancel
</Button>
</div>
@@ -141,5 +131,5 @@ export function DeckSwitcher({
</Dialog>
) : null}
</div>
)
);
}
+28 -34
View File
@@ -1,49 +1,43 @@
import { createHash, randomUUID } from 'node:crypto'
import { and, eq } from 'drizzle-orm'
import { getDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { legacyWorkspaceSchema } from './legacy-import'
import {
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
import { createHash, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { getDatabase } from "../storage/database.server";
import { connections, legacyImports } from "../storage/schema";
import { legacyWorkspaceSchema } from "./legacy-import";
import { createDeck, DeckPersistenceError, listDecks } from "./repository.server";
export function importLegacyWorkspace(
input: unknown,
database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? "").origin,
) {
const legacy = legacyWorkspaceSchema.parse(input)
const payloadHash = createHash('sha256')
.update(JSON.stringify(legacy.decks))
.digest('hex')
const legacy = legacyWorkspaceSchema.parse(input);
const payloadHash = createHash("sha256").update(JSON.stringify(legacy.decks)).digest("hex");
return database.transaction(
() => {
const marker = database
.select()
.from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2'))
.get()
.where(eq(legacyImports.id, "local-storage-v2"))
.get();
if (marker) {
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'Legacy decks have already been imported and cannot be overwritten.',
)
return listDecks(database)
"conflict",
"Legacy decks have already been imported and cannot be overwritten.",
);
return listDecks(database);
}
const available = database
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'twitter'),
eq(connections.platform, "twitter"),
eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'),
eq(connections.status, "connected"),
),
)
.all()
.all();
const imported = legacy.decks.map((deck) =>
createDeck(
{
@@ -52,29 +46,29 @@ export function importLegacyWorkspace(
columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find(
(connection) => connection.relayProfile === profileName,
)
);
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
"connection-unavailable",
`No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
)
return { ...column, connectionId: connection.id }
);
return { ...column, connectionId: connection.id };
}),
},
database,
),
)
);
database
.insert(legacyImports)
.values({
id: 'local-storage-v2',
id: "local-storage-v2",
payloadHash,
deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(),
})
.run()
return listDecks(database)
.run();
return listDecks(database);
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
+55 -65
View File
@@ -1,98 +1,88 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { importLegacyWorkspace } from './import.server'
import { createDeck, listDecks } from './repository.server'
import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connections, legacyImports } from "../storage/schema";
import { importLegacyWorkspace } from "./import.server";
import { createDeck, listDecks } from "./repository.server";
let database: AppDatabase
let database: AppDatabase;
const legacy = {
version: 2,
activeDeckId: 'same-id',
activeDeckId: "same-id",
decks: [
{
id: 'same-id',
title: 'Legacy',
id: "same-id",
title: "Legacy",
columns: [
{
id: 'a',
title: 'Account',
profileName: 'old-profile',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
id: "a",
title: "Account",
profileName: "old-profile",
source: { platform: "twitter", kind: "user", target: "alice" },
},
],
},
],
}
};
beforeEach(() => {
database = openDatabase(':memory:')
database = openDatabase(":memory:");
database
.insert(connections)
.values({
id: 'new-connection-id',
platform: 'twitter',
origin: 'https://relay.invalid',
relayProfile: 'old-profile',
displayName: 'Old account',
status: 'connected',
id: "new-connection-id",
platform: "twitter",
origin: "https://relay.invalid",
relayProfile: "old-profile",
displayName: "Old account",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
.run();
});
afterEach(() => database.$client.close());
it("imports legacy profiles as connection bindings without overwriting existing same-ID decks", () => {
const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] },
{ id: "same-id", title: "Existing server deck", columns: [] },
database,
)
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
);
const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
expect(result).toHaveLength(2);
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing);
const imported = result.find((deck) => deck.title === "Legacy");
expect(imported?.id).not.toBe("same-id");
expect(imported?.columns[0]).toEqual({
id: 'a',
title: 'Account',
connectionId: 'new-connection-id',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
})
expect(
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
).toEqual(result)
})
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
id: "a",
title: "Account",
connectionId: "new-connection-id",
source: { platform: "twitter", kind: "user", target: "alice" },
});
expect(importLegacyWorkspace(legacy, database, "https://relay.invalid")).toEqual(result);
});
it("blocks a different browser snapshot after the one-time import", () => {
const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
{ ...legacy, decks: [{ ...legacy.decks[0], title: "Different" }] },
database,
'https://relay.invalid',
"https://relay.invalid",
),
).toThrow('Legacy decks have already been imported')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
).toThrow("Legacy decks have already been imported");
expect(listDecks(database)).toEqual(result);
});
it("rolls back all imported decks and marker when any profile is unavailable", () => {
const second = {
...legacy.decks[0],
id: 'second',
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
}
id: "second",
columns: [{ ...legacy.decks[0]?.columns[0], profileName: "missing" }],
};
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] },
database,
'https://relay.invalid',
"https://relay.invalid",
),
).toThrow('No connection matches')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
).toThrow("No connection matches");
expect(listDecks(database)).toEqual([]);
expect(database.select().from(legacyImports).all()).toEqual([]);
});
+10 -13
View File
@@ -1,10 +1,10 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
import { z } from "zod";
import { columnSchema, deckSchema, MAX_COLUMNS } from "./model";
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
export const LEGACY_STORAGE_KEY = "twitter-lite-research-deck";
const legacyColumn = columnSchema
.omit({ connectionId: true })
.extend({ profileName: z.string().min(1) })
.extend({ profileName: z.string().min(1) });
const legacyDeck = z
.object({
id: deckSchema.shape.id,
@@ -13,11 +13,9 @@ const legacyDeck = z
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'Column IDs must be unique.',
)
(deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
"Column IDs must be unique.",
);
export const legacyWorkspaceSchema = z
.object({
version: z.literal(2),
@@ -26,7 +24,6 @@ export const legacyWorkspaceSchema = z
})
.strict()
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'Deck IDs must be unique.',
)
(value) => new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
"Deck IDs must be unique.",
);
+62 -95
View File
@@ -1,30 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
import { useQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { type ReactElement, useId } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { loadMastodonLists } from "../platforms/mastodon-server-functions";
import type { MastodonSource } from "../platforms/mastodon-source";
type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T
onChange: (source: MastodonSource) => void
}
source: T;
onChange: (source: MastodonSource) => void;
};
export function defaultMastodonSource(
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
export function defaultMastodonSource(kind: MastodonSource["kind"] = "search"): MastodonSource {
switch (kind) {
case 'search':
return { platform: 'mastodon', kind, query: '' }
case 'user':
return { platform: 'mastodon', kind, target: '' }
case 'list':
return { platform: 'mastodon', kind, target: '' }
case 'hashtag':
return { platform: 'mastodon', kind, target: '' }
case "search":
return { platform: "mastodon", kind, query: "" };
case "user":
return { platform: "mastodon", kind, target: "" };
case "list":
return { platform: "mastodon", kind, target: "" };
case "hashtag":
return { platform: "mastodon", kind, target: "" };
}
}
@@ -33,7 +31,7 @@ export function MastodonSourceEditor({
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
const id = useId();
return (
<>
<Label htmlFor={id}>
@@ -43,11 +41,7 @@ export function MastodonSourceEditor({
id={id}
value={source.kind}
onChange={(event) =>
onChange(
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
onChange(defaultMastodonSource(event.target.value as MastodonSource["kind"]))
}
>
<NativeSelectOption value="search">Search</NativeSelectOption>
@@ -56,13 +50,9 @@ export function MastodonSourceEditor({
<NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
<MastodonSourceFields source={source} connectionId={connectionId} onChange={onChange} />
</>
)
);
}
function MastodonSourceFields({
@@ -71,28 +61,22 @@ function MastodonSourceFields({
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <MastodonSearchEditor source={source} onChange={onChange} />
case 'user':
return <MastodonUserEditor source={source} onChange={onChange} />
case 'list':
return (
<MastodonListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
case "search":
return <MastodonSearchEditor source={source} onChange={onChange} />;
case "user":
return <MastodonUserEditor source={source} onChange={onChange} />;
case "list":
return <MastodonListEditor source={source} connectionId={connectionId} onChange={onChange} />;
case "hashtag":
return <MastodonHashtagEditor source={source} onChange={onChange} />;
}
}
function MastodonSearchEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -100,27 +84,25 @@ function MastodonSearchEditor({
<Input
id={id}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
onChange={(event) => onChange({ ...source, query: event.target.value })}
maxLength={512}
placeholder="e.g. WebMCP"
required
/>
</Label>
<p className="deck-help">
Full-text search depends on server settings. Zero results do not
necessarily mean search is supported.
Full-text search depends on server settings. Zero results do not necessarily mean search is
supported.
</p>
</div>
)
);
}
function MastodonUserEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -128,23 +110,21 @@ function MastodonUserEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="user@server or account ID"
maxLength={256}
required
/>
</Label>
</div>
)
);
}
function MastodonHashtagEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -152,26 +132,24 @@ function MastodonHashtagEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="Tag without #"
maxLength={128}
required
/>
</Label>
</div>
)
);
}
function MastodonListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
connectionId: string
}: EditorProps<Extract<MastodonSource, { kind: "list" }>> & {
connectionId: string;
}) {
const id = useId()
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -179,9 +157,7 @@ function MastodonListEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="List ID for this account"
maxLength={32}
required
@@ -194,52 +170,43 @@ function MastodonListEditor({
/>
)}
</div>
)
);
}
function MastodonListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
connectionId: string;
onSelect: (target: string) => void;
}) {
const fetchLists = useServerFn(loadMastodonLists)
const fetchLists = useServerFn(loadMastodonLists);
const lists = useQuery({
queryKey: ['mastodon-lists', connectionId],
queryKey: ["mastodon-lists", connectionId],
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">Loading lists…</p>
});
if (lists.isPending) return <p role="status">Loading lists…</p>;
if (lists.isError)
return (
<p role="alert">
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
<Button variant="outline" type="button" onClick={() => void lists.refetch()}>
Retry
</Button>
</p>
)
);
return (
<section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? (
<p>This account has no lists.</p>
) : (
lists.data.lists.map((list) => (
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
<Button variant="outline" type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
</Button>
))
)}
</section>
)
);
}
+44 -47
View File
@@ -1,39 +1,39 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
import { describe, expect, it } from "vitest";
import { type Deck, deckSchema, moveColumn } from "./model";
const deck: Deck = {
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id: "research",
title: "調査",
columns: ["a", "b"].map((id) => ({
id,
title: id,
connectionId: 'main',
connectionId: "main",
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "AI lang:ja",
product: "Latest",
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('normalizes user and list targets and rejects invalid targets', () => {
const column = deck.columns[0]
};
describe("deck definitions", () => {
it("preserves native search and the bound relay profile when serialized", () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck);
});
it("normalizes user and list targets and rejects invalid targets", () => {
const column = deck.columns[0];
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' },
source: { platform: "twitter", kind: "user", target: "@alice" },
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
).toEqual({ platform: "twitter", kind: "user", target: "alice" });
expect(
deckSchema.parse({
...deck,
@@ -41,41 +41,41 @@ describe('deck definitions', () => {
{
...column,
source: {
platform: 'twitter',
kind: 'list',
target: 'https://x.com/i/lists/123',
platform: "twitter",
kind: "list",
target: "https://x.com/i/lists/123",
},
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
).toEqual({ platform: "twitter", kind: "list", target: "123" });
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
source: { platform: "twitter", kind: "list", target: "invalid" },
},
],
}).success,
).toBe(false)
})
it('rejects duplicate identities and missing profiles', () => {
).toBe(false);
});
it("rejects duplicate identities and missing profiles", () => {
expect(
deckSchema.safeParse({
...deck,
columns: [deck.columns[0], deck.columns[0]],
}).success,
).toBe(false)
).toBe(false);
expect(
deckSchema.safeParse({
...deck,
columns: [{ ...deck.columns[0], connectionId: '' }],
columns: [{ ...deck.columns[0], connectionId: "" }],
}).success,
).toBe(false)
})
it('rejects queries exceeding the upstream limit after adding follows', () => {
).toBe(false);
});
it("rejects queries exceeding the upstream limit after adding follows", () => {
expect(
deckSchema.safeParse({
...deck,
@@ -83,23 +83,20 @@ describe('deck definitions', () => {
{
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
platform: "twitter",
kind: "search",
query: "a".repeat(512),
product: "Latest",
following: true,
},
},
],
}).success,
).toBe(false)
})
it('moves a column without changing its conditions or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
])
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
expect(moveColumn(deck, 'a', -1)).toBe(deck)
})
})
).toBe(false);
});
it("moves a column without changing its conditions or the previous deck", () => {
expect(moveColumn(deck, "b", -1).columns).toEqual([deck.columns[1], deck.columns[0]]);
expect(deck.columns.map((column) => column.id)).toEqual(["a", "b"]);
expect(moveColumn(deck, "a", -1)).toBe(deck);
});
});
+45 -52
View File
@@ -1,33 +1,32 @@
import { z } from 'zod'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { z } from "zod";
import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
} from "#/features/posts/inputs";
export const MAX_COLUMNS = 6
export const MAX_COLUMNS = 6;
const searchSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('search'),
platform: z.literal("twitter"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
product: z.enum(["Latest", "Top"]),
following: z.boolean(),
})
.strict()
.superRefine((source, ctx) => {
try {
buildSearchQuery(source.query, source.following)
buildSearchQuery(source.query, source.following);
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : 'Check the search query.',
})
code: "custom",
message: error instanceof Error ? error.message : "Check the search query.",
});
}
})
});
function targetSchema(normalize: (target: string) => string) {
return z
@@ -37,31 +36,31 @@ function targetSchema(normalize: (target: string) => string) {
.max(256)
.transform((target, ctx) => {
try {
return normalize(target)
return normalize(target);
} catch (error) {
ctx.addIssue({
code: 'custom',
message: error instanceof Error ? error.message : 'Check the target.',
})
return z.NEVER
code: "custom",
message: error instanceof Error ? error.message : "Check the target.",
});
return z.NEVER;
}
})
});
}
const userSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('user'),
platform: z.literal("twitter"),
kind: z.literal("user"),
target: targetSchema(normalizeUserTarget),
})
.strict()
.strict();
const listSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('list'),
platform: z.literal("twitter"),
kind: z.literal("list"),
target: targetSchema(normalizeListTarget),
})
.strict()
.strict();
export const columnSchema = z
.object({
@@ -69,15 +68,11 @@ export const columnSchema = z
title: z.string().trim().min(1).max(80),
connectionId: z.string().min(1),
source: z.union([
z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
z.discriminatedUnion("kind", [searchSourceSchema, userSourceSchema, listSourceSchema]),
mastodonSourceSchema,
]),
})
.strict()
.strict();
export const deckSchema = z
.object({
@@ -87,35 +82,33 @@ export const deckSchema = z
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'Column IDs must be unique.',
)
(deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
"Column IDs must be unique.",
);
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export type Deck = z.infer<typeof deckSchema>;
export type DeckColumn = z.infer<typeof columnSchema>;
export type DeckView = Deck & { persisted: boolean; revision?: number };
export type Workspace = { activeDeckId: string; decks: DeckView[] };
export const EMPTY_DECK: Deck = {
id: 'initial',
title: 'New research',
id: "initial",
title: "New research",
columns: [],
}
};
export const EMPTY_WORKSPACE: Workspace = {
activeDeckId: EMPTY_DECK.id,
decks: [{ ...EMPTY_DECK, persisted: false }],
}
};
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
return crypto.getRandomValues(new Uint32Array(4)).join("-");
}
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
const columns = [...deck.columns]
const [column] = columns.splice(index, 1)
if (column) columns.splice(target, 0, column)
return { ...deck, columns }
const index = deck.columns.findIndex((column) => column.id === id);
const target = index + direction;
if (index < 0 || target < 0 || target >= deck.columns.length) return deck;
const columns = [...deck.columns];
const [column] = columns.splice(index, 1);
if (column) columns.splice(target, 0, column);
return { ...deck, columns };
}
+17 -17
View File
@@ -1,36 +1,36 @@
import { z } from 'zod'
import { deckSchema } from './model'
import { z } from "zod";
import { deckSchema } from "./model";
export const savedDeckDefinitionSchema = deckSchema
export const savedDeckDefinitionSchema = deckSchema;
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
});
export const replaceDeckInputSchema = z
.object({
deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(),
})
.strict()
.strict();
export const deleteDeckInputSchema = z
.object({
id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(),
})
.strict()
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
export type SavedDeck = z.infer<typeof savedDeckSchema>
.strict();
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>;
export type SavedDeck = z.infer<typeof savedDeckSchema>;
export type DeckPersistenceFailure = {
ok: false
ok: false;
error: {
code:
| 'conflict'
| 'not-found'
| 'connection-unavailable'
| 'invalid-input'
| 'storage-unavailable'
message: string
}
}
| "conflict"
| "not-found"
| "connection-unavailable"
| "invalid-input"
| "storage-unavailable";
message: string;
};
};
+60 -72
View File
@@ -1,6 +1,6 @@
import { and, asc, eq } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections, deckColumns, decks } from '../storage/schema'
import { and, asc, eq } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections, deckColumns, decks } from "../storage/schema";
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
@@ -9,33 +9,27 @@ import {
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from './persistence-model'
} from "./persistence-model";
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure['error']['code'],
readonly code: DeckPersistenceFailure["error"]["code"],
message: string,
) {
super(message)
super(message);
}
}
const conflict = () =>
new DeckPersistenceError(
'conflict',
'This deck changed on another device. Reload the latest deck.',
)
"conflict",
"This deck changed on another device. Reload the latest deck.",
);
const missing = () =>
new DeckPersistenceError(
'not-found',
'This deck was deleted or could not be found.',
)
new DeckPersistenceError("not-found", "This deck was deleted or could not be found.");
export function loadDeck(
id: string,
database = getDatabase(),
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
export function loadDeck(id: string, database = getDatabase()): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get();
if (!record) return null;
const columns = database
.select({
id: deckColumns.id,
@@ -46,8 +40,8 @@ export function loadDeck(
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all()
return savedDeckSchema.parse({ ...record, columns })
.all();
return savedDeckSchema.parse({ ...record, columns });
}
export function listDecks(database = getDatabase()): SavedDeck[] {
@@ -58,11 +52,11 @@ export function listDecks(database = getDatabase()): SavedDeck[] {
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database)
if (!deck) throw missing()
return deck
const deck = loadDeck(id, database);
if (!deck) throw missing();
return deck;
}),
)
);
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
@@ -71,22 +65,22 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get()
.get();
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== 'connected'
connection.status !== "connected"
) {
throw new DeckPersistenceError(
'connection-unavailable',
'The account for this column is unavailable. Check your connections.',
)
"connection-unavailable",
"The account for this column is unavailable. Check your connections.",
);
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run();
if (deck.columns.length)
database
.insert(deckColumns)
@@ -97,29 +91,26 @@ function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
position,
})),
)
.run()
.run();
}
export function createDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
export function createDeck(input: unknown, database = getDatabase()): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
const existing = loadDeck(deck.id, database);
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
})
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
return existing
});
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict();
return existing;
}
validateConnections(deck, database)
const now = Date.now()
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
validateConnections(deck, database);
const now = Date.now();
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now };
database
.insert(decks)
.values({
@@ -129,56 +120,53 @@ export function createDeck(
createdAt: now,
updatedAt: now,
})
.run()
writeColumns(deck, database)
return result
.run();
writeColumns(deck, database);
return result;
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export function replaceDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
export function replaceDeck(input: unknown, database = getDatabase()): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (!existing) throw missing()
if (existing.revision !== expectedRevision) throw conflict()
validateConnections(deck, database)
const updatedAt = Date.now()
const existing = loadDeck(deck.id, database);
if (!existing) throw missing();
if (existing.revision !== expectedRevision) throw conflict();
validateConnections(deck, database);
const updatedAt = Date.now();
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
writeColumns(deck, database)
.run();
if (result.changes !== 1) throw conflict();
writeColumns(deck, database);
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
}
};
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
const { id, expectedRevision } = deleteDeckInputSchema.parse(input);
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing()
if (!loadDeck(id, database)) throw missing();
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
return id
.run();
if (result.changes !== 1) throw conflict();
return id;
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
+128 -157
View File
@@ -1,183 +1,154 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections, deckColumns } from "../storage/schema";
import type { SavedDeckDefinition } from "./persistence-model";
import { createDeck, deleteDeck, listDecks, loadDeck, replaceDeck } from "./repository.server";
let directory: string
let database: AppDatabase
let second: AppDatabase
let directory: string;
let database: AppDatabase;
let second: AppDatabase;
const deck: SavedDeckDefinition = {
id: 'request-123',
title: 'Research',
id: "request-123",
title: "Research",
columns: [
{
id: 'first',
title: 'First',
connectionId: 'twitter-one',
id: "first",
title: "First",
connectionId: "twitter-one",
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
{
id: 'second',
title: 'Second',
connectionId: 'twitter-two',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
id: "second",
title: "Second",
connectionId: "twitter-two",
source: { platform: "twitter", kind: "user", target: "alice" },
},
],
}
};
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
database = openDatabase(join(directory, 'app.db'))
second = openDatabase(join(directory, 'app.db'))
directory = mkdtempSync(join(tmpdir(), "twitter-lite-deck-repository-"));
database = openDatabase(join(directory, "app.db"));
second = openDatabase(join(directory, "app.db"));
database
.insert(connections)
.values(
['twitter-one', 'twitter-two'].map((id) => ({
["twitter-one", "twitter-two"].map((id) => ({
id,
platform: 'twitter' as const,
origin: 'https://relay.invalid',
platform: "twitter" as const,
origin: "https://relay.invalid",
relayProfile: id,
displayName: id,
status: 'connected' as const,
status: "connected" as const,
createdAt: 1,
updatedAt: 1,
})),
)
.run()
})
.run();
});
afterEach(() => {
second.$client.close()
database.$client.close()
rmSync(directory, { recursive: true })
})
second.$client.close();
database.$client.close();
rmSync(directory, { recursive: true });
});
it('shares deck order and per-column account bindings with a separate database client', () => {
const saved = createDeck(deck, database)
expect(saved).toMatchObject({ ...deck, revision: 1 })
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(listDecks(second)).toEqual([saved])
})
it("shares deck order and per-column account bindings with a separate database client", () => {
const saved = createDeck(deck, database);
expect(saved).toMatchObject({ ...deck, revision: 1 });
expect(loadDeck(deck.id, second)).toEqual(saved);
expect(listDecks(second)).toEqual([saved]);
});
it('makes retried creation idempotent but rejects different content using the same ID', () => {
const saved = createDeck(deck, database)
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('This deck changed on another device')
expect(listDecks(database)).toEqual([saved])
})
it("makes retried creation idempotent but rejects different content using the same ID", () => {
const saved = createDeck(deck, database);
expect(createDeck(deck, second)).toEqual(saved);
expect(() => createDeck({ ...deck, title: "Different request" }, second)).toThrow(
"This deck changed on another device",
);
expect(listDecks(database)).toEqual([saved]);
});
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
const initial = createDeck(deck, database)
it("replaces title, ordered columns and bindings atomically, then rejects stale edits", () => {
const initial = createDeck(deck, database);
const reversed = {
...deck,
title: 'Changed',
title: "Changed",
columns: [...deck.columns].reverse(),
}
const saved = replaceDeck(
{ deck: reversed, expectedRevision: initial.revision },
database,
)
};
const saved = replaceDeck({ deck: reversed, expectedRevision: initial.revision }, database);
expect(saved).toMatchObject({
...reversed,
revision: 2,
createdAt: initial.createdAt,
})
});
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'This deck changed on another device',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
"This deck changed on another device",
);
expect(loadDeck(deck.id, second)).toEqual(saved);
});
it('clears all columns and prevents stale deletion from another device', () => {
createDeck(deck, database)
const saved = replaceDeck(
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
database,
)
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('This deck changed on another device')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it("clears all columns and prevents stale deletion from another device", () => {
createDeck(deck, database);
const saved = replaceDeck({ deck: { ...deck, columns: [] }, expectedRevision: 1 }, database);
expect(loadDeck(deck.id, second)?.columns).toEqual([]);
expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
"This deck changed on another device",
);
expect(loadDeck(deck.id, second)).toEqual(saved);
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id);
expect(loadDeck(deck.id, database)).toBeNull();
});
it('cascades deleted deck columns and reports already-deleted decks', () => {
createDeck(deck, database)
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'This deck was deleted',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('This deck was deleted')
})
it("cascades deleted deck columns and reports already-deleted decks", () => {
createDeck(deck, database);
deleteDeck({ id: deck.id, expectedRevision: 1 }, database);
expect(database.select().from(deckColumns).all()).toEqual([]);
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow("This deck was deleted");
expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
"This deck was deleted",
);
});
it.each([
'disconnected',
'expired',
] as const)('rejects %s connections without partially saving a deck', (status) => {
const initial = createDeck(deck, database)
it.each(["disconnected", "expired"] as const)(
"rejects %s connections without partially saving a deck",
(status) => {
const initial = createDeck(deck, database);
database.update(connections).set({ status }).where(eq(connections.id, "twitter-two")).run();
expect(() =>
replaceDeck({ deck: { ...deck, title: "Should not save" }, expectedRevision: 1 }, database),
).toThrow("The account for this column is unavailable");
expect(loadDeck(deck.id, second)).toEqual(initial);
},
);
it("rejects a connection from another platform", () => {
database
.update(connections)
.set({ status })
.where(eq(connections.id, 'twitter-two'))
.run()
expect(() =>
replaceDeck(
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
.set({ platform: "mastodon" })
.where(eq(connections.id, "twitter-one"))
.run();
expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
expect(listDecks(database)).toEqual([]);
});
it('rejects a connection from another platform', () => {
database
.update(connections)
.set({ platform: 'mastodon' })
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it("rejects unknown connections without creating rows", () => {
database.delete(connections).where(eq(connections.id, "twitter-two")).run();
expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
expect(listDecks(database)).toEqual([]);
});
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
it("rejects duplicate column IDs without changing the saved version", () => {
const saved = createDeck(deck, database);
expect(() =>
replaceDeck(
{
@@ -186,29 +157,29 @@ it('rejects duplicate column IDs without changing the saved version', () => {
},
database,
),
).toThrow('Column IDs must be unique')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
).toThrow("Column IDs must be unique");
expect(loadDeck(deck.id, database)).toEqual(saved);
});
it('returns only deck definitions and revision metadata, never credential data', () => {
it("returns only deck definitions and revision metadata, never credential data", () => {
database
.insert(connectionCredentials)
.values({
connectionId: 'twitter-one',
encryptedToken: 'secret-envelope',
connectionId: "twitter-one",
encryptedToken: "secret-envelope",
updatedAt: 1,
})
.run()
createDeck(deck, database)
const loaded = listDecks(database)
.run();
createDeck(deck, database);
const loaded = listDecks(database);
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns',
'createdAt',
'id',
'revision',
'title',
'updatedAt',
])
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
})
"columns",
"createdAt",
"id",
"revision",
"title",
"updatedAt",
]);
expect(JSON.stringify(loaded)).not.toContain("secret-envelope");
expect(JSON.stringify(loaded)).not.toContain("relayProfile");
});
+71 -75
View File
@@ -1,89 +1,85 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { ResearchPost } from "../platforms/types";
import { ResearchPostCard } from "./research-post-card";
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('focuses and briefly highlights a cited card without revealing sensitive content', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', () => ({ matches: true }))
it("focuses and briefly highlights a cited card without revealing sensitive content", () => {
vi.useFakeTimers();
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
key: "mastodon:1",
platform: "mastodon",
nativeId: "1",
url: "https://social.invalid/@a/1",
text: "Hidden content",
author: { name: "Alice", handle: "[email protected]" },
contentWarning: "Spoiler",
sensitive: true,
}
const { rerender } = render(<ResearchPostCard post={post} />)
const article = screen.getByRole('article')
const scroll = vi.fn()
article.scrollIntoView = scroll
rerender(
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />,
)
expect(article).toHaveFocus()
expect(article).toHaveClass('citation-highlight')
};
const { rerender } = render(<ResearchPostCard post={post} />);
const article = screen.getByRole("article");
const scroll = vi.fn<() => void>();
article.scrollIntoView = scroll;
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
expect(article).toHaveFocus();
expect(article).toHaveClass("citation-highlight");
expect(scroll).toHaveBeenCalledWith({
block: 'center',
inline: 'center',
behavior: 'instant',
})
expect(screen.getByText('Post saved during research')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
block: "center",
inline: "center",
behavior: "instant",
});
expect(screen.getByText("Post saved during research")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show content" })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
rerender(<ResearchPostCard post={post} citationRequest={1} />);
expect(scroll).toHaveBeenCalledTimes(2);
expect(article).toHaveFocus();
expect(screen.queryByText("Post saved during research")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(2500)
})
expect(article).not.toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} citationRequest={2} />)
expect(article).toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} />)
expect(article).not.toHaveClass('citation-highlight')
})
vi.advanceTimersByTime(2500);
});
expect(article).not.toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} citationRequest={2} />);
expect(article).toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} />);
expect(article).not.toHaveClass("citation-highlight");
});
it('waits for reveal before mounting CW content or sensitive media', () => {
it("waits for reveal before mounting CW content or sensitive media", () => {
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
html: '<p>Hidden <strong>content</strong></p>',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
key: "mastodon:1",
platform: "mastodon",
nativeId: "1",
url: "https://social.invalid/@a/1",
text: "Hidden content",
html: "<p>Hidden <strong>content</strong></p>",
author: { name: "Alice", handle: "[email protected]" },
contentWarning: "Spoiler",
sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' },
boostedBy: { name: "Bob", handle: "[email protected]" },
media: [
{
type: 'photo',
url: 'https://social.invalid/image.png',
alt: 'Hidden image',
type: "photo",
url: "https://social.invalid/image.png",
alt: "Hidden image",
},
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Boosted by Bob')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Show content' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: 'Hide content' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
};
render(<ResearchPostCard post={post} />);
expect(screen.getByText("Boosted by Bob")).toBeInTheDocument();
expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
expect(screen.queryByText("content")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show content" }));
expect(screen.getByAltText("Hidden image")).toBeInTheDocument();
expect(screen.getByText("content").tagName).toBe("STRONG");
fireEvent.click(screen.getByRole("button", { name: "Hide content" }));
expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
});
+46 -64
View File
@@ -1,63 +1,56 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types'
import { useEffect, useRef, useState } from "react";
import { Icon } from "#/components/icon";
import { PostText } from "#/components/post-text";
import { Avatar, AvatarFallback, AvatarImage } from "#/components/ui/avatar";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import type { ResearchPost } from "#/features/platforms/types";
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Tokyo",
});
export function ResearchPostCard({
post,
citationRequest,
citationSnapshot,
}: {
post: ResearchPost
citationRequest?: number
citationSnapshot?: boolean
post: ResearchPost;
citationRequest?: number;
citationSnapshot?: boolean;
}) {
const [revealed, setRevealed] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const article = useRef<HTMLElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position.
const [revealed, setRevealed] = useState(false);
const [expiredHighlight, setExpiredHighlight] = useState<string>();
const highlightKey = `${citationRequest}:${citationSnapshot}`;
const highlighted = citationRequest !== undefined && expiredHighlight !== highlightKey;
const article = useRef<HTMLElement>(null);
useEffect(() => {
if (citationRequest === undefined) {
setHighlighted(false)
return
}
setHighlighted(true)
article.current?.focus({ preventScroll: true })
if (citationRequest === undefined) return;
article.current?.focus({ preventScroll: true });
article.current?.scrollIntoView({
block: 'center',
inline: 'center',
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? 'instant'
: 'smooth',
})
const timer = setTimeout(() => setHighlighted(false), 2500)
return () => clearTimeout(timer)
}, [citationRequest, citationSnapshot])
const concealed = Boolean(post.contentWarning || post.sensitive)
block: "center",
inline: "center",
behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
const timer = setTimeout(() => setExpiredHighlight(highlightKey), 2500);
return () => clearTimeout(timer);
}, [citationRequest, highlightKey]);
const concealed = Boolean(post.contentWarning || post.sensitive);
return (
<article
ref={article}
tabIndex={-1}
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
className={`research-post${highlighted ? " citation-highlight" : ""}`}
data-post-url={post.url}
>
{citationSnapshot ? (
<Badge variant="secondary">Post saved during research</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">Boosted by {post.boostedBy.name}</p>
) : null}
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
{post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null}
<header>
<Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
@@ -65,9 +58,7 @@ export function ResearchPostCard({
</Avatar>
<div>
<strong>{post.author.name}</strong>
<span className="handle">
@{post.author.handle.replace(/^@/, '')}
</span>
<span className="handle">@{post.author.handle.replace(/^@/, "")}</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}>
@@ -77,7 +68,7 @@ export function ResearchPostCard({
</header>
{concealed ? (
<div>
<p>{post.contentWarning || 'This post contains sensitive media'}</p>
<p>{post.contentWarning || "This post contains sensitive media"}</p>
<Button
variant="outline"
size="sm"
@@ -85,7 +76,7 @@ export function ResearchPostCard({
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? 'Hide content' : 'Show content'}
{revealed ? "Hide content" : "Show content"}
</Button>
</div>
) : null}
@@ -94,7 +85,7 @@ export function ResearchPostCard({
{post.html !== undefined ? (
<div
className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization.
// Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
@@ -103,21 +94,16 @@ export function ResearchPostCard({
</p>
)}
{post.media?.map((media) =>
media.type === 'photo' ? (
media.type === "photo" ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
alt={media.alt ?? ""}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
<a key={media.url} href={media.url} target="_blank" rel="noreferrer noopener">
Open video
</a>
),
@@ -128,11 +114,7 @@ export function ResearchPostCard({
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
Open quoted post
</a>
</blockquote>
@@ -145,5 +127,5 @@ export function ResearchPostCard({
</a>
</footer>
</article>
)
);
}
+26 -28
View File
@@ -1,60 +1,58 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { legacyWorkspaceSchema } from './legacy-import'
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { legacyWorkspaceSchema } from "./legacy-import";
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
savedDeckDefinitionSchema,
} from './persistence-model'
} from "./persistence-model";
async function run<T>(
operation: (
repository: typeof import('./repository.server'),
) => T | Promise<T>,
operation: (repository: typeof import("./repository.server")) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server')
const repository = await import("./repository.server");
try {
return { ok: true, data: await operation(repository) }
return { ok: true, data: await operation(repository) };
} catch (error) {
if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } }
return { ok: false, error: { code: error.code, message: error.message } };
if (error instanceof z.ZodError)
return {
ok: false,
error: {
code: 'invalid-input',
message: 'Check the deck details.',
code: "invalid-input",
message: "Check the deck details.",
},
}
};
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'Unable to save or load the deck. Please try again.',
code: "storage-unavailable",
message: "Unable to save or load the deck. Please try again.",
},
}
};
}
}
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
export const loadSavedDecks = createServerFn({ method: "GET" }).handler(() =>
run((repository) => repository.listDecks()),
)
export const createSavedDeck = createServerFn({ method: 'POST' })
);
export const createSavedDeck = createServerFn({ method: "POST" })
.validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data)))
export const replaceSavedDeck = createServerFn({ method: 'POST' })
.handler(({ data }) => run((repository) => repository.createDeck(data)));
export const replaceSavedDeck = createServerFn({ method: "POST" })
.validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
export const deleteSavedDeck = createServerFn({ method: 'POST' })
.handler(({ data }) => run((repository) => repository.replaceDeck(data)));
export const deleteSavedDeck = createServerFn({ method: "POST" })
.validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
.handler(({ data }) => run((repository) => repository.deleteDeck(data)));
export const importBrowserDecks = createServerFn({ method: 'POST' })
export const importBrowserDecks = createServerFn({ method: "POST" })
.validator(legacyWorkspaceSchema)
.handler(({ data }) =>
run(async () => {
const { importLegacyWorkspace } = await import('./import.server')
return importLegacyWorkspace(data)
const { importLegacyWorkspace } = await import("./import.server");
return importLegacyWorkspace(data);
}),
)
);
+25 -25
View File
@@ -1,37 +1,37 @@
import type { DeckColumn } from './model'
import type { DeckColumn } from "./model";
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): {
label: string
query: string
export function describeSource(source: DeckColumn["source"]): {
label: string;
query: string;
} {
switch (source.platform) {
case 'twitter':
case "twitter":
switch (source.kind) {
case 'search':
case "search":
return {
label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`,
label: `${source.product === "Latest" ? "Latest" : "Top"}${source.following ? " · Following" : ""}`,
query: source.query,
}
case 'user':
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'List', query: source.target }
};
case "user":
return { label: "User posts", query: source.target };
case "list":
return { label: "List", query: source.target };
}
break
case 'mastodon':
break;
case "mastodon":
switch (source.kind) {
case 'search':
return { label: 'Full-text search', query: source.query }
case 'user':
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'List', query: source.target }
case 'hashtag':
return { label: 'Hashtag', query: `#${source.target}` }
case "search":
return { label: "Full-text search", query: source.query };
case "user":
return { label: "User posts", query: source.target };
case "list":
return { label: "List", query: source.target };
case "hashtag":
return { label: "Hashtag", query: `#${source.target}` };
}
break
break;
}
const exhaustive: never = source
return exhaustive
const exhaustive: never = source;
return exhaustive;
}
+56 -90
View File
@@ -1,32 +1,32 @@
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
import { type ReactElement, useId } from "react";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { useListChoices } from "../posts/use-list-choices";
import type { DeckColumn } from "./model";
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
type Source = Extract<DeckColumn["source"], { platform: "twitter" }>;
type EditorProps<T extends Source = Source> = {
source: T
onChange: (source: Source) => void
}
source: T;
onChange: (source: Source) => void;
};
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
export function defaultTwitterSource(kind: Source["kind"] = "search"): Source {
switch (kind) {
case 'search':
case "search":
return {
platform: 'twitter',
platform: "twitter",
kind,
query: '',
product: 'Latest',
query: "",
product: "Latest",
following: false,
}
case 'user':
return { platform: 'twitter', kind, target: '' }
case 'list':
return { platform: 'twitter', kind, target: '' }
};
case "user":
return { platform: "twitter", kind, target: "" };
case "list":
return { platform: "twitter", kind, target: "" };
}
}
@@ -35,7 +35,7 @@ export function TwitterSourceEditor({
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
const id = useId();
return (
<>
<Label htmlFor={id}>
@@ -44,22 +44,16 @@ export function TwitterSourceEditor({
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
onChange={(event) => onChange(defaultTwitterSource(event.target.value as Source["kind"]))}
>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
<TwitterSourceFields source={source} connectionId={connectionId} onChange={onChange} />
</>
)
);
}
function TwitterSourceFields({
@@ -68,26 +62,20 @@ function TwitterSourceFields({
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <TwitterSearchEditor source={source} onChange={onChange} />
case 'user':
return <TwitterUserEditor source={source} onChange={onChange} />
case 'list':
return (
<TwitterListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case "search":
return <TwitterSearchEditor source={source} onChange={onChange} />;
case "user":
return <TwitterUserEditor source={source} onChange={onChange} />;
case "list":
return <TwitterListEditor source={source} connectionId={connectionId} onChange={onChange} />;
}
}
function TwitterSearchEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
const id = useId()
}: EditorProps<Extract<Source, { kind: "search" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={`${id}-query`}>
@@ -95,9 +83,7 @@ function TwitterSearchEditor({
<Input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
onChange={(event) => onChange({ ...source, query: event.target.value })}
maxLength={512}
placeholder="e.g. WebMCP lang:en"
required
@@ -112,7 +98,7 @@ function TwitterSearchEditor({
onChange={(event) =>
onChange({
...source,
product: event.target.value as 'Latest' | 'Top',
product: event.target.value as "Latest" | "Top",
})
}
>
@@ -120,27 +106,20 @@ function TwitterSearchEditor({
<NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
Use Twitter search operators such as from:, lang:, and since:.
</p>
<p className="deck-help">Use Twitter search operators such as from:, lang:, and since:.</p>
<Label className="deck-checkbox">
<Checkbox
checked={source.following}
onCheckedChange={(checked) =>
onChange({ ...source, following: checked })
}
onCheckedChange={(checked) => onChange({ ...source, following: checked })}
/>
Following only
</Label>
</div>
)
);
}
function TwitterUserEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
const id = useId()
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -148,24 +127,22 @@ function TwitterUserEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="@handle or profile URL"
maxLength={256}
required
/>
</Label>
</div>
)
);
}
function TwitterListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
const id = useId()
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -173,9 +150,7 @@ function TwitterListEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="List URL or ID"
maxLength={256}
required
@@ -188,48 +163,39 @@ function TwitterListEditor({
/>
)}
</div>
)
);
}
function TwitterListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
connectionId: string;
onSelect: (target: string) => void;
}) {
const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">Loading lists…</p>
const lists = useListChoices(connectionId);
if (lists.isPending) return <p role="status">Loading lists…</p>;
if (lists.isError)
return (
<p role="alert">
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
<Button variant="outline" type="button" onClick={() => void lists.refetch()}>
Retry
</Button>
</p>
)
);
return (
<section className="deck-list-choices" aria-label="Lists for this profile">
{lists.data.length === 0 ? (
<p>No lists found. You can enter a URL or ID instead.</p>
) : (
lists.data.map((list) => (
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
<Button variant="outline" type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · Private' : ''}
{list.isPrivate ? " · Private" : ""}
</Button>
))
)}
</section>
)
);
}
+248 -193
View File
@@ -1,214 +1,217 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { LEGACY_STORAGE_KEY } from "./legacy-import";
import type { SavedDeck } from "./persistence-model";
import { useDeck } from "./use-deck";
const navigation = vi.hoisted(() => ({
search: {} as { deck?: string; citation?: string },
set: vi.fn<
(patch: { deck?: string; citation?: string }, options?: { replace?: boolean }) => void
>(),
}));
vi.mock("../navigation/use-url-state", () => ({
useUrlState: () => [navigation.search, navigation.set],
}));
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
load: vi.fn<typeof import("./server-functions").loadSavedDecks>(),
create: vi.fn<typeof import("./server-functions").createSavedDeck>(),
replace: vi.fn<typeof import("./server-functions").replaceSavedDeck>(),
remove: vi.fn<typeof import("./server-functions").deleteSavedDeck>(),
import: vi.fn<typeof import("./server-functions").importBrowserDecks>(),
}));
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("./server-functions", () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
}));
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
id: "saved",
title: "Shared research",
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
};
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
navigation.search = {};
localStorage.clear();
vi.clearAllMocks();
server.load.mockResolvedValue({ ok: true, data: [] });
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
}));
});
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("creates and edits temporary views without writing shared decks", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
result.current.createTemporary("AI research");
});
const id = result.current.deck.id;
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
await result.current.save({ id, title: "Revised research", columns: [] });
});
expect(result.current.deck).toMatchObject({
title: 'Revised research',
title: "Revised research",
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
});
expect(server.create).not.toHaveBeenCalled();
expect(server.replace).not.toHaveBeenCalled();
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull();
await act(async () => {
await result.current.persist(id)
})
await result.current.persist(id);
});
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
});
expect(server.create).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
await result.current.persist(id);
});
expect(server.create).toHaveBeenCalledTimes(1);
});
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("keeps a temporary view on failed save and retries with the same identity", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
result.current.createTemporary("Keep this");
});
const id = result.current.deck.id;
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
error: { code: "storage-unavailable", message: "Server disk unavailable" },
});
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(await result.current.persist(id)).toBe(false);
});
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
title: "Keep this",
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
});
expect(result.current.storageError).toBe("Server disk unavailable");
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
expect(await result.current.persist(id)).toBe(true);
});
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([id, id]);
});
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
error: { code: "storage-unavailable", message: "Revision conflict" },
});
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(await result.current.save({ ...saved, title: "My draft" })).toBe(false);
});
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 });
expect(result.current.storageError).toBe("Revision conflict");
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
deck: { id: saved.id, title: "My draft", columns: [] },
expectedRevision: 1,
},
})
})
});
});
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
it("refreshes on focus but holds remote updates while a form is being edited", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
});
await waitFor(() => expect(result.current.ready).toBe(true));
rerender({ editing: true });
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
data: [{ ...saved, title: "Remote edit", revision: 2 }],
});
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
window.dispatchEvent(new Event("focus"));
});
expect(server.load).toHaveBeenCalledTimes(1);
expect(result.current.deck.title).toBe(saved.title);
rerender({ editing: false });
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
window.dispatchEvent(new Event("focus"));
});
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
title: "Remote edit",
revision: 2,
})
})
});
});
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
it("restores the active deck from the URL and copies saved content into a separate temporary view", async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
data: [saved, { ...saved, id: "second", title: "Second" }],
});
navigation.search = { deck: "second" };
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.deck.id).toBe("second"));
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
result.current.createTemporary("A copy", result.current.deck.columns);
});
const copy = result.current.deck;
expect(copy.persisted).toBe(false);
expect(copy.id).not.toBe("second");
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
await result.current.save({ ...copy, title: "Changed copy" });
});
expect(result.current.workspace.decks.find((deck) => deck.id === "second")?.title).toBe("Second");
expect(server.replace).not.toHaveBeenCalled();
});
it('does not import old browser data without an explicit action', async () => {
it("does not import old browser data without an explicit action", async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
activeDeckId: "old",
decks: [{ id: "old", title: "Old deck", columns: [] }],
};
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy));
server.import.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.legacyAvailable).toBe(true);
expect(server.import).not.toHaveBeenCalled();
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy));
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
await result.current.importLegacy();
});
expect(server.import).toHaveBeenCalledWith({ data: legacy });
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull();
});
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('Unable to load')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it("leaves unreadable legacy data untouched", async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, "{broken");
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.storageError).toContain("Unable to load");
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe("{broken");
});
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("recognizes a committed save after a lost response without duplicating its temporary view", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
result.current.createTemporary("Lost response");
});
const view = result.current.deck;
server.create.mockRejectedValueOnce(new Error("Network disconnected"));
await act(async () => {
await result.current.persist(view.id)
})
await result.current.persist(view.id);
});
server.load.mockResolvedValue({
ok: true,
data: [
@@ -221,30 +224,28 @@ it('recognizes a committed save after a lost response without duplicating its te
updatedAt: 1,
},
],
})
});
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
await result.current.refresh();
});
expect(result.current.workspace.decks.filter((deck) => deck.id === view.id)).toHaveLength(1);
expect(result.current.deck.persisted).toBe(true);
});
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("preserves a divergent temporary edit when a lost-response save appears remotely", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
result.current.createTemporary("Before request");
});
const view = result.current.deck;
server.create.mockRejectedValueOnce(new Error("Network disconnected"));
await act(async () => {
await result.current.persist(view.id)
})
await result.current.persist(view.id);
});
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
await result.current.save({ ...view, title: "Edited after failure" });
});
server.load.mockResolvedValue({
ok: true,
data: [
@@ -257,47 +258,101 @@ it('preserves a divergent temporary edit when a lost-response save appears remot
updatedAt: 1,
},
],
})
});
await act(async () => {
await result.current.refresh()
})
await result.current.refresh();
});
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
title: "Edited after failure",
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
});
expect(result.current.deck.id).not.toBe(view.id);
expect(result.current.workspace.decks.find((deck) => deck.id === view.id)).toMatchObject({
title: "Before request",
persisted: true,
});
});
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
it("ignores an older refresh response after a newer request has completed", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
let resolveOlder: (value: Awaited<ReturnType<typeof server.load>>) => void = () => {};
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
resolveOlder = resolve;
}),
)
);
act(() => {
void result.current.refresh()
})
void result.current.refresh();
});
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
data: [{ ...saved, title: "Newest", revision: 3 }],
});
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await result.current.refresh();
});
expect(result.current.deck.revision).toBe(3);
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})
data: [{ ...saved, title: "Older", revision: 2 }],
});
});
expect(result.current.deck).toMatchObject({ title: "Newest", revision: 3 });
});
it("restores deck selection when browser history changes", async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: "second" }],
});
navigation.search = { deck: "second" };
const { result, rerender } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.deck.id).toBe("second"));
act(() => {
navigation.search = { deck: "saved" };
rerender();
});
expect(result.current.deck.id).toBe("saved");
act(() => {
result.current.select("second");
});
expect(navigation.set).toHaveBeenCalledWith({
deck: "second",
citation: undefined,
});
});
it("reconstructs a temporary research deck with its bookmarked identity without writing saved data", async () => {
navigation.search = { deck: "bookmarked-plan" };
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
const view = result.current.createTemporary("Restored plan", [], false, "bookmarked-plan");
result.current.select(view.id, false);
});
expect(result.current.deck).toMatchObject({
id: "bookmarked-plan",
title: "Restored plan",
persisted: false,
});
expect(navigation.set).not.toHaveBeenCalled();
expect(server.create).not.toHaveBeenCalled();
expect(server.replace).not.toHaveBeenCalled();
});
it("preserves a bookmarked citation when restoring the already requested deck", async () => {
navigation.search = { deck: saved.id, citation: "twitter:123" };
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.select(saved.id);
});
expect(result.current.deck.id).toBe(saved.id);
expect(navigation.set).not.toHaveBeenCalled();
});
+203 -188
View File
@@ -1,6 +1,7 @@
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from './legacy-import'
import { useServerFn } from "@tanstack/react-start";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useUrlState } from "../navigation/use-url-state";
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from "./legacy-import";
import {
type Deck,
type DeckView,
@@ -9,17 +10,32 @@ import {
EMPTY_WORKSPACE,
newId,
type Workspace,
} from './model'
import type { SavedDeck } from './persistence-model'
} from "./model";
import type { SavedDeck } from "./persistence-model";
import {
createSavedDeck,
deleteSavedDeck,
importBrowserDecks,
loadSavedDecks,
replaceSavedDeck,
} from './server-functions'
} from "./server-functions";
const subscribeToLegacy = (notify: () => void) => {
window.addEventListener("storage", notify);
return () => window.removeEventListener("storage", notify);
};
function getLegacyStatus(): "none" | "available" | "invalid" {
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return "none";
legacyWorkspaceSchema.parse(JSON.parse(raw));
return "available";
} catch {
return "invalid";
}
}
const getServerLegacyStatus = () => "none" as const;
export const ACTIVE_DECK_KEY = 'twitter-lite-active-deck'
function asView(deck: SavedDeck): DeckView {
return {
id: deck.id,
@@ -27,193 +43,175 @@ function asView(deck: SavedDeck): DeckView {
columns: deck.columns,
revision: deck.revision,
persisted: true,
}
};
}
function definition(deck: Deck): Deck {
return deckSchema.parse({
id: deck.id,
title: deck.title,
columns: deck.columns,
})
});
}
export function useDeck(editing = false) {
const [search, setSearch] = useUrlState();
const url = useRef({ search, setSearch });
useEffect(() => {
url.current = { search, setSearch };
}, [search, setSearch]);
const services = {
load: useServerFn(loadSavedDecks),
create: useServerFn(createSavedDeck),
replace: useServerFn(replaceSavedDeck),
remove: useServerFn(deleteSavedDeck),
import: useServerFn(importBrowserDecks),
}
const servicesRef = useRef(services)
servicesRef.current = services
const editingRef = useRef(editing)
editingRef.current = editing
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const initialized = useRef(false)
const [storageError, setStorageError] = useState<string>()
const [saving, setSaving] = useState(false)
const busy = useRef(false)
const generation = useRef(0)
const refreshSequence = useRef(0)
const [legacyAvailable, setLegacyAvailable] = useState(false)
};
const servicesRef = useRef(services);
useEffect(() => {
servicesRef.current = services;
});
const editingRef = useRef(editing);
useEffect(() => {
editingRef.current = editing;
}, [editing]);
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE);
const current = useRef(workspace);
const [ready, setReady] = useState(false);
const initialized = useRef(false);
const [storageError, setStorageError] = useState<string>();
const [saving, setSaving] = useState(false);
const busy = useRef(false);
const generation = useRef(0);
const refreshSequence = useRef(0);
const legacyStatus = useSyncExternalStore(
subscribeToLegacy,
getLegacyStatus,
getServerLegacyStatus,
);
const [legacyImported, setLegacyImported] = useState(false);
const legacyAvailable = !legacyImported && legacyStatus === "available";
const apply = useCallback((next: Workspace) => {
current.current = next
setWorkspace(next)
try {
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
} catch {
/* Selection is a device-local preference, not saved deck data. */
}
}, [])
current.current = next;
setWorkspace(next);
}, []);
const reconcile = useCallback(
(saved: SavedDeck[]) => {
let preferred = current.current.activeDeckId
let temporary = current.current.decks.filter((deck) => !deck.persisted)
let preferred = url.current.search.deck ?? current.current.activeDeckId;
let temporary = current.current.decks.filter((deck) => !deck.persisted);
if (!initialized.current)
temporary = saved.length
? []
: [{ ...EMPTY_DECK, id: newId(), persisted: false }]
temporary = saved.length ? [] : [{ ...EMPTY_DECK, id: newId(), persisted: false }];
temporary = temporary.flatMap((view) => {
const accepted = saved.find((deck) => deck.id === view.id)
if (!accepted) return [view]
if (
JSON.stringify(definition(accepted)) ===
JSON.stringify(definition(view))
)
return []
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'Your unsaved changes have been kept as a temporary copy.',
)
return [{ ...view, id }]
})
const all = [...saved.map(asView), ...temporary]
if (!all.length)
all.push({ ...EMPTY_DECK, id: newId(), persisted: false })
if (!initialized.current) {
try {
preferred = localStorage.getItem(ACTIVE_DECK_KEY) ?? preferred
} catch {
/* Optional local preference. */
}
}
const accepted = saved.find((deck) => deck.id === view.id);
if (!accepted) return [view];
if (JSON.stringify(definition(accepted)) === JSON.stringify(definition(view))) return [];
const id = newId();
if (preferred === view.id) preferred = id;
setStorageError("Your unsaved changes have been kept as a temporary copy.");
return [{ ...view, id }];
});
const all = [...saved.map(asView), ...temporary];
if (!all.length) all.push({ ...EMPTY_DECK, id: newId(), persisted: false });
const activeDeckId = all.some((deck) => deck.id === preferred)
? preferred
: (all[0]?.id ?? '')
: (all[0]?.id ?? "");
if (initialized.current && activeDeckId !== preferred)
setStorageError('This deck was deleted on another device.')
initialized.current = true
apply({ activeDeckId, decks: all })
setStorageError("This deck was deleted on another device.");
initialized.current = true;
apply({ activeDeckId, decks: all });
},
[apply],
)
);
const refresh = useCallback(
async (force = false) => {
if (busy.current || (!force && editingRef.current)) return false
const requestGeneration = generation.current
const requestSequence = ++refreshSequence.current
if (busy.current || (!force && editingRef.current)) return false;
const requestGeneration = generation.current;
const requestSequence = ++refreshSequence.current;
try {
const result = await servicesRef.current.load()
const result = await servicesRef.current.load();
if (
requestGeneration !== generation.current ||
requestSequence !== refreshSequence.current ||
busy.current ||
(!force && editingRef.current)
)
return false
if (!result.ok) throw new Error(result.error.message)
if (force) setStorageError(undefined)
reconcile(result.data)
setReady(true)
return true
return false;
if (!result.ok) throw new Error(result.error.message);
if (force) setStorageError(undefined);
reconcile(result.data);
setReady(true);
return true;
} catch {
if (
requestSequence !== refreshSequence.current ||
requestGeneration !== generation.current
)
return false
setStorageError('Unable to load saved decks. Please try again.')
return false
if (requestSequence !== refreshSequence.current || requestGeneration !== generation.current)
return false;
setStorageError("Unable to load saved decks. Please try again.");
return false;
}
},
[reconcile],
)
);
useEffect(() => {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
legacyWorkspaceSchema.parse(JSON.parse(legacy))
setLegacyAvailable(true)
}
} catch {
setStorageError(
'Unable to load legacy decks. Your browser data has not been changed.',
)
}
void refresh()
// oxlint-disable-next-line react/set-state-in-effect -- Loading saved decks synchronizes external storage; refresh updates state only after awaiting the server.
void refresh();
const onFocus = () => {
if (document.visibilityState === 'visible') void refresh()
}
const timer = setInterval(onFocus, 5000)
window.addEventListener('focus', onFocus)
if (document.visibilityState === "visible") void refresh();
};
const timer = setInterval(onFocus, 5000);
window.addEventListener("focus", onFocus);
return () => {
clearInterval(timer)
window.removeEventListener('focus', onFocus)
}
}, [refresh])
clearInterval(timer);
window.removeEventListener("focus", onFocus);
};
}, [refresh]);
async function mutate<T>(
operation: () => Promise<T>,
accept: (value: T) => void,
) {
useEffect(() => {
if (!ready) return;
const selected =
current.current.decks.find((deck) => deck.id === search.deck) ?? current.current.decks[0];
if (selected && current.current.activeDeckId !== selected.id)
apply({ ...current.current, activeDeckId: selected.id });
}, [search.deck, ready, apply]);
async function mutate<T>(operation: () => Promise<T>, accept: (value: T) => void) {
if (busy.current) {
setStorageError('Please wait until saving finishes.')
return false
setStorageError("Please wait until saving finishes.");
return false;
}
busy.current = true
generation.current += 1
setSaving(true)
busy.current = true;
generation.current += 1;
setSaving(true);
try {
const result = await operation()
accept(result)
setStorageError(undefined)
return true
const result = await operation();
accept(result);
setStorageError(undefined);
return true;
} catch (error) {
setStorageError(
error instanceof Error
? error.message
: 'Unable to save. Please try again.',
)
return false
setStorageError(error instanceof Error ? error.message : "Unable to save. Please try again.");
return false;
} finally {
busy.current = false
setSaving(false)
busy.current = false;
setSaving(false);
}
}
function upsert(view: DeckView) {
const previous = current.current
const previous = current.current;
apply({
activeDeckId: view.id,
decks: previous.decks.some((deck) => deck.id === view.id)
? previous.decks.map((deck) => (deck.id === view.id ? view : deck))
: [...previous.decks, view],
})
});
}
async function save(next: Deck, expectedRevision?: number) {
const parsed = definition(next)
const existing = current.current.decks.find((deck) => deck.id === parsed.id)
const parsed = definition(next);
const existing = current.current.decks.find((deck) => deck.id === parsed.id);
if (!existing?.persisted) {
if (busy.current) return false
generation.current += 1
upsert({ ...parsed, persisted: false })
return true
if (busy.current) return false;
generation.current += 1;
upsert({ ...parsed, persisted: false });
return true;
}
return mutate(
async () => {
@@ -222,103 +220,120 @@ export function useDeck(editing = false) {
deck: parsed,
expectedRevision: expectedRevision ?? existing.revision ?? 0,
},
})
if (!result.ok) throw new Error(result.error.message)
return result.data
});
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => upsert(asView(result)),
)
);
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('Deck not found.')
apply({ ...current.current, activeDeckId: id })
return true
function select(id: string, updateUrl = true) {
if (!current.current.decks.some((deck) => deck.id === id)) throw new Error("Deck not found.");
apply({ ...current.current, activeDeckId: id });
if (updateUrl && url.current.search.deck !== id)
url.current.setSearch({ deck: id, citation: undefined });
return true;
}
function createTemporary(title: string, columns: Deck['columns'] = []) {
function createTemporary(
title: string,
columns: Deck["columns"] = [],
activate = true,
preferredId?: string,
) {
const view = {
...definition({ id: newId(), title, columns }),
...definition({
id:
preferredId && !current.current.decks.some((deck) => deck.id === preferredId)
? preferredId
: newId(),
title,
columns,
}),
persisted: false,
}
upsert(view)
return view
};
upsert(view);
if (activate) url.current.setSearch({ deck: view.id, citation: undefined });
return view;
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('Deck not found.')
if (view.persisted) return true
const view = current.current.decks.find((deck) => deck.id === id);
if (!view) throw new Error("Deck not found.");
if (view.persisted) return true;
return mutate(
async () => {
const result = await servicesRef.current.create({
data: definition(view),
})
if (!result.ok) throw new Error(result.error.message)
return result.data
});
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => upsert(asView(result)),
)
);
}
async function create(title: string) {
const view = createTemporary(title)
return persist(view.id)
const view = createTemporary(title);
return persist(view.id);
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('Deck not found.')
const view = current.current.decks.find((deck) => deck.id === id);
if (!view) throw new Error("Deck not found.");
const accept = () => {
const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)
if (!decks.length)
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
const previous = current.current;
const decks = previous.decks.filter((deck) => deck.id !== id);
if (!decks.length) decks.push({ ...EMPTY_DECK, id: newId(), persisted: false });
apply({
activeDeckId:
previous.activeDeckId === id
? (decks[0]?.id ?? '')
: previous.activeDeckId,
activeDeckId: previous.activeDeckId === id ? (decks[0]?.id ?? "") : previous.activeDeckId,
decks,
})
}
});
if (previous.activeDeckId === id)
url.current.setSearch({ deck: decks[0]?.id, citation: undefined }, { replace: true });
};
if (!view.persisted) {
if (busy.current) return false
accept()
return true
if (busy.current) return false;
accept();
return true;
}
return mutate(async () => {
const result = await servicesRef.current.remove({
data: { id, expectedRevision: expectedRevision ?? view.revision ?? 0 },
})
if (!result.ok) throw new Error(result.error.message)
}, accept)
});
if (!result.ok) throw new Error(result.error.message);
}, accept);
}
async function importLegacy() {
return mutate(
async () => {
const data = legacyWorkspaceSchema.parse(
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? 'null'),
)
const result = await servicesRef.current.import({ data })
if (!result.ok) throw new Error(result.error.message)
return result.data
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? "null"),
);
const result = await servicesRef.current.import({ data });
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => {
reconcile(result)
setLegacyAvailable(false)
reconcile(result);
setLegacyImported(true);
try {
localStorage.removeItem(LEGACY_STORAGE_KEY)
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
/* Server marker prevents duplicate imports on retry. */
}
},
)
);
}
return {
deck: workspace.decks.find(
(deck) => deck.id === workspace.activeDeckId,
) ?? { ...EMPTY_DECK, persisted: false },
deck: workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ?? {
...EMPTY_DECK,
persisted: false,
},
workspace,
ready,
saving,
storageError,
storageError:
storageError ??
(legacyStatus === "invalid"
? "Unable to load legacy decks. Your browser data has not been changed."
: undefined),
legacyAvailable,
save,
create,
@@ -329,5 +344,5 @@ export function useDeck(editing = false) {
refresh,
importLegacy,
getWorkspace: () => current.current,
}
};
}
+7 -6
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
const getSnapshot = () => Boolean(document.modelContext);
const getServerSnapshot = () => false;
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+71 -89
View File
@@ -1,140 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { Connection } from '#/features/connections/model'
import { prepareDeck } from './webmcp-contracts'
import { describe, expect, it } from "vitest";
import type { Connection } from "#/features/connections/model";
import { prepareDeck } from "./webmcp-contracts";
const account = (id: string): Connection => ({
id,
platform: 'twitter',
origin: 'https://relay.invalid',
platform: "twitter",
origin: "https://relay.invalid",
accountId: null,
displayName: id,
status: 'connected',
})
status: "connected",
});
const column = {
title: '観点',
connectionId: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
account('main'),
])
title: "観点",
connectionId: "main",
source: { kind: "search", query: "AI" },
};
describe("deck tool input", () => {
it("fills Twitter defaults and assigns independent deck and column IDs", () => {
const deck = prepareDeck({ title: "調査", columns: [column, column] }, [account("main")]);
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
kind: 'search',
query: 'AI',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "AI",
product: "Latest",
following: false,
})
});
expect(new Set([deck.id, ...deck.columns.map((column) => column.id)]).size).toBe(3);
});
it("keeps supplied identities and rejects duplicates", () => {
const identified = { ...column, id: "existing" };
expect(
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
).toBe(3)
})
it('keeps supplied identities and rejects duplicates', () => {
const identified = { ...column, id: 'existing' }
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
[account('main')],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
prepareDeck({ deckId: "research", title: "調査", columns: [identified] }, [account("main")]),
).toMatchObject({ id: "research", columns: [{ id: "existing" }] });
expect(() =>
prepareDeck({ title: '調査', columns: [identified, identified] }, [
account('main'),
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
).toThrow('Unavailable connection')
})
it('accepts user and list columns bound to separate profiles', () => {
prepareDeck({ title: "調査", columns: [identified, identified] }, [account("main")]),
).toThrow("Column IDs must be unique.");
});
it("rejects unavailable profiles before applying a definition", () => {
expect(() => prepareDeck({ title: "調査", columns: [column] }, [account("work")])).toThrow(
"Unavailable connection",
);
});
it("accepts user and list columns bound to separate profiles", () => {
const deck = prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{ ...column, source: { kind: "user", target: "@alice" } },
{
...column,
connectionId: 'work',
source: { kind: 'list', target: '123' },
connectionId: "work",
source: { kind: "list", target: "123" },
},
],
},
[account('main'), account('work')],
)
expect(deck.columns.map((column) => column.connectionId)).toEqual([
'main',
'work',
])
expect(deck.columns.map((column) => column.source.kind)).toEqual([
'user',
'list',
])
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
[account("main"), account("work")],
);
expect(deck.columns.map((column) => column.connectionId)).toEqual(["main", "work"]);
expect(deck.columns.map((column) => column.source.kind)).toEqual(["user", "list"]);
});
it("rejects unsupported platforms, overlong queries and more than six columns", () => {
expect(() =>
prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
source: { kind: "search", platform: "nostr", query: "AI" },
},
],
},
[account('main')],
[account("main")],
),
).toThrow()
).toThrow("Invalid input");
expect(() =>
prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
kind: "search",
query: "a".repeat(512),
following: true,
},
},
],
},
[account('main')],
[account("main")],
),
).toThrow()
).toThrow("512 characters");
expect(() =>
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
[account('main')],
),
).toThrow()
})
})
prepareDeck({ title: "調査", columns: Array.from({ length: 7 }, () => column) }, [
account("main"),
]),
).toThrow("Too big");
});
});
it('builds a mixed-platform deck with explicit account bindings', () => {
it("builds a mixed-platform deck with explicit account bindings", () => {
const deck = prepareDeck(
{
title: 'Mixed',
title: "Mixed",
columns: [
column,
{
title: 'Mastodon tag',
connectionId: 'masto',
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
title: "Mastodon tag",
connectionId: "masto",
source: { platform: "mastodon", kind: "hashtag", target: "WebMCP" },
},
],
},
[account('main'), { ...account('masto'), platform: 'mastodon' }],
)
expect(
deck.columns.map((column) => [column.connectionId, column.source.platform]),
).toEqual([
['main', 'twitter'],
['masto', 'mastodon'],
])
})
[account("main"), { ...account("masto"), platform: "mastodon" }],
);
expect(deck.columns.map((column) => [column.connectionId, column.source.platform])).toEqual([
["main", "twitter"],
["masto", "mastodon"],
]);
});
+37 -49
View File
@@ -1,82 +1,73 @@
import { z } from 'zod'
import type { Connection } from '#/features/connections/model'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
import { z } from "zod";
import type { Connection } from "#/features/connections/model";
import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import { InputError } from "#/features/posts/inputs";
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from "./model";
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing column ID when editing; omit for a new column.',
),
.describe("Keep an existing column ID when editing; omit for a new column."),
source: z.union([
mastodonSourceSchema,
z.discriminatedUnion('kind', [
z.discriminatedUnion("kind", [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
product: z.enum(["Latest", "Top"]).default("Latest"),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("user"),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("list"),
target: z.string().min(1).max(256),
})
.strict(),
]),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
});
export const emptyToolInput = z.object({}).strict();
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict();
export const deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
.strict();
export const getDeckInput = z.object({ deckId: deckSchema.shape.id.optional() }).strict();
export const setDeckInput = z
.object({
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.',
"Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.",
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe(
'Required when replacing a saved deck; omit for a temporary view.',
),
.describe("Required when replacing a saved deck; omit for a temporary view."),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.',
"Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.",
),
})
.strict()
.strict();
export function prepareDeck(
input: unknown,
connections: readonly Connection[],
) {
const parsed = setDeckInput.parse(input)
export function prepareDeck(input: unknown, connections: readonly Connection[]) {
const parsed = setDeckInput.parse(input);
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
@@ -84,45 +75,42 @@ export function prepareDeck(
...column,
id: column.id ?? newId(),
})),
})
});
for (const column of deck.columns) {
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === 'connected',
connection.status === "connected",
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`)
throw new InputError(`Unavailable connection: ${column.connectionId}`);
}
return deck
return deck;
}
export async function toolResult(run: () => unknown | Promise<unknown>) {
export async function toolResult(run: () => unknown) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
content: [{ type: "text" as const, text: JSON.stringify(await run()) }] as const,
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text' as const,
type: "text" as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
? "invalid-input"
: "tool-error",
message: error instanceof Error ? error.message : "Tool execution failed.",
retryable: false,
}),
},
] as const,
}
};
}
}
+77 -82
View File
@@ -1,7 +1,7 @@
import { useWebMCP } from 'usewebmcp'
import type { Connection } from '#/features/connections/model'
import type { Deck, DeckView, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { useWebMCP } from "usewebmcp";
import type { Connection } from "#/features/connections/model";
import type { Deck, DeckView, Workspace } from "./model";
import { useWebMCPSupported } from "./use-webmcp-supported";
import {
deckIdInput,
deleteDeckToolInput,
@@ -10,7 +10,7 @@ import {
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
} from "./webmcp-contracts";
export function useDeckTools({
ready,
@@ -25,67 +25,65 @@ export function useDeckTools({
createTemporary,
persist,
}: {
ready: boolean
saving: boolean
storageError?: string
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
select: (id: string) => boolean
remove: (id: string, expectedRevision?: number) => Promise<boolean>
getWorkspace: () => Workspace
connections: Connection[] | undefined
onApplied: () => void
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
persist: (id: string) => Promise<boolean>
ready: boolean;
saving: boolean;
storageError?: string;
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>;
select: (id: string) => boolean;
remove: (id: string, expectedRevision?: number) => Promise<boolean>;
getWorkspace: () => Workspace;
connections: Connection[] | undefined;
onApplied: () => void;
createTemporary: (title: string, columns?: Deck["columns"]) => DeckView;
persist: (id: string) => Promise<boolean>;
}) {
const enabled = useWebMCPSupported() && ready
const enabled = useWebMCPSupported() && ready;
function requireDeck(id?: string) {
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (id ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return deck
const workspace = getWorkspace();
const deck = workspace.decks.find((deck) => deck.id === (id ?? workspace.activeDeckId));
if (!deck) throw new Error("Deck not found.");
return deck;
}
function requireIdle() {
if (saving) throw new Error('Wait for the current save to finish.')
if (saving) throw new Error("Wait for the current save to finish.");
}
function applied(success: boolean) {
if (!success)
throw new Error(
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
)
onApplied()
"The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.",
);
onApplied();
}
useWebMCP({
name: 'list_connections',
name: "list_connections",
description:
'List available connected accounts and stable connection IDs for each platform. Credentials are never returned.',
"List available connected accounts and stable connection IDs for each platform. Credentials are never returned.",
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { connections: connections ?? null }
emptyToolInput.parse(input);
return { connections: connections ?? null };
}),
})
});
useWebMCP({
name: 'list_decks',
name: "list_decks",
description:
'List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.',
"List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.",
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { ...getWorkspace(), storageError: storageError ?? null }
emptyToolInput.parse(input);
return { ...getWorkspace(), storageError: storageError ?? null };
}),
})
});
useWebMCP({
name: 'get_deck',
name: "get_deck",
description:
'Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.',
"Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.",
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
@@ -94,86 +92,83 @@ export function useDeckTools({
deck: requireDeck(getDeckInput.parse(input).deckId),
storageError: storageError ?? null,
})),
})
});
useWebMCP({
name: 'set_deck',
name: "set_deck",
description:
'Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.',
"Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.",
inputSchema: setDeckInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const parsed = setDeckInput.parse(input)
if (!connections) throw new Error('Connections are not available yet.')
const next = prepareDeck(parsed, connections)
requireIdle();
const parsed = setDeckInput.parse(input);
if (!connections) throw new Error("Connections are not available yet.");
const next = prepareDeck(parsed, connections);
if (!parsed.deckId) {
const deck = createTemporary(next.title, next.columns)
onApplied()
return { deck, persisted: false, posts: 'loading-asynchronously' }
const deck = createTemporary(next.title, next.columns);
onApplied();
return { deck, persisted: false, posts: "loading-asynchronously" };
}
const previous = requireDeck(parsed.deckId)
const previous = requireDeck(parsed.deckId);
if (previous.persisted && parsed.expectedRevision === undefined)
throw new Error(
'expectedRevision is required to update a saved deck.',
)
applied(await save(next, parsed.expectedRevision))
const deck = requireDeck(next.id)
throw new Error("expectedRevision is required to update a saved deck.");
applied(await save(next, parsed.expectedRevision));
select(next.id);
const deck = requireDeck(next.id);
return {
deck,
persisted: deck.persisted,
posts: 'loading-asynchronously',
}
posts: "loading-asynchronously",
};
}),
})
});
useWebMCP({
name: 'save_deck',
name: "save_deck",
description:
'Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.',
"Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.",
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(await persist(deckId))
return { deck: requireDeck(deckId), persisted: true }
requireIdle();
const { deckId } = deckIdInput.parse(input);
applied(await persist(deckId));
return { deck: requireDeck(deckId), persisted: true };
}),
})
});
useWebMCP({
name: 'select_deck',
name: "select_deck",
description:
'Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.',
"Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.",
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
return getWorkspace()
requireIdle();
const { deckId } = deckIdInput.parse(input);
applied(select(deckId));
return getWorkspace();
}),
})
});
useWebMCP({
name: 'delete_deck',
name: "delete_deck",
description:
'Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.',
"Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.",
inputSchema: deleteDeckToolInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input)
requireIdle();
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input);
if (requireDeck(deckId).persisted && expectedRevision === undefined)
throw new Error(
'expectedRevision is required to delete a saved deck.',
)
applied(await remove(deckId, expectedRevision))
return getWorkspace()
throw new Error("expectedRevision is required to delete a saved deck.");
applied(await remove(deckId, expectedRevision));
return getWorkspace();
}),
})
});
}
+18 -39
View File
@@ -1,64 +1,43 @@
import { conversations } from '#/features/support/mock-conversations'
import { useWorkspaceState } from '#/features/workspace/workspace-state'
import { conversations } from "#/features/support/mock-conversations";
import { useWorkspaceState } from "#/features/workspace/workspace-state";
export function DailyBrief({
onOpenContact,
}: {
onOpenContact: (id: string) => void
}) {
const { tasks, statuses, entries } = useWorkspaceState()
const focus = tasks.find(
(task) => task.section === 'focus' && task.status === 'todo',
)
const optional = tasks.find(
(task) => task.section === 'optional' && task.status === 'todo',
)
const contacts = conversations.filter(
(person) => (statuses[person.id] ?? 'now') === 'now',
)
const firstContact = contacts[0]
export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => void }) {
const { tasks, statuses, entries } = useWorkspaceState();
const focus = tasks.find((task) => task.section === "focus" && task.status === "todo");
const optional = tasks.find((task) => task.section === "optional" && task.status === "todo");
const contacts = conversations.filter((person) => (statuses[person.id] ?? "now") === "now");
const firstContact = contacts[0];
const resume = entries.find(
(entry) =>
entry.kind === '再開地点' &&
!entries.some((other) => other.correctionOf === entry.id),
)
entry.kind === "再開地点" && !entries.some((other) => other.correctionOf === entry.id),
);
return (
<section className="home-brief" aria-label="Daily brief">
<p>
{focus
? `Start with “${focus.title}” today.`
: 'There are no priority tasks right now.'}
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
{firstContact ? (
<>
{' '}
<button
type="button"
onClick={() => onOpenContact(firstContact.id)}
>
{" "}
<button type="button" onClick={() => onOpenContact(firstContact.id)}>
Reply to {firstContact.name}
</button>
{contacts.length > 1
? ` — one of ${contacts.length} conversations to review.`
: ' when you’re ready.'}
: " when you’re ready."}
</>
) : (
' There are no conversations awaiting your attention.'
" There are no conversations awaiting your attention."
)}
</p>
{resume ? (
<p className="home-brief-secondary">
Your resume note: “
{resume.text.length > 100
? `${resume.text.slice(0, 100)}…`
: resume.text}
”
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
</p>
) : optional ? (
<p className="home-brief-secondary">
“{optional.title}” can wait until you have room.
</p>
<p className="home-brief-secondary">“{optional.title}” can wait until you have room.</p>
) : null}
</section>
)
);
}
+143 -207
View File
@@ -1,4 +1,4 @@
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router'
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
import {
ArrowDown,
ArrowUp,
@@ -11,161 +11,146 @@ import {
Plus,
RotateCcw,
X,
} from 'lucide-react'
import { useRef, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
import { Textarea } from '#/components/ui/textarea'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { articles } from '#/features/inbox/mock-articles'
import { ConversationDetail } from '#/features/support/conversation-detail'
import { conversations } from '#/features/support/mock-conversations'
import {
entryKindLabels,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import { DailyBrief } from './daily-brief'
import { type HomeAction, interpretAction } from './interpret-action'
import './home.css'
} from "lucide-react";
import { useRef, useState } from "react";
import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
import { Textarea } from "#/components/ui/textarea";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
import { articles } from "#/features/inbox/mock-articles";
import { useUrlState } from "#/features/navigation/use-url-state";
import { ConversationDetail } from "#/features/support/conversation-detail";
import { conversations } from "#/features/support/mock-conversations";
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
import { DailyBrief } from "./daily-brief";
import { type HomeAction, interpretAction } from "./interpret-action";
export function HomePage() {
const { auth } = useRouteContext({ from: '__root__' })
const {
tasks,
setTasks,
statuses,
setStatuses,
outcomes,
setOutcomes,
entries,
setEntries,
} = useWorkspaceState()
const navigate = useNavigate()
const input = useRef<HTMLTextAreaElement>(null)
const [prompt, setPrompt] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const { tasks, setTasks, statuses, setStatuses, outcomes, setOutcomes, entries, setEntries } =
useWorkspaceState();
const navigate = useNavigate();
const input = useRef<HTMLTextAreaElement>(null);
const [prompt, setPrompt] = useState("");
const [proposal, setProposal] = useState<{
action: HomeAction
label: string
} | null>(null)
const [notice, setNotice] = useState('')
const [contactId, setContactId] = useState<string>()
const [articleId, setArticleId] = useState<string>()
const contact = conversations.find((item) => item.id === contactId)
const article = articles.find((item) => item.id === articleId)
const waiting = conversations.filter(
(item) => (statuses[item.id] ?? 'now') === 'now',
)
action: HomeAction;
label: string;
} | null>(null);
const [notice, setNotice] = useState("");
const [search, updateSearch] = useUrlState();
const contactId = search.conversation;
const articleId = search.article;
const setContactId = (id: string | undefined) =>
updateSearch({ conversation: id, article: undefined });
const setArticleId = (id: string | undefined) =>
updateSearch({ article: id, conversation: undefined });
const contact = conversations.find((item) => item.id === contactId);
const article = articles.find((item) => item.id === articleId);
const waiting = conversations.filter((item) => (statuses[item.id] ?? "now") === "now");
const currentEntries = entries.filter(
(entry) => !entries.some((other) => other.correctionOf === entry.id),
)
const rest = tasks.filter((task) => task.status !== 'todo')
);
const rest = tasks.filter((task) => task.status !== "todo");
function suggest(text: string) {
setPrompt(text)
setProposal(null)
input.current?.focus()
setPrompt(text);
setProposal(null);
input.current?.focus();
}
function interpret() {
const result = interpretAction(prompt, tasks, conversations)
if ('error' in result) {
setNotice(result.error)
setProposal(null)
const result = interpretAction(prompt, tasks, conversations);
if ("error" in result) {
setNotice(result.error);
setProposal(null);
} else {
setProposal(result)
setNotice('')
setProposal(result);
setNotice("");
}
}
function apply(action: HomeAction) {
switch (action.type) {
case 'add-task':
case "add-task":
setTasks((current) => [
...current,
{
id: crypto.randomUUID(),
title: action.title,
section: action.section,
status: 'todo',
status: "todo",
},
])
break
case 'task-status':
]);
break;
case "task-status":
setTasks((current) =>
current.map((task) =>
task.id === action.id ? { ...task, status: action.status } : task,
),
)
break
case 'task-move':
moveTask(action.id, action.direction)
break
case 'routine-step':
);
break;
case "task-move":
moveTask(action.id, action.direction);
break;
case "routine-step":
setTasks((current) =>
current.map((task) =>
task.id === action.id
? {
...task,
completedSteps: action.done
? Array.from(
new Set([...(task.completedSteps ?? []), action.step]),
)
: (task.completedSteps ?? []).filter(
(step) => step !== action.step,
),
? Array.from(new Set([...(task.completedSteps ?? []), action.step]))
: (task.completedSteps ?? []).filter((step) => step !== action.step),
}
: task,
),
)
break
case 'contact':
setContactId(action.id)
break
case 'contact-status':
setStatuses((current) => ({ ...current, [action.id]: action.status }))
break
case 'journal':
);
break;
case "contact":
setContactId(action.id);
break;
case "contact-status":
setStatuses((current) => ({ ...current, [action.id]: action.status }));
break;
case "journal":
setEntries((current) => [
{
id: crypto.randomUUID(),
kind: action.kind,
text: action.text,
source: '',
occurredAt: '',
source: "",
occurredAt: "",
recordedAt: new Date().toISOString(),
},
...current,
])
break
case 'navigate':
void navigate({ to: action.to })
break
]);
break;
case "navigate":
void navigate({ to: action.to });
break;
}
setNotice(proposal?.label ?? 'Updated')
setProposal(null)
setPrompt('')
setNotice(proposal?.label ?? "Updated");
setProposal(null);
setPrompt("");
}
function moveTask(id: string, direction: -1 | 1) {
setTasks((current) => {
const index = current.findIndex((task) => task.id === id)
const index = current.findIndex((task) => task.id === id);
const siblings = current
.map((task, i) => ({ task, i }))
.filter(
({ task }) =>
task.section === current[index]?.section && task.status === 'todo',
)
const position = siblings.findIndex(({ task }) => task.id === id)
const target = siblings[position + direction]?.i
if (target === undefined) return current
const sourceTask = current[index]
const targetTask = current[target]
if (!sourceTask || !targetTask) return current
const updated = [...current]
updated[index] = targetTask
updated[target] = sourceTask
return updated
})
.filter(({ task }) => task.section === current[index]?.section && task.status === "todo");
const position = siblings.findIndex(({ task }) => task.id === id);
const target = siblings[position + direction]?.i;
if (target === undefined) return current;
const sourceTask = current[index];
const targetTask = current[target];
if (!sourceTask || !targetTask) return current;
const updated = [...current];
updated[index] = targetTask;
updated[target] = sourceTask;
return updated;
});
}
return (
<TooltipProvider delay={500}>
@@ -182,8 +167,8 @@ export function HomePage() {
<form
className="home-composer"
onSubmit={(event) => {
event.preventDefault()
interpret()
event.preventDefault();
interpret();
}}
>
<Textarea
@@ -192,25 +177,23 @@ export function HomePage() {
placeholder="What’s on your mind?"
value={prompt}
onChange={(event) => {
setPrompt(event.target.value)
setProposal(null)
setNotice('')
setPrompt(event.target.value);
setProposal(null);
setNotice("");
}}
onKeyDown={(event) => {
if (
(event.metaKey || event.ctrlKey) &&
event.key === 'Enter' &&
event.key === "Enter" &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
interpret()
event.preventDefault();
interpret();
}
}}
/>
<div className="home-composer-footer">
<span className="text-xs text-muted-foreground">
Tasks, replies, notes.
</span>
<span className="text-xs text-muted-foreground">Tasks, replies, notes.</span>
<Button
type="submit"
size="icon"
@@ -222,16 +205,8 @@ export function HomePage() {
</div>
</form>
<div className="home-suggestions">
{[
'Add grocery shopping',
'Reply to Aoki',
'Note: Resume on page 2',
].map((text) => (
<button
type="button"
key={text}
onClick={() => suggest(text)}
>
{["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => (
<button type="button" key={text} onClick={() => suggest(text)}>
{text}
<ArrowUpRight className="size-3" />
</button>
@@ -255,12 +230,12 @@ export function HomePage() {
{notice}
</p>
<div className="home-columns">
<section className="home-plan" aria-label="Today’s plan">
<section aria-label="Today’s plan">
{(
[
{ id: 'focus', label: 'Start here' },
{ id: 'routine', label: 'Routines' },
{ id: 'optional', label: 'If there’s room' },
{ id: "focus", label: "Start here" },
{ id: "routine", label: "Routines" },
{ id: "optional", label: "If there’s room" },
] as const
).map((section) => (
<section key={section.id} className="home-section">
@@ -272,11 +247,11 @@ export function HomePage() {
aria-label={`Add to ${section.label}`}
onClick={() =>
suggest(
section.id === 'routine'
? 'Add routine:'
: section.id === 'optional'
? 'Optional: '
: 'Add:',
section.id === "routine"
? "Add routine:"
: section.id === "optional"
? "Optional: "
: "Add:",
)
}
>
@@ -284,11 +259,7 @@ export function HomePage() {
</Button>
</header>
{tasks
.filter(
(task) =>
task.section === section.id &&
task.status === 'todo',
)
.filter((task) => task.section === section.id && task.status === "todo")
.map((task) => (
<div className="home-task" key={task.id}>
<button
@@ -298,9 +269,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'done' }
: item,
item.id === task.id ? { ...item, status: "done" } : item,
),
)
}
@@ -321,11 +290,7 @@ export function HomePage() {
>
<Checkbox
id={`step-${task.id}-${step}`}
checked={
task.completedSteps?.includes(
step,
) ?? false
}
checked={task.completedSteps?.includes(step) ?? false}
onCheckedChange={(checked) =>
setTasks((current) =>
current.map((item) =>
@@ -333,17 +298,9 @@ export function HomePage() {
? {
...item,
completedSteps: checked
? [
...(item.completedSteps ??
[]),
step,
]
: (
item.completedSteps ??
[]
).filter(
(value) =>
value !== step,
? [...(item.completedSteps ?? []), step]
: (item.completedSteps ?? []).filter(
(value) => value !== step,
),
}
: item,
@@ -382,9 +339,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'later' }
: item,
item.id === task.id ? { ...item, status: "later" } : item,
),
)
}
@@ -394,7 +349,7 @@ export function HomePage() {
</div>
</div>
))}
{section.id === 'focus' &&
{section.id === "focus" &&
waiting.slice(0, 2).map((item) => (
<div className="home-task" key={item.id}>
<MessagesSquare className="size-5 text-muted-foreground mt-1" />
@@ -403,12 +358,8 @@ export function HomePage() {
className="home-task-body text-left"
onClick={() => setContactId(item.id)}
>
<span className="block">
Reply to {item.name}
</span>
<span className="text-xs text-muted-foreground">
{item.subject}
</span>
<span className="block">Reply to {item.name}</span>
<span className="text-xs text-muted-foreground">{item.subject}</span>
</button>
<Button
variant="ghost"
@@ -421,13 +372,10 @@ export function HomePage() {
</div>
))}
{!tasks.some(
(task) =>
task.section === section.id && task.status === 'todo',
(task) => task.section === section.id && task.status === "todo",
) &&
(section.id !== 'focus' || !waiting.length) && (
<p className="home-empty">
It’s okay to leave some room.
</p>
(section.id !== "focus" || !waiting.length) && (
<p className="home-empty">It’s okay to leave some room.</p>
)}
</section>
))}
@@ -438,7 +386,7 @@ export function HomePage() {
</summary>
{rest.map((task) => (
<div className="home-task" key={task.id}>
{task.status === 'done' ? (
{task.status === "done" ? (
<Check className="size-4" />
) : (
<Coffee className="size-4" />
@@ -451,9 +399,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'todo' }
: item,
item.id === task.id ? { ...item, status: "todo" } : item,
),
)
}
@@ -465,7 +411,7 @@ export function HomePage() {
</details>
)}
</section>
<aside className="home-context" aria-label="Today’s context">
<aside aria-label="Today’s context">
<section className="home-section">
<header className="home-section-heading">
<h2>Messages</h2>
@@ -474,12 +420,7 @@ export function HomePage() {
</Link>
</header>
{conversations
.filter(
(item) =>
!['sent', 'dismissed'].includes(
statuses[item.id] ?? 'now',
),
)
.filter((item) => !["sent", "dismissed"].includes(statuses[item.id] ?? "now"))
.map((item) => (
<button
className="home-context-row"
@@ -489,11 +430,11 @@ export function HomePage() {
>
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{statuses[item.id] === 'waiting'
? 'Waiting'
: statuses[item.id] === 'later'
? 'Later'
: item.channels.join(' / ')}
{statuses[item.id] === "waiting"
? "Waiting"
: statuses[item.id] === "later"
? "Later"
: item.channels.join(" / ")}
</span>
</button>
))}
@@ -512,7 +453,7 @@ export function HomePage() {
</span>
<span>
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
{entry.kind === "体重" ? " kg" : ""}
</span>
</p>
))}
@@ -520,7 +461,7 @@ export function HomePage() {
<button
type="button"
className="home-context-row"
onClick={() => suggest('Note:')}
onClick={() => suggest("Note:")}
>
<span>Leave a note</span>
<Plus className="size-4" />
@@ -541,9 +482,7 @@ export function HomePage() {
key={item.id}
onClick={() => setArticleId(item.id)}
>
<span className="text-xs text-muted-foreground">
{item.source}
</span>
<span className="text-xs text-muted-foreground">{item.source}</span>
<span>{item.title}</span>
</button>
))}
@@ -566,16 +505,13 @@ export function HomePage() {
<ConversationDetail
conversation={contact}
onResolve={(status, note) => {
setStatuses((current) => ({ ...current, [contact.id]: status }))
if (note)
setOutcomes((current) => ({ ...current, [contact.id]: note }))
setContactId(undefined)
setStatuses((current) => ({ ...current, [contact.id]: status }));
if (note) setOutcomes((current) => ({ ...current, [contact.id]: note }));
setContactId(undefined);
}}
/>
{outcomes[contact.id] && (
<p className="text-sm whitespace-pre-wrap">
{outcomes[contact.id]}
</p>
<p className="text-sm whitespace-pre-wrap">{outcomes[contact.id]}</p>
)}
</Dialog>
)}
@@ -595,5 +531,5 @@ export function HomePage() {
)}
</SidebarProvider>
</TooltipProvider>
)
);
}
+135 -179
View File
@@ -1,210 +1,166 @@
import { describe, expect, it } from 'vitest'
import { interpretAction } from './interpret-action'
import { describe, expect, it } from "vitest";
import { interpretAction } from "./interpret-action";
const tasks = [
{ id: 'stretch', title: 'ストレッチ' },
{ id: 'shop', title: '買い物' },
]
{ id: "stretch", title: "ストレッチ" },
{ id: "shop", title: "買い物" },
];
const contacts = [
{ id: 'aoki', name: '青木 遥' },
{ id: 'mori', name: '森 直人' },
]
{ id: "aoki", name: "青木 遥" },
{ id: "mori", name: "森 直人" },
];
describe('home natural-language proposals', () => {
describe("home natural-language proposals", () => {
it.each([
['買い物を上に移動', -1],
['買い物を下に動かして', 1],
])('moves the named task by one position: %s', (input, direction) => {
["買い物を上に移動", -1],
["買い物を下に動かして", 1],
])("moves the named task by one position: %s", (input, direction) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-move', id: 'shop', direction },
})
})
action: { type: "task-move", id: "shop", direction },
});
});
it('does not reorder a partial task match', () => {
expect(interpretAction('買いを上に移動', tasks, contacts)).toHaveProperty(
'error',
)
})
it("does not reorder a partial task match", () => {
expect(interpretAction("買いを上に移動", tasks, contacts)).toHaveProperty("error");
});
it.each([
['朝の支度の水を飲むを完了', true],
['朝の支度の水を飲むを未完了にして', false],
])('targets a named routine step: %s', (input, done) => {
["朝の支度の水を飲むを完了", true],
["朝の支度の水を飲むを未完了にして", false],
])("targets a named routine step: %s", (input, done) => {
expect(
interpretAction(
input,
[{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '着替える'] }],
[{ id: "morning", title: "朝の支度", steps: ["水を飲む", "着替える"] }],
contacts,
),
).toMatchObject({
action: { type: 'routine-step', id: 'morning', step: '水を飲む', done },
})
})
action: { type: "routine-step", id: "morning", step: "水を飲む", done },
});
});
it('rejects unknown and ambiguous routine steps', () => {
const routines = [
{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '水を飲む'] },
]
it("rejects unknown and ambiguous routine steps", () => {
const routines = [{ id: "morning", title: "朝の支度", steps: ["水を飲む", "水を飲む"] }];
expect(interpretAction("朝の支度の水を飲むを完了", routines, contacts)).toHaveProperty("error");
expect(interpretAction("朝の支度の着替えるを完了", routines, contacts)).toHaveProperty("error");
});
it.each([
["買い物を追加", "買い物", "focus"],
["追加:買い物", "買い物", "focus"],
["ルーティンに追加:夜のストレッチ", "夜のストレッチ", "routine"],
["夜のストレッチをルーティンに追加", "夜のストレッチ", "routine"],
["余裕があれば本を読む", "本を読む", "optional"],
])("proposes %s without mutating existing tasks", (input, title, section) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: "add-task", title, section },
});
expect(tasks).toHaveLength(2);
});
it.each([
["ストレッチを完了", "done"],
["ストレッチは終わった", "done"],
["ストレッチをあとで", "later"],
["ストレッチを保留", "later"],
["ストレッチを未完了にして", "todo"],
])("matches the whole task title for %s", (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: "task-status", id: "stretch", status },
});
});
it("rejects partial and duplicate task names", () => {
expect(interpretAction("買いを完了", tasks, contacts)).toHaveProperty("error");
expect(
interpretAction('朝の支度の水を飲むを完了', routines, contacts),
).toHaveProperty('error')
interpretAction("買い物を完了", [...tasks, { id: "duplicate", title: "買い物" }], contacts),
).toHaveProperty("error");
});
it("opens the uniquely named contact without sending", () => {
expect(interpretAction("青木さんに返信", tasks, contacts)).toMatchObject({
action: { type: "contact", id: "aoki" },
});
expect(interpretAction("青木遥に返信", tasks, contacts)).toMatchObject({
action: { type: "contact", id: "aoki" },
});
});
it("rejects ambiguous surnames", () => {
expect(
interpretAction('朝の支度の着替えるを完了', routines, contacts),
).toHaveProperty('error')
})
interpretAction("青木さんに返信", tasks, [...contacts, { id: "other", name: "青木 太郎" }]),
).toHaveProperty("error");
});
it.each([
['買い物を追加', '買い物', 'focus'],
['追加:買い物', '買い物', 'focus'],
['ルーティンに追加:夜のストレッチ', '夜のストレッチ', 'routine'],
['夜のストレッチをルーティンに追加', '夜のストレッチ', 'routine'],
['余裕があれば本を読む', '本を読む', 'optional'],
])('proposes %s without mutating existing tasks', (input, title, section) => {
["森さんを相手待ちにして", "waiting"],
["森さんを保留", "later"],
])("changes only the named contact for %s", (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'add-task', title, section },
})
expect(tasks).toHaveLength(2)
})
action: { type: "contact-status", id: "mori", status },
});
});
it.each([
['ストレッチを完了', 'done'],
['ストレッチは終わった', 'done'],
['ストレッチをあとで', 'later'],
['ストレッチを保留', 'later'],
['ストレッチを未完了にして', 'todo'],
])('matches the whole task title for %s', (input, status) => {
["メモ:散歩でアイデアを思いついた", "メモ", "散歩でアイデアを思いついた"],
["再開地点:レビューの続き", "再開地点", "レビューの続き"],
["体重は65.2kg", "体重", "65.2"],
["薬を飲んだ", "服薬", "Took medication"],
])("records explicit user statements: %s", (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-status', id: 'stretch', status },
})
})
it('rejects partial and duplicate task names', () => {
expect(interpretAction('買いを完了', tasks, contacts)).toHaveProperty(
'error',
)
expect(
interpretAction(
'買い物を完了',
[...tasks, { id: 'duplicate', title: '買い物' }],
contacts,
),
).toHaveProperty('error')
})
it('opens the uniquely named contact without sending', () => {
expect(interpretAction('青木さんに返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
expect(interpretAction('青木遥に返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
})
it('rejects ambiguous surnames', () => {
expect(
interpretAction('青木さんに返信', tasks, [
...contacts,
{ id: 'other', name: '青木 太郎' },
]),
).toHaveProperty('error')
})
action: { type: "journal", kind, text },
});
});
it.each([
['森さんを相手待ちにして', 'waiting'],
['森さんを保留', 'later'],
])('changes only the named contact for %s', (input, status) => {
"ストレッチを完了にしないで",
"買い物を追加しない",
"青木さんに送信",
"青木さんに返信を送って",
"薬を何錠飲めばいい",
"体重を記録",
"適当に片付けて",
"toString",
"買い物を完了、ストレッチを追加",
"買い物を完了して、ストレッチを追加",
])("leaves unsupported, negative, and compound requests unchanged: %s", (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty("error");
});
it.each([
["連絡を開いて", "/support"],
["記録", "/journal"],
["リーダーを開く", "/inbox"],
["Researchに移動", "/deck"],
])("recognizes navigation: %s", (input, to) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'contact-status', id: 'mori', status },
})
})
action: { type: "navigate", to },
});
});
});
describe("English home commands", () => {
const tasks = [{ id: "shop", title: "grocery shopping" }];
const contacts = [{ id: "aoki", name: "Haruka Aoki" }];
it.each([
['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'],
['再開地点:レビューの続き', '再開地点', 'レビューの続き'],
['体重は65.2kg', '体重', '65.2'],
['薬を飲んだ', '服薬', 'Took medication'],
])('records explicit user statements: %s', (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'journal', kind, text },
})
})
it.each([
'ストレッチを完了にしないで',
'買い物を追加しない',
'青木さんに送信',
'青木さんに返信を送って',
'薬を何錠飲めばいい',
'体重を記録',
'適当に片付けて',
'toString',
'買い物を完了、ストレッチを追加',
'買い物を完了して、ストレッチを追加',
])('leaves unsupported, negative, and compound requests unchanged: %s', (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty('error')
})
it.each([
['連絡を開いて', '/support'],
['記録', '/journal'],
['リーダーを開く', '/inbox'],
['Researchに移動', '/deck'],
])('recognizes navigation: %s', (input, to) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'navigate', to },
})
})
})
describe('English home commands', () => {
const tasks = [{ id: 'shop', title: 'grocery shopping' }]
const contacts = [{ id: 'aoki', name: 'Haruka Aoki' }]
it.each([
[
'Add grocery shopping',
{ type: 'add-task', title: 'grocery shopping', section: 'focus' },
],
[
'Add routine: Stretch',
{ type: 'add-task', title: 'Stretch', section: 'routine' },
],
[
'Optional: Read',
{ type: 'add-task', title: 'Read', section: 'optional' },
],
['Reply to Aoki', { type: 'contact', id: 'aoki' }],
[
'Note: Resume on page 2',
{ type: 'journal', kind: 'メモ', text: 'Resume on page 2' },
],
['Resume: Page 2', { type: 'journal', kind: '再開地点', text: 'Page 2' }],
['Weight: 65.2 kg', { type: 'journal', kind: '体重', text: '65.2' }],
[
'Complete grocery shopping',
{ type: 'task-status', id: 'shop', status: 'done' },
],
[
'Defer grocery shopping',
{ type: 'task-status', id: 'shop', status: 'later' },
],
[
'Restore grocery shopping',
{ type: 'task-status', id: 'shop', status: 'todo' },
],
[
'Move grocery shopping up',
{ type: 'task-move', id: 'shop', direction: -1 },
],
['Open reader', { type: 'navigate', to: '/inbox' }],
])('interprets %s', (input, action) => {
["Add grocery shopping", { type: "add-task", title: "grocery shopping", section: "focus" }],
["Add routine: Stretch", { type: "add-task", title: "Stretch", section: "routine" }],
["Optional: Read", { type: "add-task", title: "Read", section: "optional" }],
["Reply to Aoki", { type: "contact", id: "aoki" }],
["Note: Resume on page 2", { type: "journal", kind: "メモ", text: "Resume on page 2" }],
["Resume: Page 2", { type: "journal", kind: "再開地点", text: "Page 2" }],
["Weight: 65.2 kg", { type: "journal", kind: "体重", text: "65.2" }],
["Complete grocery shopping", { type: "task-status", id: "shop", status: "done" }],
["Defer grocery shopping", { type: "task-status", id: "shop", status: "later" }],
["Restore grocery shopping", { type: "task-status", id: "shop", status: "todo" }],
["Move grocery shopping up", { type: "task-move", id: "shop", direction: -1 }],
["Open reader", { type: "navigate", to: "/inbox" }],
])("interprets %s", (input, action) => {
expect(interpretAction(input as string, tasks, contacts)).toMatchObject({
action,
})
})
it.each([
"Don't complete grocery shopping",
'Send a reply to Aoki',
])('rejects %s', (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty('error')
})
})
});
});
it.each(["Don't complete grocery shopping", "Send a reply to Aoki"])("rejects %s", (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty("error");
});
});
+125 -145
View File
@@ -1,27 +1,27 @@
export type HomeAction =
| {
type: 'add-task'
title: string
section: 'focus' | 'routine' | 'optional'
type: "add-task";
title: string;
section: "focus" | "routine" | "optional";
}
| { type: 'task-status'; id: string; status: 'todo' | 'done' | 'later' }
| { type: 'task-move'; id: string; direction: -1 | 1 }
| { type: 'routine-step'; id: string; step: string; done: boolean }
| { type: 'contact'; id: string }
| { type: 'contact-status'; id: string; status: 'later' | 'waiting' }
| { type: "task-status"; id: string; status: "todo" | "done" | "later" }
| { type: "task-move"; id: string; direction: -1 | 1 }
| { type: "routine-step"; id: string; step: string; done: boolean }
| { type: "contact"; id: string }
| { type: "contact-status"; id: string; status: "later" | "waiting" }
| {
type: 'journal'
kind: 'メモ' | '再開地点' | '服薬' | '体重'
text: string
type: "journal";
kind: "メモ" | "再開地点" | "服薬" | "体重";
text: string;
}
| { type: 'navigate'; to: '/support' | '/journal' | '/inbox' | '/deck' }
| { type: "navigate"; to: "/support" | "/journal" | "/inbox" | "/deck" };
type Interpretation = { action: HomeAction; label: string } | { error: string }
type Interpretation = { action: HomeAction; label: string } | { error: string };
const unsupported = {
error:
'Try one action at a time, such as “Add grocery shopping”, “Reply to Aoki”, or “Note: A thought”.',
}
"Try one action at a time, such as “Add grocery shopping”, “Reply to Aoki”, or “Note: A thought”.",
};
/** Local, deterministic prototype. Returns a proposal; never performs an action. */
export function interpretAction(
@@ -31,227 +31,207 @@ export function interpretAction(
): Interpretation {
let text = input
.trim()
.replace(/[。!!]$/, '')
.trim()
.replace(/[。!!]$/, "")
.trim();
// Normalize English commands into the same small, deterministic grammar.
if (/\b(?:don't|do not|cancel|send)\b/i.test(text))
return { error: 'Please review this action in the relevant screen.' }
return { error: "Please review this action in the relevant screen." };
text = text
.replace(
/^open (messages|journal|reader|research|deck)$/i,
(_, name: string) =>
({
messages: '連絡',
journal: '記録',
reader: 'リーダー',
research: 'Research',
deck: 'デッキ',
messages: "連絡",
journal: "記録",
reader: "リーダー",
research: "Research",
deck: "デッキ",
})[name.toLowerCase()] ?? name,
)
.replace(/^note\s*:/i, 'メモ:')
.replace(/^resume(?: point)?\s*:/i, '再開地点:')
.replace(/^weight\s*:?\s*/i, '体重:')
.replace(/^log medication$|^took (?:my )?medication$/i, '服薬した')
.replace(/^reply to (.+)$/i, '$1に返信')
.replace(/^defer (.+)$/i, '$1を保留')
.replace(/^mark (.+) (?:as )?waiting$/i, '$1を相手待ち')
.replace(/^note\s*:/i, "メモ:")
.replace(/^resume(?: point)?\s*:/i, "再開地点:")
.replace(/^weight\s*:?\s*/i, "体重:")
.replace(/^log medication$|^took (?:my )?medication$/i, "服薬した")
.replace(/^reply to (.+)$/i, "$1に返信")
.replace(/^defer (.+)$/i, "$1を保留")
.replace(/^mark (.+) (?:as )?waiting$/i, "$1を相手待ち")
.replace(
/^move (.+) (up|down)$/i,
(_, title: string, direction: string) =>
`${title}を${direction.toLowerCase() === 'up' ? '上' : '下'}に移動`,
`${title}を${direction.toLowerCase() === "up" ? "上" : "下"}に移動`,
)
.replace(/^complete (.+)$/i, '$1を完了')
.replace(/^restore (.+)$/i, '$1を未完了')
.replace(/^add routine\s*:\s*(.+)$/i, '$1をルーティンに追加')
.replace(/^optional\s*:\s*(.+)$/i, '余裕があれば $1')
.replace(/^add\s*:?\s+(.+)$/i, '$1を追加')
if (!text) return { error: 'Enter what you would like to do.' }
.replace(/^complete (.+)$/i, "$1を完了")
.replace(/^restore (.+)$/i, "$1を未完了")
.replace(/^add routine\s*:\s*(.+)$/i, "$1をルーティンに追加")
.replace(/^optional\s*:\s*(.+)$/i, "余裕があれば $1")
.replace(/^add\s*:?\s+(.+)$/i, "$1を追加");
if (!text) return { error: "Enter what you would like to do." };
if (
/しない|しなく|せず|しないで|やめ|取り消|取消|ではなく|じゃなく|不要|送信|送って|送る/.test(
text,
)
) {
return {
error: 'Please review this action in the relevant screen.',
}
error: "Please review this action in the relevant screen.",
};
}
const navigation: Record<
string,
'/support' | '/journal' | '/inbox' | '/deck'
> = {
連絡: '/support',
記録: '/journal',
リーダー: '/inbox',
Research: '/deck',
デッキ: '/deck',
}
const destination = text.replace(/(?:を開いて|を開く|に移動)$/, '')
const route = Object.hasOwn(navigation, destination)
? navigation[destination]
: undefined
const navigation: Record<string, "/support" | "/journal" | "/inbox" | "/deck"> = {
連絡: "/support",
記録: "/journal",
リーダー: "/inbox",
Research: "/deck",
デッキ: "/deck",
};
const destination = text.replace(/(?:を開いて|を開く|に移動)$/, "");
const route = Object.hasOwn(navigation, destination) ? navigation[destination] : undefined;
if (route)
return {
action: { type: 'navigate', to: route },
label: `Open ${{ '/support': 'Messages', '/journal': 'Journal', '/inbox': 'Reader', '/deck': 'Research' }[route]}`,
}
action: { type: "navigate", to: route },
label: `Open ${{ "/support": "Messages", "/journal": "Journal", "/inbox": "Reader", "/deck": "Research" }[route]}`,
};
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s)
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s);
if (note?.[2]) {
const kind = note[1] === 'メモ' ? 'メモ' : '再開地点'
const body = note[2].trim()
if (!body) return unsupported
const kind = note[1] === "メモ" ? "メモ" : "再開地点";
const body = note[2].trim();
if (!body) return unsupported;
return {
action: { type: 'journal', kind, text: body },
label: `Save ${kind === 'メモ' ? 'note' : 'resume point'}`,
}
action: { type: "journal", kind, text: body },
label: `Save ${kind === "メモ" ? "note" : "resume point"}`,
};
}
const weight = text.match(
/^体重(?:は|を記録[::]?|[::]|\s)*\s*(\d{2,3}(?:\.\d{1,2})?)\s*(?:kg|キロ)(?:を記録|だった|です)?$/i,
)
);
if (weight?.[1])
return {
action: { type: 'journal', kind: '体重', text: weight[1] },
action: { type: "journal", kind: "体重", text: weight[1] },
label: `Log weight: ${weight[1]} kg`,
}
if (
/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(
text,
)
) {
};
if (/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(text)) {
return {
action: { type: 'journal', kind: '服薬', text: 'Took medication' },
label: 'Log medication',
}
action: { type: "journal", kind: "服薬", text: "Took medication" },
label: "Log medication",
};
}
const contact = text.match(
/^(.+?)(?:さん)?(?:に返信(?:したい|する)?|の返信(?:を開いて|を開く)?|を相手待ち(?:にして|にする)?|を保留(?:にして|にする)?)$/,
)
);
if (contact?.[1]) {
const name = contact[1].trim()
const name = contact[1].trim();
const matches = contacts.filter(
(item) =>
item.name === name ||
item.name.replace(/\s/g, '') === name ||
item.name
.split(/\s+/)
.some((part) => part.toLowerCase() === name.toLowerCase()),
)
const matchingTasks = tasks.filter((task) => task.title === name)
const matchingTask = matchingTasks[0]
if (
matches.length === 0 &&
matchingTasks.length === 1 &&
matchingTask &&
/保留/.test(text)
) {
item.name.replace(/\s/g, "") === name ||
item.name.split(/\s+/).some((part) => part.toLowerCase() === name.toLowerCase()),
);
const matchingTasks = tasks.filter((task) => task.title === name);
const matchingTask = matchingTasks[0];
if (matches.length === 0 && matchingTasks.length === 1 && matchingTask && /保留/.test(text)) {
return {
action: {
type: 'task-status',
type: "task-status",
id: matchingTask.id,
status: 'later',
status: "later",
},
label: `Defer ${name}`,
}
};
}
if (matches.length > 0 && matchingTasks.length > 0 && /保留/.test(text)) {
return {
error:
'This matches both a task and a contact. Please use the relevant screen.',
}
error: "This matches both a task and a contact. Please use the relevant screen.",
};
}
const person = matches[0]
const person = matches[0];
if (matches.length !== 1 || !person)
return { error: 'Please enter the full name of one contact.' }
return { error: "Please enter the full name of one contact." };
if (/相手待ち|保留/.test(text)) {
const status = text.includes('相手待ち') ? 'waiting' : 'later'
const status = text.includes("相手待ち") ? "waiting" : "later";
return {
action: { type: 'contact-status', id: person.id, status },
label: `Mark the conversation with ${person.name} as ${status === 'waiting' ? 'waiting' : 'later'}`,
}
action: { type: "contact-status", id: person.id, status },
label: `Mark the conversation with ${person.name} as ${status === "waiting" ? "waiting" : "later"}`,
};
}
return {
action: { type: 'contact', id: person.id },
action: { type: "contact", id: person.id },
label: `Review reply to ${person.name}`,
}
};
}
const move = text.match(/^(.+?)を(上|下)(?:に移動|に動かして|へ|にして)$/)
const move = text.match(/^(.+?)を(上|下)(?:に移動|に動かして|へ|にして)$/);
if (move?.[1]) {
const title = move[1].trim()
const matches = tasks.filter((task) => task.title === title)
const task = matches[0]
const title = move[1].trim();
const matches = tasks.filter((task) => task.title === title);
const task = matches[0];
if (matches.length !== 1 || !task)
return {
error: 'Please use the exact name of one task from the list.',
}
error: "Please use the exact name of one task from the list.",
};
return {
action: {
type: 'task-move',
type: "task-move",
id: task.id,
direction: move[2] === '上' ? -1 : 1,
direction: move[2] === "上" ? -1 : 1,
},
label: `Move ${task.title} ${move[2] === '上' ? 'up' : 'down'}`,
}
label: `Move ${task.title} ${move[2] === "上" ? "up" : "down"}`,
};
}
const change = text.match(
/^(.+?)(?:を|は)(完了(?:にして|にする)?|終わった|あとで(?:にして|にする)?|後で(?:にして|にする)?|保留(?:にして|にする)?|未完了(?:にして|にする)?|戻して)$/,
)
);
if (change?.[1] && change[2]) {
const title = change[1].trim()
const matches = tasks.filter((task) => task.title === title)
const title = change[1].trim();
const matches = tasks.filter((task) => task.title === title);
const steps = tasks.flatMap((task) =>
(task.steps ?? [])
.filter((step) => `${task.title}の${step}` === title)
.map((step) => ({ id: task.id, step })),
)
);
if (matches.length + steps.length !== 1)
return {
error: 'Please use the exact name of one task from the list.',
}
error: "Please use the exact name of one task from the list.",
};
const status = /未完了|戻して/.test(change[2])
? 'todo'
? "todo"
: /完了|終わった/.test(change[2])
? 'done'
: 'later'
const step = steps[0]
? "done"
: "later";
const step = steps[0];
if (steps.length === 1 && step) {
if (status === 'later')
return { error: 'Routine steps can be marked done or incomplete.' }
if (status === "later") return { error: "Routine steps can be marked done or incomplete." };
return {
action: {
type: 'routine-step',
type: "routine-step",
id: step.id,
step: step.step,
done: status === 'done',
done: status === "done",
},
label: `Mark ${title} as ${status === 'done' ? 'done' : 'incomplete'}`,
}
label: `Mark ${title} as ${status === "done" ? "done" : "incomplete"}`,
};
}
const task = matches[0]
if (!task) return unsupported
const task = matches[0];
if (!task) return unsupported;
return {
action: { type: 'task-status', id: task.id, status },
label: `Mark ${title} as ${status === 'done' ? 'done' : status === 'later' ? 'later' : 'incomplete'}`,
}
action: { type: "task-status", id: task.id, status },
label: `Mark ${title} as ${status === "done" ? "done" : status === "later" ? "later" : "incomplete"}`,
};
}
const routine =
text.match(/^(.+?)をルーティンに追加(?:して)?$/) ??
text.match(/^ルーティンに追加[::]\s*(.+)$/)
const optional = text.match(/^余裕があれば[、\s]*(.+)$/)
const add =
text.match(/^(.+?)を(?:タスクに)?追加(?:して)?$/) ??
text.match(/^追加[::]\s*(.+)$/)
const title = (routine?.[1] ?? optional?.[1] ?? add?.[1])?.trim()
text.match(/^ルーティンに追加[::]\s*(.+)$/);
const optional = text.match(/^余裕があれば[、\s]*(.+)$/);
const add = text.match(/^(.+?)を(?:タスクに)?追加(?:して)?$/) ?? text.match(/^追加[::]\s*(.+)$/);
const title = (routine?.[1] ?? optional?.[1] ?? add?.[1])?.trim();
if (title && !/[。、\n]|(?:してから|それから|と一緒に)/.test(title)) {
const section = routine ? 'routine' : optional ? 'optional' : 'focus'
const section = routine ? "routine" : optional ? "optional" : "focus";
return {
action: { type: 'add-task', title, section },
label: `Add “${title}” to ${section === 'routine' ? 'Routines' : section === 'optional' ? 'If there’s room' : 'Start here'}`,
}
action: { type: "add-task", title, section },
label: `Add “${title}” to ${section === "routine" ? "Routines" : section === "optional" ? "If there’s room" : "Start here"}`,
};
}
return unsupported
return unsupported;
}
+29 -34
View File
@@ -1,32 +1,31 @@
import { ArrowLeft, Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { ArrowLeft, Search } from "lucide-react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarProvider,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { type Article, articles } from './mock-articles'
import './inbox.css'
} from "#/components/ui/sidebar";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
import { useSearchInput } from "#/features/navigation/use-search-input";
import { useUrlState } from "#/features/navigation/use-url-state";
import { type Article, articles } from "./mock-articles";
export function InboxPage() {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>(
articles[0]?.id,
)
const [readingMobile, setReadingMobile] = useState(false)
const [search, updateSearch] = useUrlState();
const query = search.q ?? "";
const selectedId = search.article;
const readingMobile = !!selectedId;
const { clear: clearSearch, ...searchInput } = useSearchInput("article");
const visible = articles.filter((article) =>
`${article.title} ${article.summary} ${article.source}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
)
const selected =
visible.find((article) => article.id === selectedId) ?? visible[0]
);
const selected = visible.find((article) => article.id === selectedId) ?? visible[0];
return (
<TooltipProvider delay={500}>
@@ -45,8 +44,7 @@ export function InboxPage() {
aria-label="Search articles"
placeholder="Search"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
{...searchInput}
/>
</div>
</SidebarHeader>
@@ -57,15 +55,14 @@ export function InboxPage() {
article={article}
selected={selected?.id === article.id}
onSelect={() => {
setSelectedId(article.id)
setReadingMobile(true)
updateSearch({ article: article.id });
}}
/>
))}
{visible.length === 0 && (
<div className="inbox-no-results">
<p>No articles found</p>
<Button variant="outline" onClick={() => setQuery('')}>
<Button variant="outline" onClick={clearSearch}>
Clear search
</Button>
</div>
@@ -80,19 +77,17 @@ export function InboxPage() {
size="icon"
className="inbox-back"
aria-label="Back to articles"
onClick={() => setReadingMobile(false)}
onClick={() => updateSearch({ article: undefined })}
>
<ArrowLeft />
</Button>
<span className="text-xs text-muted-foreground">
{selected?.source}
</span>
<span className="text-xs text-muted-foreground">{selected?.source}</span>
</header>
<section
className="inbox-reader-scroll"
key={selected?.id}
aria-label="Article content"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable.
tabIndex={0}
>
{selected && <ArticleReader article={selected} />}
@@ -100,7 +95,7 @@ export function InboxPage() {
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
);
}
function ArticleRow({
@@ -108,15 +103,15 @@ function ArticleRow({
selected,
onSelect,
}: {
article: Article
selected: boolean
onSelect: () => void
article: Article;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className="inbox-article-row"
aria-current={selected ? 'true' : undefined}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
>
<span className="inbox-row-meta">
@@ -126,7 +121,7 @@ function ArticleRow({
<span className="inbox-row-title">{article.title}</span>
<span className="inbox-row-summary">{article.summary}</span>
</button>
)
);
}
function ArticleReader({ article }: { article: Article }) {
@@ -142,5 +137,5 @@ function ArticleReader({ article }: { article: Article }) {
))}
</div>
</article>
)
);
}
+92 -97
View File
@@ -1,138 +1,133 @@
export type Article = {
id: string
title: string
summary: string
source: string
author: string
date: string
paragraphs: string[]
}
id: string;
title: string;
summary: string;
source: string;
author: string;
date: string;
paragraphs: string[];
};
// All articles are original mock content. Editorial names are fictional.
export const articles: Article[] = [
{
id: 'agent-browser-workflow',
title: 'Thinking in the browser: a new workflow with AI agents',
id: "agent-browser-workflow",
title: "Thinking in the browser: a new workflow with AI agents",
summary:
'Make implementation and verification one task: open the screen, use it, and check the result.',
source: 'Field Notes',
author: 'Field Notes Editors',
date: '09:40',
"Make implementation and verification one task: open the screen, use it, and check the result.",
source: "Field Notes",
author: "Field Notes Editors",
date: "09:40",
paragraphs: [
'Correct code and a usable interface are different things. Is a button off screen? Does a draft survive navigation? Opening the browser is how we find out.',
'When requesting a change, include the user journey: select an article, read it, and return to the list. Completion becomes an observed behavior rather than a modified file.',
'Check both a typical screen width and a narrow one. Record what you tried and what you could not verify, so the next change has a clear starting point.',
'People decide whether an experience serves its purpose. Letting an agent repeat interactions leaves more time for wording, spacing, and information order.',
"Correct code and a usable interface are different things. Is a button off screen? Does a draft survive navigation? Opening the browser is how we find out.",
"When requesting a change, include the user journey: select an article, read it, and return to the list. Completion becomes an observed behavior rather than a modified file.",
"Check both a typical screen width and a narrow one. Record what you tried and what you could not verify, so the next change has a clear starting point.",
"People decide whether an experience serves its purpose. Letting an agent repeat interactions leaves more time for wording, spacing, and information order.",
],
},
{
id: 'small-cli-tools',
title: 'A good CLI considers its final line',
summary:
'Help, errors, and results: the smallest output details shape a tool’s experience.',
source: 'Toolbox Letter',
author: 'Toolbox Letter Editors',
date: '09:15',
id: "small-cli-tools",
title: "A good CLI considers its final line",
summary: "Help, errors, and results: the smallest output details shape a tool’s experience.",
source: "Toolbox Letter",
author: "Toolbox Letter Editors",
date: "09:15",
paragraphs: [
'A command can succeed without making clear what happened. Showing the output path or number of processed items helps people understand the result.',
'Keep the cause of an error close to the next useful action. “Choose an existing directory” is more helpful than “Invalid configuration.”',
'Human-readable output and machine-readable output serve different purposes. Offer concise text by default and a structured format when needed.',
"A command can succeed without making clear what happened. Showing the output path or number of processed items helps people understand the result.",
"Keep the cause of an error close to the next useful action. “Choose an existing directory” is more helpful than “Invalid configuration.”",
"Human-readable output and machine-readable output serve different purposes. Offer concise text by default and a structured format when needed.",
],
},
{
id: 'three-column-reading',
title: 'Three columns create a rhythm for reading',
summary: 'Choose a place, scan the options, and settle into an article.',
source: 'Quiet Interface',
author: 'Quiet Interface Editors',
date: '08:50',
id: "three-column-reading",
title: "Three columns create a rhythm for reading",
summary: "Choose a place, scan the options, and settle into an article.",
source: "Quiet Interface",
author: "Quiet Interface Editors",
date: "08:50",
paragraphs: [
'We do not read every article at the same depth. We choose a subject, scan headlines, and focus on the pieces that interest us. Three columns can give each activity its own space.',
'Navigation on the left, options in the middle, and content on the right help preserve context. A quiet selection state and a stable scroll position make returning easy.',
'Columns do not need equal widths. Give the list enough room for headlines, then use the remaining space for a comfortably sized reading pane.',
'On narrow screens, present these roles in sequence. A clear way back matters more than keeping every column visible.',
"We do not read every article at the same depth. We choose a subject, scan headlines, and focus on the pieces that interest us. Three columns can give each activity its own space.",
"Navigation on the left, options in the middle, and content on the right help preserve context. A quiet selection state and a stable scroll position make returning easy.",
"Columns do not need equal widths. Give the list enough room for headlines, then use the remaining space for a comfortably sized reading pane.",
"On narrow screens, present these roles in sequence. A clear way back matters more than keeping every column visible.",
],
},
{
id: 'agent-context',
title: 'Context for an agent can be brief',
summary:
'Align on the goal, constraints, and completion criteria before adding background.',
source: 'Field Notes',
author: 'Field Notes Editors',
date: '08:30',
id: "agent-context",
title: "Context for an agent can be brief",
summary: "Align on the goal, constraints, and completion criteria before adding background.",
source: "Field Notes",
author: "Field Notes Editors",
date: "08:30",
paragraphs: [
'Long requests can mix context, preferences, and requirements. Whether the recipient is a person or an agent, the first question is what the work should achieve.',
'Start with a one-sentence goal, then add the constraints that must hold. Finally, decide what to check before calling the work complete. Reference files and screenshots can support these three points.',
'When a decision changes, a short rewrite of the current direction can be more useful than appending more explanation. Make the active decisions easy to find.',
"Long requests can mix context, preferences, and requirements. Whether the recipient is a person or an agent, the first question is what the work should achieve.",
"Start with a one-sentence goal, then add the constraints that must hold. Finally, decide what to check before calling the work complete. Reference files and screenshots can support these three points.",
"When a decision changes, a short rewrite of the current direction can be more useful than appending more explanation. Make the active decisions easy to find.",
],
},
{
id: 'local-first-notes',
title: 'A small, local workspace for collecting information',
summary: 'Finding something again matters more than collecting it.',
source: 'Workbench Journal',
author: 'Workbench Journal Editors',
date: 'Yesterday',
id: "local-first-notes",
title: "A small, local workspace for collecting information",
summary: "Finding something again matters more than collecting it.",
source: "Workbench Journal",
author: "Workbench Journal Editors",
date: "Yesterday",
paragraphs: [
'Bookmarks, notes, and messages to yourself can scatter saved articles across many places. Convenient capture does not guarantee convenient retrieval.',
'Start by saving the title, source, and one sentence explaining why it matters. That sentence provides useful context when you find the article again.',
'Local files work well with familiar search tools and editors. If you use multiple devices, decide how to handle synchronization and conflicts before adding more tools.',
"Bookmarks, notes, and messages to yourself can scatter saved articles across many places. Convenient capture does not guarantee convenient retrieval.",
"Start by saving the title, source, and one sentence explaining why it matters. That sentence provides useful context when you find the article again.",
"Local files work well with familiar search tools and editors. If you use multiple devices, decide how to handle synchronization and conflicts before adding more tools.",
],
},
{
id: 'quiet-design-system',
title: 'Build a design system in the user’s language',
summary:
'Consistency extends beyond components to labels and empty states.',
source: 'Quiet Interface',
author: 'Quiet Interface Editors',
date: 'Yesterday',
id: "quiet-design-system",
title: "Build a design system in the user’s language",
summary: "Consistency extends beyond components to labels and empty states.",
source: "Quiet Interface",
author: "Quiet Interface Editors",
date: "Yesterday",
paragraphs: [
'Matching colors and corners do not prevent confusion if every screen names the same action differently. Words need shared rules too.',
'Start with the names of common actions and states. Agreeing on terms like unread, saved, and loading reduces decisions when building new screens.',
'An empty list should explain why it is empty and what can happen next. A useful sentence and action are often enough.',
"Matching colors and corners do not prevent confusion if every screen names the same action differently. Words need shared rules too.",
"Start with the names of common actions and states. Agreeing on terms like unread, saved, and loading reduces decisions when building new screens.",
"An empty list should explain why it is empty and what can happen next. A useful sentence and action are often enough.",
],
},
{
id: 'personal-feed',
title: 'A personal feed with understandable recommendations',
summary: 'Explain why an article connects to someone’s interests.',
source: 'Field Notes',
author: 'Field Notes Editors',
date: 'Yesterday',
id: "personal-feed",
title: "A personal feed with understandable recommendations",
summary: "Explain why an article connects to someone’s interests.",
source: "Field Notes",
author: "Field Notes Editors",
date: "Yesterday",
paragraphs: [
'Recommendations are easier to understand when their reasons are visible. A short explanation can show how an article relates to previous activity.',
'The reason does not need to be a complex score. It should accurately describe the information used, such as a connection to something previously read.',
'Interests change. A useful feed learns from activity and provides a way to correct irrelevant recommendations.',
"Recommendations are easier to understand when their reasons are visible. A short explanation can show how an article relates to previous activity.",
"The reason does not need to be a complex score. It should accurately describe the information used, such as a connection to something previously read.",
"Interests change. A useful feed learns from activity and provides a way to correct irrelevant recommendations.",
],
},
{
id: 'behavior-tests',
title: 'Test the promises made to users',
summary:
'Describe behavior that should survive changes to the implementation.',
source: 'Toolbox Letter',
author: 'Toolbox Letter Editors',
date: '2 days ago',
id: "behavior-tests",
title: "Test the promises made to users",
summary: "Describe behavior that should survive changes to the implementation.",
source: "Toolbox Letter",
author: "Toolbox Letter Editors",
date: "2 days ago",
paragraphs: [
'If splitting a component breaks many tests, consider whether those tests depend too much on internal structure. The promise is an observable result.',
'For an article list, test selecting a topic or finding a saved article. Using the visible names of controls makes the behavior being checked easier to understand.',
'Not everything needs a browser test. Check complex calculations in small tests and representative journeys in the interface. Match the scope to the change.',
"If splitting a component breaks many tests, consider whether those tests depend too much on internal structure. The promise is an observable result.",
"For an article list, test selecting a topic or finding a saved article. Using the visible names of controls makes the behavior being checked easier to understand.",
"Not everything needs a browser test. Check complex calculations in small tests and representative journeys in the interface. Match the scope to the change.",
],
},
{
id: 'reading-typography',
title: 'Readable text is about more than font size',
summary:
'Line length, line spacing, and paragraph spacing all affect reading.',
source: 'Quiet Interface',
author: 'Quiet Interface Editors',
date: '2 days ago',
id: "reading-typography",
title: "Readable text is about more than font size",
summary: "Line length, line spacing, and paragraph spacing all affect reading.",
source: "Quiet Interface",
author: "Quiet Interface Editors",
date: "2 days ago",
paragraphs: [
'Even large text is difficult to follow when lines are too long. Very narrow lines can also interrupt the rhythm. Consider width together with font size.',
'Line spacing helps readers follow individual lines, while paragraph spacing separates ideas. Their different roles should remain clear.',
'Use long headlines and several paragraphs when checking a layout. Reading for a few minutes reveals fatigue and confusion that a quick glance can miss.',
"Even large text is difficult to follow when lines are too long. Very narrow lines can also interrupt the rhythm. Consider width together with font size.",
"Line spacing helps readers follow individual lines, while paragraph spacing separates ideas. Their different roles should remain clear.",
"Use long headlines and several paragraphs when checking a layout. Reading for a few minutes reveals fatigue and confusion that a quick glance can miss.",
],
},
]
];
+89 -108
View File
@@ -1,43 +1,46 @@
import { useId, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
import { Textarea } from '#/components/ui/textarea'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { useId, useState } from "react";
import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { Input } from "#/components/ui/input";
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
import { Textarea } from "#/components/ui/textarea";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
import { useUrlState } from "#/features/navigation/use-url-state";
import {
type JournalEntry as Entry,
type EntryKind,
entryKindLabels,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import './journal.css'
} from "#/features/workspace/workspace-state";
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
type Draft = Pick<Entry, "kind" | "text" | "source" | "occurredAt">;
const emptyDraft: Draft = { kind: "メモ", text: "", source: "", occurredAt: "" };
const kinds: EntryKind[] = ["メモ", "再開地点", "服薬", "体重"];
function formatTime(value: string | undefined) {
if (!value) return 'Unknown'
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
if (!value) return "Unknown";
return new Date(value).toLocaleString("en-US", {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function JournalPage() {
const { entries, setEntries } = useWorkspaceState()
const [draft, setDraft] = useState<Draft>(emptyDraft)
const [correcting, setCorrecting] = useState<Entry | null>(null)
const [reviewing, setReviewing] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const superseded = new Set(entries.map((entry) => entry.correctionOf))
const current = entries.filter((entry) => !superseded.has(entry.id))
const { entries, setEntries } = useWorkspaceState();
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [search, updateSearch] = useUrlState();
const correcting = entries.find((entry) => entry.id === search.entry);
const reviewing = search.review;
const setCorrecting = (entry: Entry | null) => updateSearch({ entry: entry?.id });
const setReviewing = (open: boolean) => updateSearch({ review: open || undefined });
const [selected, setSelected] = useState<Set<string>>(new Set());
const superseded = new Set(entries.map((entry) => entry.correctionOf));
const current = entries.filter((entry) => !superseded.has(entry.id));
function append(value: Draft, correctionOf?: string) {
setEntries((previous) => [
@@ -50,7 +53,7 @@ export function JournalPage() {
correctionOf,
},
...previous,
])
]);
}
return (
@@ -64,8 +67,8 @@ export function JournalPage() {
variant="outline"
disabled={current.length === 0}
onClick={() => {
setSelected(new Set(current.map((entry) => entry.id)))
setReviewing(true)
setSelected(new Set(current.map((entry) => entry.id)));
setReviewing(true);
}}
>
Summarize entries
@@ -77,15 +80,13 @@ export function JournalPage() {
draft={draft}
onChange={setDraft}
onSubmit={() => {
append(draft)
setDraft(emptyDraft)
append(draft);
setDraft(emptyDraft);
}}
submitLabel="Save entry"
/>
<section className="journal-entries" aria-label="Journal entries">
{entries.length === 0 && (
<p className="journal-empty">No entries yet</p>
)}
{entries.length === 0 && <p className="journal-empty">No entries yet</p>}
{entries.map((entry) => (
<article
key={entry.id}
@@ -93,18 +94,12 @@ export function JournalPage() {
aria-label={`${entryKindLabels[entry.kind]} entry`}
>
<div className="journal-entry-heading">
<span className="text-sm font-medium">
{entryKindLabels[entry.kind]}
</span>
<span className="text-sm font-medium">{entryKindLabels[entry.kind]}</span>
{superseded.has(entry.id) && (
<span className="text-xs text-muted-foreground">
Original
</span>
<span className="text-xs text-muted-foreground">Original</span>
)}
{entry.correctionOf && (
<span className="text-xs text-muted-foreground">
Correction
</span>
<span className="text-xs text-muted-foreground">Correction</span>
)}
{!superseded.has(entry.id) && (
<Button
@@ -119,15 +114,15 @@ export function JournalPage() {
</div>
<p className="journal-entry-text">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
{entry.kind === "体重" ? " kg" : ""}
</p>
<p className="text-xs text-muted-foreground">
Occurred: {formatTime(entry.occurredAt)} · Recorded:{' '}
Occurred: {formatTime(entry.occurredAt)} · Recorded:{" "}
{formatTime(entry.recordedAt)}
</p>
{(entry.kind === '体重' || entry.source) && (
{(entry.kind === "体重" || entry.source) && (
<p className="mt-2 text-xs text-muted-foreground">
Source: {entry.source || 'Unknown'}
Source: {entry.source || "Unknown"}
</p>
)}
</article>
@@ -142,8 +137,8 @@ export function JournalPage() {
entry={correcting}
onClose={() => setCorrecting(null)}
onSave={(value) => {
append(value, correcting.id)
setCorrecting(null)
append(value, correcting.id);
setCorrecting(null);
}}
/>
)}
@@ -158,31 +153,26 @@ export function JournalPage() {
</p>
<div className="journal-review-list">
{current.map((entry) => (
<label
key={entry.id}
className="journal-review-row"
htmlFor={`review-${entry.id}`}
>
<label key={entry.id} className="journal-review-row" htmlFor={`review-${entry.id}`}>
<Checkbox
id={`review-${entry.id}`}
checked={selected.has(entry.id)}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous)
if (checked) next.add(entry.id)
else next.delete(entry.id)
return next
const next = new Set(previous);
if (checked) next.add(entry.id);
else next.delete(entry.id);
return next;
})
}
/>
<span>
<span className="text-xs text-muted-foreground">
{entryKindLabels[entry.kind]} ·{' '}
{formatTime(entry.occurredAt)}
{entryKindLabels[entry.kind]} · {formatTime(entry.occurredAt)}
</span>
<span className="block text-sm">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
{entry.kind === "体重" ? " kg" : ""}
</span>
</span>
</label>
@@ -192,14 +182,12 @@ export function JournalPage() {
aria-label="Entry summary"
className="journal-summary"
readOnly
value={buildSummary(
current.filter((entry) => selected.has(entry.id)),
)}
value={buildSummary(current.filter((entry) => selected.has(entry.id)))}
/>
</Dialog>
)}
</TooltipProvider>
)
);
}
function EntryEditor({
@@ -208,22 +196,21 @@ function EntryEditor({
onSubmit,
submitLabel,
}: {
draft: Draft
onChange: (draft: Draft) => void
onSubmit: () => void
submitLabel: string
draft: Draft;
onChange: (draft: Draft) => void;
onSubmit: () => void;
submitLabel: string;
}) {
const dateId = useId()
const dateId = useId();
const valid =
draft.text.trim().length > 0 &&
(draft.kind !== '体重' ||
(Number.isFinite(Number(draft.text)) && Number(draft.text) > 0))
(draft.kind !== "体重" || (Number.isFinite(Number(draft.text)) && Number(draft.text) > 0));
return (
<form
className="journal-editor"
onSubmit={(event) => {
event.preventDefault()
if (valid) onSubmit()
event.preventDefault();
if (valid) onSubmit();
}}
>
<fieldset className="journal-kind-switch" aria-label="Entry type">
@@ -231,7 +218,7 @@ function EntryEditor({
<Button
key={kind}
type="button"
variant={draft.kind === kind ? 'secondary' : 'ghost'}
variant={draft.kind === kind ? "secondary" : "ghost"}
size="sm"
aria-pressed={draft.kind === kind}
onClick={() => onChange({ ...draft, kind })}
@@ -240,7 +227,7 @@ function EntryEditor({
</Button>
))}
</fieldset>
{draft.kind === '体重' ? (
{draft.kind === "体重" ? (
<div className="flex items-center gap-2">
<Input
type="number"
@@ -249,9 +236,7 @@ function EntryEditor({
aria-label="Weight"
placeholder="Weight"
value={draft.text}
onChange={(event) =>
onChange({ ...draft, text: event.target.value })
}
onChange={(event) => onChange({ ...draft, text: event.target.value })}
/>
<span className="text-sm text-muted-foreground">kg</span>
</div>
@@ -259,24 +244,22 @@ function EntryEditor({
<Textarea
aria-label="Entry text"
placeholder={
draft.kind === '服薬'
? 'Name of the medication taken'
: draft.kind === '再開地点'
? 'What to do next and where to pick up'
: 'A thought or your next step'
draft.kind === "服薬"
? "Name of the medication taken"
: draft.kind === "再開地点"
? "What to do next and where to pick up"
: "A thought or your next step"
}
value={draft.text}
onChange={(event) => onChange({ ...draft, text: event.target.value })}
/>
)}
{draft.kind === '体重' && (
{draft.kind === "体重" && (
<Input
aria-label="Source"
placeholder="Source (e.g. scale, optional)"
value={draft.source}
onChange={(event) =>
onChange({ ...draft, source: event.target.value })
}
onChange={(event) => onChange({ ...draft, source: event.target.value })}
/>
)}
<div className="journal-editor-footer">
@@ -287,9 +270,7 @@ function EntryEditor({
type="datetime-local"
aria-label="When it happened"
value={draft.occurredAt}
onChange={(event) =>
onChange({ ...draft, occurredAt: event.target.value })
}
onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })}
/>
</label>
<Button type="submit" disabled={!valid}>
@@ -297,7 +278,7 @@ function EntryEditor({
</Button>
</div>
</form>
)
);
}
function CorrectionDialog({
@@ -305,11 +286,11 @@ function CorrectionDialog({
onClose,
onSave,
}: {
entry: Entry
onClose: () => void
onSave: (draft: Draft) => void
entry: Entry;
onClose: () => void;
onSave: (draft: Draft) => void;
}) {
const [draft, setDraft] = useState<Draft>(entry)
const [draft, setDraft] = useState<Draft>(entry);
return (
<Dialog title="Correct entry" onClose={onClose} className="sm:max-w-xl">
<p className="text-sm text-muted-foreground">
@@ -322,27 +303,27 @@ function CorrectionDialog({
submitLabel="Add correction"
/>
</Dialog>
)
);
}
function buildSummary(entries: Entry[]) {
if (!entries.length) return 'Select an entry.'
if (!entries.length) return "Select an entry.";
const dates = entries
.map((entry) => entry.occurredAt)
.filter(Boolean)
.sort()
.sort();
const period = dates.length
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
: 'Unknown'
: "Unknown";
return [
`Period covered: ${period}`,
`Entries with an unknown event time: ${entries.filter((entry) => !entry.occurredAt).length} entries`,
'',
"",
...entries.map(
(entry) =>
`[${entryKindLabels[entry.kind]}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\nOccurred: ${formatTime(entry.occurredAt)} / Recorded: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / Source: ${entry.source || 'Unknown'}` : ''}`,
`[${entryKindLabels[entry.kind]}] ${entry.text}${entry.kind === "体重" ? " kg" : ""}\nOccurred: ${formatTime(entry.occurredAt)} / Recorded: ${formatTime(entry.recordedAt)}${entry.kind === "体重" ? ` / Source: ${entry.source || "Unknown"}` : ""}`,
),
'',
'Medication and weight are unknown for periods without entries.',
].join('\n')
"",
"Medication and weight are unknown for periods without entries.",
].join("\n");
}
+27 -32
View File
@@ -1,8 +1,8 @@
import { eq } from 'drizzle-orm'
import { decryptCredential } from '../connections/credentials.server'
import { getDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
import { eq } from "drizzle-orm";
import { decryptCredential } from "../connections/credentials.server";
import { getDatabase } from "../storage/database.server";
import { connectionCredentials, connections } from "../storage/schema";
import { requireMastodonOrigin, safeMastodonRequest } from "./transport.server";
export async function requestMastodon(
connectionId: string,
@@ -10,40 +10,35 @@ export async function requestMastodon(
query?: URLSearchParams,
): Promise<Response> {
if (
!path.startsWith('/api/') ||
path.startsWith('//') ||
path.includes('\\') ||
path.includes('?') ||
path.includes('#')
!path.startsWith("/api/") ||
path.startsWith("//") ||
path.includes("\\") ||
path.includes("?") ||
path.includes("#")
) {
throw new Error('Invalid Mastodon API path.')
throw new Error("Invalid Mastodon API path.");
}
const database = getDatabase()
const database = getDatabase();
const connection = database
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new Error(
'Mastodon connection is unavailable. Reconnect this account.',
)
.get();
if (connection?.platform !== "mastodon" || connection.status !== "connected")
throw new Error("Mastodon connection is unavailable. Reconnect this account.");
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (!credential)
throw new Error(
'Mastodon credentials are unavailable. Reconnect this account.',
)
const url = new URL(path, requireMastodonOrigin(connection.origin))
if (query) url.search = query.toString()
.get();
if (!credential) throw new Error("Mastodon credentials are unavailable. Reconnect this account.");
const url = new URL(path, requireMastodonOrigin(connection.origin));
if (query) url.search = query.toString();
const response = await safeMastodonRequest(url, {
headers: {
authorization: `Bearer ${decryptCredential(credential.encryptedToken, `connection:${connectionId}:access-token`)}`,
},
})
});
if (response.status === 401) {
database.transaction(
(tx) => {
@@ -51,15 +46,15 @@ export async function requestMastodon(
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (current?.encryptedToken !== credential.encryptedToken) return
.get();
if (current?.encryptedToken !== credential.encryptedToken) return;
tx.update(connections)
.set({ status: 'expired', updatedAt: Date.now() })
.set({ status: "expired", updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
.run();
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
return response
return response;
}
+67 -71
View File
@@ -1,100 +1,96 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { requestMastodon } from './client.server'
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections } from "../storage/schema";
import { requestMastodon } from "./client.server";
let database: AppDatabase
const send = vi.hoisted(() => vi.fn())
vi.mock('../storage/database.server', async (original) => ({
...(await original<typeof import('../storage/database.server')>()),
let database: AppDatabase;
const send = vi.hoisted(() => vi.fn<typeof import("./transport.server").safeMastodonRequest>());
vi.mock("../storage/database.server", async (original) => ({
...(await original<typeof import("../storage/database.server")>()),
getDatabase: () => database,
}))
vi.mock('../connections/credentials.server', () => ({
}));
vi.mock("../connections/credentials.server", () => ({
decryptCredential: (value: string) => value,
}))
vi.mock('./transport.server', () => ({
}));
vi.mock("./transport.server", () => ({
requireMastodonOrigin: (origin: string) => origin,
safeMastodonRequest: send,
}))
}));
beforeEach(() => {
send.mockReset()
database = openDatabase(':memory:')
send.mockReset();
database = openDatabase(":memory:");
database
.insert(connections)
.values({
id: 'account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: 'Account',
status: 'connected',
id: "account",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "1",
displayName: "Account",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
.run();
database
.insert(connectionCredentials)
.values({
connectionId: 'account',
encryptedToken: 'old-token',
connectionId: "account",
encryptedToken: "old-token",
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
.run();
});
afterEach(() => database.$client.close());
it('marks the current rejected credential expired', async () => {
send.mockResolvedValue(new Response(null, { status: 401 }))
expect((await requestMastodon('account', '/api/v1/lists')).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('expired')
})
it("marks the current rejected credential expired", async () => {
send.mockResolvedValue(new Response(null, { status: 401 }));
expect((await requestMastodon("account", "/api/v1/lists")).status).toBe(401);
expect(database.select().from(connections).get()?.status).toBe("expired");
});
it('does not expire a reconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
it("does not expire a reconnected account when an old request returns 401 later", async () => {
let resolveResponse!: (response: Response) => void;
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
expect(send).toHaveBeenCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer old-token' } },
)
resolveResponse = resolve;
});
send.mockReturnValueOnce(response);
const request = requestMastodon("account", "/api/v1/lists");
expect(send).toHaveBeenCalledWith(new URL("https://mastodon.invalid/api/v1/lists"), {
headers: { authorization: "Bearer old-token" },
});
database
.update(connectionCredentials)
.set({
encryptedToken: 'new-token',
encryptedToken: "new-token",
updatedAt: 2,
})
.where(eq(connectionCredentials.connectionId, 'account'))
.run()
resolveResponse(new Response(null, { status: 401 }))
expect((await request).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(
database.select().from(connectionCredentials).get()?.encryptedToken,
).toBe('new-token')
send.mockResolvedValueOnce(Response.json([]))
expect((await requestMastodon('account', '/api/v1/lists')).ok).toBe(true)
expect(send).toHaveBeenLastCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer new-token' } },
)
})
.where(eq(connectionCredentials.connectionId, "account"))
.run();
resolveResponse(new Response(null, { status: 401 }));
expect((await request).status).toBe(401);
expect(database.select().from(connections).get()?.status).toBe("connected");
expect(database.select().from(connectionCredentials).get()?.encryptedToken).toBe("new-token");
send.mockResolvedValueOnce(Response.json([]));
expect((await requestMastodon("account", "/api/v1/lists")).ok).toBe(true);
expect(send).toHaveBeenLastCalledWith(new URL("https://mastodon.invalid/api/v1/lists"), {
headers: { authorization: "Bearer new-token" },
});
});
it('does not change a disconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
it("does not change a disconnected account when an old request returns 401 later", async () => {
let resolveResponse!: (response: Response) => void;
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
database.delete(connectionCredentials).run()
database.update(connections).set({ status: 'disconnected' }).run()
resolveResponse(new Response(null, { status: 401 }))
await request
expect(database.select().from(connections).get()?.status).toBe('disconnected')
})
resolveResponse = resolve;
});
send.mockReturnValueOnce(response);
const request = requestMastodon("account", "/api/v1/lists");
database.delete(connectionCredentials).run();
database.update(connections).set({ status: "disconnected" }).run();
resolveResponse(new Response(null, { status: 401 }));
await request;
expect(database.select().from(connections).get()?.status).toBe("disconnected");
});
+113 -153
View File
@@ -1,47 +1,39 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { and, eq, gt, isNull, lt } from 'drizzle-orm'
import { z } from 'zod'
import { readAccessConfig } from '../access/policy.server'
import {
decryptCredential,
encryptCredential,
} from '../connections/credentials.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, eq, gt, isNull, lt } from "drizzle-orm";
import { z } from "zod";
import { readAccessConfig } from "../access/policy.server";
import { decryptCredential, encryptCredential } from "../connections/credentials.server";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connectionCredentials, connections, oauthApps, oauthAttempts } from "../storage/schema";
import { requireMastodonOrigin, safeMastodonRequest } from "./transport.server";
export const oauthLifetimeSeconds = 600
const scopes = 'read:accounts read:statuses read:lists read:search'
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const random = () => randomBytes(32).toString('base64url')
export const oauthLifetimeSeconds = 600;
const scopes = "read:accounts read:statuses read:lists read:search";
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const random = () => randomBytes(32).toString("base64url");
const appSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
})
});
const tokenSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().regex(/^bearer$/i),
scope: z.string(),
})
});
const accountSchema = z.object({
id: z.string().min(1),
acct: z.string().min(1),
})
type Send = typeof safeMastodonRequest
});
type Send = typeof safeMastodonRequest;
export function oauthCookieName(state: string) {
return `mastodon_oauth_${hash(state).slice(0, 24)}`
return `mastodon_oauth_${hash(state).slice(0, 24)}`;
}
function callbackUri() {
const config = readAccessConfig()
if (!config) throw new Error('Access configuration is required.')
return `${config.origin}/oauth/mastodon/callback`
const config = readAccessConfig();
if (!config) throw new Error("Access configuration is required.");
return `${config.origin}/oauth/mastodon/callback`;
}
async function formRequest(
@@ -51,17 +43,16 @@ async function formRequest(
fields: Record<string, string>,
) {
const response = await send(new URL(path, origin), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields),
})
if (!response.ok)
throw new Error(`Mastodon request failed with HTTP ${response.status}.`)
return response
});
if (!response.ok) throw new Error(`Mastodon request failed with HTTP ${response.status}.`);
return response;
}
async function getApp(origin: string, database: AppDatabase, send: Send) {
const redirectUri = callbackUri()
const redirectUri = callbackUri();
const existing = database
.select()
.from(oauthApps)
@@ -72,28 +63,25 @@ async function getApp(origin: string, database: AppDatabase, send: Send) {
eq(oauthApps.scopes, scopes),
),
)
.get()
if (existing) return existing
const metadataResponse = await send(
new URL('/.well-known/oauth-authorization-server', origin),
)
if (!metadataResponse.ok)
throw new Error('Mastodon OAuth metadata is unavailable.')
.get();
if (existing) return existing;
const metadataResponse = await send(new URL("/.well-known/oauth-authorization-server", origin));
if (!metadataResponse.ok) throw new Error("Mastodon OAuth metadata is unavailable.");
const metadata = z
.object({ code_challenge_methods_supported: z.array(z.string()) })
.parse(await metadataResponse.json())
if (!metadata.code_challenge_methods_supported.includes('S256'))
throw new Error('This instance does not support PKCE S256.')
.parse(await metadataResponse.json());
if (!metadata.code_challenge_methods_supported.includes("S256"))
throw new Error("This instance does not support PKCE S256.");
const registered = appSchema.parse(
await (
await formRequest(send, origin, '/api/v1/apps', {
client_name: 'Personal Workspace',
await formRequest(send, origin, "/api/v1/apps", {
client_name: "Personal Workspace",
redirect_uris: redirectUri,
scopes,
})
).json(),
)
const id = randomUUID()
);
const id = randomUUID();
const app = {
id,
origin,
@@ -105,8 +93,8 @@ async function getApp(origin: string, database: AppDatabase, send: Send) {
`oauth-app:${id}:client-secret`,
),
createdAt: Date.now(),
}
database.insert(oauthApps).values(app).onConflictDoNothing().run()
};
database.insert(oauthApps).values(app).onConflictDoNothing().run();
return (
database
.select()
@@ -119,7 +107,7 @@ async function getApp(origin: string, database: AppDatabase, send: Send) {
),
)
.get() ?? app
)
);
}
export async function beginMastodonOAuth(
@@ -127,67 +115,61 @@ export async function beginMastodonOAuth(
database = getDatabase(),
send: Send = safeMastodonRequest,
) {
database
.delete(oauthAttempts)
.where(lt(oauthAttempts.expiresAt, Date.now()))
.run()
const origin = requireMastodonOrigin(input.origin)
database.delete(oauthAttempts).where(lt(oauthAttempts.expiresAt, Date.now())).run();
const origin = requireMastodonOrigin(input.origin);
if (input.connectionId) {
const connection = database
.select()
.from(connections)
.where(eq(connections.id, input.connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.origin !== origin)
throw new Error('The reconnect account does not match this instance.')
.get();
if (connection?.platform !== "mastodon" || connection.origin !== origin)
throw new Error("The reconnect account does not match this instance.");
}
const app = await getApp(origin, database, send)
const state = random()
const browserCookie = random()
const verifier = random()
const stateHash = hash(state)
const app = await getApp(origin, database, send);
const state = random();
const browserCookie = random();
const verifier = random();
const stateHash = hash(state);
database
.insert(oauthAttempts)
.values({
stateHash,
browserHash: hash(browserCookie),
appId: app.id,
encryptedVerifier: encryptCredential(
verifier,
`oauth-attempt:${stateHash}:verifier`,
),
encryptedVerifier: encryptCredential(verifier, `oauth-attempt:${stateHash}:verifier`),
connectionId: input.connectionId ?? null,
expiresAt: Date.now() + oauthLifetimeSeconds * 1000,
})
.run()
const authorize = new URL('/oauth/authorize', origin)
.run();
const authorize = new URL("/oauth/authorize", origin);
authorize.search = new URLSearchParams({
response_type: 'code',
response_type: "code",
client_id: app.clientId,
redirect_uri: app.redirectUri,
scope: app.scopes,
state,
force_login: 'true',
code_challenge_method: 'S256',
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
}).toString()
return { authorizationUrl: authorize.toString(), state, browserCookie }
force_login: "true",
code_challenge_method: "S256",
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
}).toString();
return { authorizationUrl: authorize.toString(), state, browserCookie };
}
export async function completeMastodonOAuth(
input: {
state: string
browserCookie?: string
code?: string
error?: string
state: string;
browserCookie?: string;
code?: string;
error?: string;
},
database = getDatabase(),
send: Send = safeMastodonRequest,
): Promise<string> {
if (!input.state || !input.browserCookie)
throw new Error('OAuth state or browser binding is missing.')
const stateHash = hash(input.state)
const now = Date.now()
throw new Error("OAuth state or browser binding is missing.");
const stateHash = hash(input.state);
const now = Date.now();
const attempt = database
.update(oauthAttempts)
.set({ consumedAt: now })
@@ -200,22 +182,16 @@ export async function completeMastodonOAuth(
),
)
.returning()
.get()
if (!attempt)
throw new Error('OAuth attempt is invalid, expired, or already used.')
if (input.error || !input.code)
throw new Error('Mastodon authorization was not completed.')
const app = database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, attempt.appId))
.get()
if (!app) throw new Error('OAuth app is unavailable.')
requireMastodonOrigin(app.origin)
.get();
if (!attempt) throw new Error("OAuth attempt is invalid, expired, or already used.");
if (input.error || !input.code) throw new Error("Mastodon authorization was not completed.");
const app = database.select().from(oauthApps).where(eq(oauthApps.id, attempt.appId)).get();
if (!app) throw new Error("OAuth app is unavailable.");
requireMastodonOrigin(app.origin);
const token = tokenSchema.parse(
await (
await formRequest(send, app.origin, '/oauth/token', {
grant_type: 'authorization_code',
await formRequest(send, app.origin, "/oauth/token", {
grant_type: "authorization_code",
code: input.code,
client_id: app.clientId,
client_secret: decryptCredential(
@@ -229,43 +205,38 @@ export async function completeMastodonOAuth(
),
})
).json(),
)
const granted = new Set(token.scope.split(' '))
if (!scopes.split(' ').every((scope) => granted.has(scope)))
throw new Error('Required Mastodon read permissions were not granted.')
const verified = await send(
new URL('/api/v1/accounts/verify_credentials', app.origin),
{
headers: { authorization: `Bearer ${token.access_token}` },
},
)
if (!verified.ok) throw new Error('Mastodon account verification failed.')
const account = accountSchema.parse(await verified.json())
);
const granted = new Set(token.scope.split(" "));
if (!scopes.split(" ").every((scope) => granted.has(scope)))
throw new Error("Required Mastodon read permissions were not granted.");
const verified = await send(new URL("/api/v1/accounts/verify_credentials", app.origin), {
headers: { authorization: `Bearer ${token.access_token}` },
});
if (!verified.ok) throw new Error("Mastodon account verification failed.");
const account = accountSchema.parse(await verified.json());
return database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'mastodon'),
eq(connections.platform, "mastodon"),
eq(connections.origin, app.origin),
eq(connections.accountId, account.id),
),
)
.get()
.get();
if (attempt.connectionId && existing?.id !== attempt.connectionId)
throw new Error(
'The authorized account does not match the account being reconnected.',
)
const id = existing?.id ?? randomUUID()
throw new Error("The authorized account does not match the account being reconnected.");
const id = existing?.id ?? randomUUID();
tx.insert(connections)
.values({
id,
platform: 'mastodon',
platform: "mastodon",
origin: app.origin,
accountId: account.id,
displayName: `@${account.acct}`,
status: 'connected',
status: "connected",
createdAt: now,
updatedAt: now,
})
@@ -273,29 +244,26 @@ export async function completeMastodonOAuth(
target: connections.id,
set: {
displayName: `@${account.acct}`,
status: 'connected',
status: "connected",
updatedAt: now,
},
})
.run()
.run();
const credential = {
connectionId: id,
appId: app.id,
encryptedToken: encryptCredential(
token.access_token,
`connection:${id}:access-token`,
),
encryptedToken: encryptCredential(token.access_token, `connection:${id}:access-token`),
updatedAt: now,
}
};
tx.insert(connectionCredentials)
.values(credential)
.onConflictDoUpdate({
target: connectionCredentials.connectionId,
set: credential,
})
.run()
return id
})
.run();
return id;
});
}
export async function disconnectMastodon(
@@ -307,28 +275,20 @@ export async function disconnectMastodon(
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon')
throw new Error('Mastodon connection was not found.')
.get();
if (connection?.platform !== "mastodon") throw new Error("Mastodon connection was not found.");
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
.get();
if (credential) {
const app = credential.appId
? database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, credential.appId))
.get()
: undefined
if (!app)
throw new Error(
'The OAuth app required to revoke this connection is missing.',
)
requireMastodonOrigin(connection.origin)
await formRequest(send, connection.origin, '/oauth/revoke', {
? database.select().from(oauthApps).where(eq(oauthApps.id, credential.appId)).get()
: undefined;
if (!app) throw new Error("The OAuth app required to revoke this connection is missing.");
requireMastodonOrigin(connection.origin);
await formRequest(send, connection.origin, "/oauth/revoke", {
client_id: app.clientId,
client_secret: decryptCredential(
app.encryptedClientSecret,
@@ -338,7 +298,7 @@ export async function disconnectMastodon(
credential.encryptedToken,
`connection:${connectionId}:access-token`,
),
})
});
}
database.transaction(
(tx) => {
@@ -346,19 +306,19 @@ export async function disconnectMastodon(
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
.get();
if (current?.encryptedToken !== credential?.encryptedToken)
throw new Error(
'This account was reconnected during revocation. Its new connection has been kept. Refresh the account list before trying again.',
)
"This account was reconnected during revocation. Its new connection has been kept. Refresh the account list before trying again.",
);
tx.delete(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.run()
.run();
tx.update(connections)
.set({ status: 'disconnected', updatedAt: Date.now() })
.set({ status: "disconnected", updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
.run();
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
+220 -311
View File
@@ -1,357 +1,266 @@
// @vitest-environment node
import { createHash, randomBytes } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential } from '../connections/credentials.server'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import {
beginMastodonOAuth,
completeMastodonOAuth,
disconnectMastodon,
} from './oauth.server'
import { createHash, randomBytes } from "node:crypto";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { decryptCredential } from "../connections/credentials.server";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections, oauthApps, oauthAttempts } from "../storage/schema";
import { beginMastodonOAuth, completeMastodonOAuth, disconnectMastodon } from "./oauth.server";
let database: AppDatabase
let directory: string
let accountId: string
const origin = 'https://mastodon.invalid'
const token = 'private-access-token'
const clientSecret = 'private-client-secret'
const send = vi.fn(async (url: URL, _init?: RequestInit): Promise<Response> => {
if (url.pathname === '/.well-known/oauth-authorization-server')
return Response.json({ code_challenge_methods_supported: ['S256'] })
if (url.pathname === '/api/v1/apps')
return Response.json({ client_id: 'client', client_secret: clientSecret })
if (url.pathname === '/oauth/token')
return Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
if (url.pathname === '/api/v1/accounts/verify_credentials')
return Response.json({ id: accountId, acct: `user${accountId}` })
if (url.pathname === '/oauth/revoke') return Response.json({})
throw new Error('Unexpected mock endpoint')
})
let database: AppDatabase;
let directory: string;
let accountId: string;
const origin = "https://mastodon.invalid";
const token = "private-access-token";
const clientSecret = "private-client-secret";
const send = vi.fn<(url: URL, init?: RequestInit) => Promise<Response>>(
async (url: URL, _init?: RequestInit): Promise<Response> => {
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({ code_challenge_methods_supported: ["S256"] });
if (url.pathname === "/api/v1/apps")
return Response.json({ client_id: "client", client_secret: clientSecret });
if (url.pathname === "/oauth/token")
return Response.json({
access_token: token,
token_type: "Bearer",
scope: "read:accounts read:statuses read:lists read:search",
});
if (url.pathname === "/api/v1/accounts/verify_credentials")
return Response.json({ id: accountId, acct: `user${accountId}` });
if (url.pathname === "/oauth/revoke") return Response.json({});
throw new Error("Unexpected mock endpoint");
},
);
beforeEach(() => {
database = openDatabase(':memory:')
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-oauth-'))
const key = join(directory, 'key')
writeFileSync(key, randomBytes(32).toString('base64'), { mode: 0o600 })
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', key)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://deck.invalid')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '[email protected]')
vi.stubEnv(
'TWITTER_LITE_MASTODON_ORIGINS',
`${origin},https://second.invalid`,
)
accountId = '1'
send.mockClear()
})
database = openDatabase(":memory:");
directory = mkdtempSync(join(tmpdir(), "twitter-lite-oauth-"));
const key = join(directory, "key");
writeFileSync(key, randomBytes(32).toString("base64"), { mode: 0o600 });
vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", key);
vi.stubEnv("TWITTER_LITE_ORIGIN", "https://deck.invalid");
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "[email protected]");
vi.stubEnv("TWITTER_LITE_MASTODON_ORIGINS", `${origin},https://second.invalid`);
accountId = "1";
send.mockClear();
});
afterEach(() => {
database.$client.close()
rmSync(directory, { force: true, recursive: true })
vi.unstubAllEnvs()
})
database.$client.close();
rmSync(directory, { force: true, recursive: true });
vi.unstubAllEnvs();
});
it('registers once and uses force-login, state and PKCE while storing only encrypted secrets', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const url = new URL(attempt.authorizationUrl)
expect(url.origin).toBe(origin)
expect(url.searchParams.get('redirect_uri')).toBe(
'https://deck.invalid/oauth/mastodon/callback',
)
expect(url.searchParams.get('force_login')).toBe('true')
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
expect(url.searchParams.get('state')).toBe(attempt.state)
it("registers once and uses force-login, state and PKCE while storing only encrypted secrets", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const url = new URL(attempt.authorizationUrl);
expect(url.origin).toBe(origin);
expect(url.searchParams.get("redirect_uri")).toBe("https://deck.invalid/oauth/mastodon/callback");
expect(url.searchParams.get("force_login")).toBe("true");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("state")).toBe(attempt.state);
const id = await completeMastodonOAuth(
{ ...attempt, code: 'authorization-code' },
{ ...attempt, code: "authorization-code" },
database,
send,
)
const credentials = database.select().from(connectionCredentials).get()
expect(credentials?.connectionId).toBe(id)
);
const credentials = database.select().from(connectionCredentials).get();
expect(credentials?.connectionId).toBe(id);
expect(
decryptCredential(
credentials?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe(token)
decryptCredential(credentials?.encryptedToken ?? "", `connection:${id}:access-token`),
).toBe(token);
const saved = JSON.stringify({
apps: database.select().from(oauthApps).all(),
attempts: database.select().from(oauthAttempts).all(),
credentials,
})
expect(saved).not.toContain(token)
expect(saved).not.toContain(clientSecret)
expect(saved).not.toContain(attempt.state)
expect(saved).not.toContain(attempt.browserCookie)
const exchange = send.mock.calls.find(
([request]) => request.pathname === '/oauth/token',
)
const fields = exchange?.[1]?.body as URLSearchParams
});
expect(saved).not.toContain(token);
expect(saved).not.toContain(clientSecret);
expect(saved).not.toContain(attempt.state);
expect(saved).not.toContain(attempt.browserCookie);
const exchange = send.mock.calls.find(([request]) => request.pathname === "/oauth/token");
const fields = exchange?.[1]?.body as URLSearchParams;
expect(
createHash('sha256')
.update(fields.get('code_verifier') ?? '')
.digest('base64url'),
).toBe(url.searchParams.get('code_challenge'))
await beginMastodonOAuth({ origin }, database, send)
expect(
send.mock.calls.filter(([request]) => request.pathname === '/api/v1/apps'),
).toHaveLength(1)
})
createHash("sha256")
.update(fields.get("code_verifier") ?? "")
.digest("base64url"),
).toBe(url.searchParams.get("code_challenge"));
await beginMastodonOAuth({ origin }, database, send);
expect(send.mock.calls.filter(([request]) => request.pathname === "/api/v1/apps")).toHaveLength(
1,
);
});
it('supports two accounts per instance and accounts on another instance without changing reconnect IDs', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const firstId = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
accountId = '2'
const second = await beginMastodonOAuth({ origin }, database, send)
const secondId = await completeMastodonOAuth(
{ ...second, code: 'second' },
database,
send,
)
expect(secondId).not.toBe(firstId)
const another = await beginMastodonOAuth(
{ origin: 'https://second.invalid' },
database,
send,
)
const anotherId = await completeMastodonOAuth(
{ ...another, code: 'another' },
database,
send,
)
expect(anotherId).not.toBe(secondId)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: secondId },
database,
send,
)
expect(
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
send,
),
).toBe(secondId)
expect(database.select().from(connections).all()).toHaveLength(3)
})
it("supports two accounts per instance and accounts on another instance without changing reconnect IDs", async () => {
const first = await beginMastodonOAuth({ origin }, database, send);
const firstId = await completeMastodonOAuth({ ...first, code: "first" }, database, send);
accountId = "2";
const second = await beginMastodonOAuth({ origin }, database, send);
const secondId = await completeMastodonOAuth({ ...second, code: "second" }, database, send);
expect(secondId).not.toBe(firstId);
const another = await beginMastodonOAuth({ origin: "https://second.invalid" }, database, send);
const anotherId = await completeMastodonOAuth({ ...another, code: "another" }, database, send);
expect(anotherId).not.toBe(secondId);
const reconnect = await beginMastodonOAuth({ origin, connectionId: secondId }, database, send);
expect(await completeMastodonOAuth({ ...reconnect, code: "reconnect" }, database, send)).toBe(
secondId,
);
expect(database.select().from(connections).all()).toHaveLength(3);
});
it('rejects mismatched reconnect accounts without replacing the existing credential', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
const before = database.select().from(connectionCredentials).get()
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
accountId = 'different'
it("rejects mismatched reconnect accounts without replacing the existing credential", async () => {
const first = await beginMastodonOAuth({ origin }, database, send);
const id = await completeMastodonOAuth({ ...first, code: "first" }, database, send);
const before = database.select().from(connectionCredentials).get();
const reconnect = await beginMastodonOAuth({ origin, connectionId: id }, database, send);
accountId = "different";
await expect(
completeMastodonOAuth({ ...reconnect, code: 'other' }, database, send),
).rejects.toThrow('does not match')
expect(database.select().from(connectionCredentials).get()).toEqual(before)
})
completeMastodonOAuth({ ...reconnect, code: "other" }, database, send),
).rejects.toThrow("does not match");
expect(database.select().from(connectionCredentials).get()).toEqual(before);
});
it('rejects browser mismatch and unknown state before exchanging a code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const calls = send.mock.calls.length
it("rejects browser mismatch and unknown state before exchanging a code", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const calls = send.mock.calls.length;
await expect(
completeMastodonOAuth(
{ ...attempt, browserCookie: 'another-browser', code: 'code' },
{ ...attempt, browserCookie: "another-browser", code: "code" },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
).rejects.toThrow("invalid, expired, or already used");
await expect(
completeMastodonOAuth(
{ ...attempt, state: 'unknown-state', code: 'code' },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
expect(send).toHaveBeenCalledTimes(calls)
})
completeMastodonOAuth({ ...attempt, state: "unknown-state", code: "code" }, database, send),
).rejects.toThrow("invalid, expired, or already used");
expect(send).toHaveBeenCalledTimes(calls);
});
it('consumes rejected authorization attempts so they cannot be retried', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
it("consumes rejected authorization attempts so they cannot be retried", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
await expect(
completeMastodonOAuth(
{ ...attempt, error: 'access_denied' },
database,
send,
),
).rejects.toThrow('not completed')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
expect(database.select().from(connections).all()).toHaveLength(0)
})
completeMastodonOAuth({ ...attempt, error: "access_denied" }, database, send),
).rejects.toThrow("not completed");
await expect(completeMastodonOAuth({ ...attempt, code: "code" }, database, send)).rejects.toThrow(
"already used",
);
expect(database.select().from(connections).all()).toHaveLength(0);
});
it('rejects expired attempts without exchanging the code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
it("rejects expired attempts without exchanging the code", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
database
.update(oauthAttempts)
.set({ expiresAt: Date.now() - 1 })
.run()
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('expired')
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(0)
})
.run();
await expect(completeMastodonOAuth({ ...attempt, code: "code" }, database, send)).rejects.toThrow(
"expired",
);
expect(send.mock.calls.filter(([request]) => request.pathname === "/oauth/token")).toHaveLength(
0,
);
});
it('allows exactly one exchange when callbacks race', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
it("allows exactly one exchange when callbacks race", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const results = await Promise.allSettled([
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
])
expect(results.map((result) => result.status).sort()).toEqual([
'fulfilled',
'rejected',
])
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(1)
})
completeMastodonOAuth({ ...attempt, code: "code" }, database, send),
completeMastodonOAuth({ ...attempt, code: "code" }, database, send),
]);
expect(results.map((result) => result.status).sort()).toEqual(["fulfilled", "rejected"]);
expect(send.mock.calls.filter(([request]) => request.pathname === "/oauth/token")).toHaveLength(
1,
);
});
it('rejects instances outside the configured allowlist without network requests', async () => {
it("rejects instances outside the configured allowlist without network requests", async () => {
await expect(
beginMastodonOAuth(
{ origin: 'https://unconfigured.invalid' },
database,
send,
),
).rejects.toThrow('not configured')
expect(send).not.toHaveBeenCalled()
})
beginMastodonOAuth({ origin: "https://unconfigured.invalid" }, database, send),
).rejects.toThrow("not configured");
expect(send).not.toHaveBeenCalled();
});
it('revokes with the original app, erases credentials, and retains the column binding ID', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://new-deck.invalid')
await disconnectMastodon(id, database, send)
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
expect(
database.select().from(connections).where(eq(connections.id, id)).get()
?.status,
).toBe('disconnected')
const revoke = send.mock.calls.find(
([request]) => request.pathname === '/oauth/revoke',
)
const fields = revoke?.[1]?.body as URLSearchParams
expect(fields.get('client_secret')).toBe(clientSecret)
expect(fields.get('token')).toBe(token)
})
it("revokes with the original app, erases credentials, and retains the column binding ID", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const id = await completeMastodonOAuth({ ...attempt, code: "code" }, database, send);
vi.stubEnv("TWITTER_LITE_ORIGIN", "https://new-deck.invalid");
await disconnectMastodon(id, database, send);
expect(database.select().from(connectionCredentials).all()).toHaveLength(0);
expect(database.select().from(connections).where(eq(connections.id, id)).get()?.status).toBe(
"disconnected",
);
const revoke = send.mock.calls.find(([request]) => request.pathname === "/oauth/revoke");
const fields = revoke?.[1]?.body as URLSearchParams;
expect(fields.get("client_secret")).toBe(clientSecret);
expect(fields.get("token")).toBe(token);
});
it('keeps credentials available for another revoke attempt when revocation fails', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
const failedRevoke = vi.fn(async () => new Response(null, { status: 503 }))
await expect(disconnectMastodon(id, database, failedRevoke)).rejects.toThrow(
'HTTP 503',
)
expect(database.select().from(connectionCredentials).all()).toHaveLength(1)
expect(database.select().from(connections).get()?.status).toBe('connected')
})
it("keeps credentials available for another revoke attempt when revocation fails", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const id = await completeMastodonOAuth({ ...attempt, code: "code" }, database, send);
const failedRevoke = vi.fn<(url: URL, init?: RequestInit) => Promise<Response>>(
async () => new Response(null, { status: 503 }),
);
await expect(disconnectMastodon(id, database, failedRevoke)).rejects.toThrow("HTTP 503");
expect(database.select().from(connectionCredentials).all()).toHaveLength(1);
expect(database.select().from(connections).get()?.status).toBe("connected");
});
it('keeps a new credential when an older disconnect completes after reconnection', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
let resolveResponse!: (response: Response) => void
it("keeps a new credential when an older disconnect completes after reconnection", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const id = await completeMastodonOAuth({ ...attempt, code: "code" }, database, send);
let resolveResponse!: (response: Response) => void;
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
const pendingRevoke = vi.fn(() => response)
const disconnect = disconnectMastodon(id, database, pendingRevoke)
expect(pendingRevoke).toHaveBeenCalledTimes(1)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
async (url, init) =>
url.pathname === '/oauth/token'
? Response.json({
access_token: 'new-access-token',
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
: send(url, init),
)
resolveResponse(Response.json({}))
await expect(disconnect).rejects.toThrow('reconnected during revocation')
const credential = database.select().from(connectionCredentials).get()
expect(
decryptCredential(
credential?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe('new-access-token')
expect(database.select().from(connections).get()?.status).toBe('connected')
})
resolveResponse = resolve;
});
const pendingRevoke = vi.fn<(url: URL, init?: RequestInit) => Promise<Response>>(() => response);
const disconnect = disconnectMastodon(id, database, pendingRevoke);
expect(pendingRevoke).toHaveBeenCalledTimes(1);
const reconnect = await beginMastodonOAuth({ origin, connectionId: id }, database, send);
await completeMastodonOAuth({ ...reconnect, code: "reconnect" }, database, async (url, init) =>
url.pathname === "/oauth/token"
? Response.json({
access_token: "new-access-token",
token_type: "Bearer",
scope: "read:accounts read:statuses read:lists read:search",
})
: send(url, init),
);
resolveResponse(Response.json({}));
await expect(disconnect).rejects.toThrow("reconnected during revocation");
const credential = database.select().from(connectionCredentials).get();
expect(decryptCredential(credential?.encryptedToken ?? "", `connection:${id}:access-token`)).toBe(
"new-access-token",
);
expect(database.select().from(connections).get()?.status).toBe("connected");
});
it('rejects incomplete scopes without saving the token', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const limitedGrant = vi.fn(async () =>
it("rejects incomplete scopes without saving the token", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const limitedGrant = vi.fn<(url: URL, init?: RequestInit) => Promise<Response>>(async () =>
Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts',
token_type: "Bearer",
scope: "read:accounts",
}),
)
);
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, limitedGrant),
).rejects.toThrow('permissions were not granted')
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
})
completeMastodonOAuth({ ...attempt, code: "code" }, database, limitedGrant),
).rejects.toThrow("permissions were not granted");
expect(database.select().from(connectionCredentials).all()).toHaveLength(0);
});
it('consumes an attempt before a failing token exchange', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const failedExchange = vi.fn(async () => new Response(null, { status: 400 }))
it("consumes an attempt before a failing token exchange", async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send);
const failedExchange = vi.fn<(url: URL, init?: RequestInit) => Promise<Response>>(
async () => new Response(null, { status: 400 }),
);
await expect(
completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
failedExchange,
),
).rejects.toThrow('HTTP 400')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
})
completeMastodonOAuth({ ...attempt, code: "code" }, database, failedExchange),
).rejects.toThrow("HTTP 400");
await expect(completeMastodonOAuth({ ...attempt, code: "code" }, database, send)).rejects.toThrow(
"already used",
);
});
+22 -24
View File
@@ -1,14 +1,12 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
export const loadMastodonInstances = createServerFn({ method: 'GET' }).handler(
async () => {
const { mastodonOrigins } = await import('./transport.server')
return { origins: mastodonOrigins() }
},
)
export const loadMastodonInstances = createServerFn({ method: "GET" }).handler(async () => {
const { mastodonOrigins } = await import("./transport.server");
return { origins: mastodonOrigins() };
});
export const startMastodonOAuth = createServerFn({ method: 'POST' })
export const startMastodonOAuth = createServerFn({ method: "POST" })
.validator(
z
.object({
@@ -18,25 +16,25 @@ export const startMastodonOAuth = createServerFn({ method: 'POST' })
.strict(),
)
.handler(async ({ data }) => {
const { setCookie } = await import('@tanstack/react-start/server')
const { setCookie } = await import("@tanstack/react-start/server");
const { beginMastodonOAuth, oauthCookieName, oauthLifetimeSeconds } =
await import('./oauth.server')
const { readAccessConfig } = await import('../access/policy.server')
const attempt = await beginMastodonOAuth(data)
await import("./oauth.server");
const { readAccessConfig } = await import("../access/policy.server");
const attempt = await beginMastodonOAuth(data);
setCookie(oauthCookieName(attempt.state), attempt.browserCookie, {
httpOnly: true,
secure: readAccessConfig()?.origin.startsWith('https:') === true,
sameSite: 'lax',
path: '/oauth/mastodon/callback',
secure: readAccessConfig()?.origin.startsWith("https:") === true,
sameSite: "lax",
path: "/oauth/mastodon/callback",
maxAge: oauthLifetimeSeconds,
})
return { authorizationUrl: attempt.authorizationUrl }
})
});
return { authorizationUrl: attempt.authorizationUrl };
});
export const disconnectMastodonAccount = createServerFn({ method: 'POST' })
export const disconnectMastodonAccount = createServerFn({ method: "POST" })
.validator(z.object({ connectionId: z.string().min(1) }).strict())
.handler(async ({ data }) => {
const { disconnectMastodon } = await import('./oauth.server')
await disconnectMastodon(data.connectionId)
return { disconnected: true }
})
const { disconnectMastodon } = await import("./oauth.server");
await disconnectMastodon(data.connectionId);
return { disconnected: true };
});
+53 -68
View File
@@ -1,37 +1,32 @@
import { lookup } from 'node:dns/promises'
import { request } from 'node:https'
import { isIP } from 'node:net'
import { lookup } from "node:dns/promises";
import { request } from "node:https";
import { isIP } from "node:net";
export function mastodonOrigins(): string[] {
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? '')
.split(',')
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
url.origin !== value ||
url.port ||
isIP(url.hostname)
) {
const url = new URL(value);
if (url.protocol !== "https:" || url.origin !== value || url.port || isIP(url.hostname)) {
throw new Error(
'Mastodon origins must be HTTPS host origins without paths or custom ports.',
)
"Mastodon origins must be HTTPS host origins without paths or custom ports.",
);
}
return url.origin
})
return url.origin;
});
}
export function requireMastodonOrigin(origin: string): string {
if (!mastodonOrigins().includes(origin))
throw new Error('This Mastodon instance is not configured.')
return origin
throw new Error("This Mastodon instance is not configured.");
return origin;
}
export function isPublicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
const [a = 0, b = 0, c = 0] = address.split(".").map(Number);
return !(
a === 0 ||
a === 10 ||
@@ -45,84 +40,74 @@ export function isPublicAddress(address: string): boolean {
(a === 192 && b === 88 && c === 99) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113)
)
);
}
if (isIP(address) === 6) {
const normalized = address.toLowerCase()
const normalized = address.toLowerCase();
return (
/^[23]/.test(normalized) &&
!/^2001:(0*:|db8:)/.test(normalized) &&
!normalized.startsWith('2002:')
)
!normalized.startsWith("2002:")
);
}
return false
return false;
}
/** Fixed approved HTTPS origins, pinned public DNS results, no redirects. */
export async function safeMastodonRequest(
url: URL,
init: RequestInit = {},
): Promise<Response> {
requireMastodonOrigin(url.origin)
if (url.username || url.password) throw new Error('Invalid Mastodon URL.')
const addresses = await lookup(url.hostname, { all: true, verbatim: true })
const address = addresses[0]
export async function safeMastodonRequest(url: URL, init: RequestInit = {}): Promise<Response> {
requireMastodonOrigin(url.origin);
if (url.username || url.password) throw new Error("Invalid Mastodon URL.");
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
const address = addresses[0];
if (!address || addresses.some((value) => !isPublicAddress(value.address))) {
throw new Error('Mastodon must resolve to public network addresses.')
throw new Error("Mastodon must resolve to public network addresses.");
}
const headers = Object.fromEntries(new Headers(init.headers))
const body =
init.body instanceof URLSearchParams ? init.body.toString() : init.body
if (body !== undefined && body !== null && typeof body !== 'string')
throw new Error('Unsupported Mastodon request body.')
const headers = Object.fromEntries(new Headers(init.headers));
const body = init.body instanceof URLSearchParams ? init.body.toString() : init.body;
if (body !== undefined && body !== null && typeof body !== "string")
throw new Error("Unsupported Mastodon request body.");
return new Promise((resolve, reject) => {
const outgoing = request(
url,
{
method: init.method ?? 'GET',
method: init.method ?? "GET",
headers,
family: address.family,
lookup: (_hostname, _options, callback) =>
callback(null, address.address, address.family),
lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
},
(incoming) => {
const chunks: Buffer[] = []
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length
const chunks: Buffer[] = [];
let bytes = 0;
incoming.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > 5 * 1024 * 1024)
incoming.destroy(new Error('Mastodon response is too large.'))
else chunks.push(chunk)
})
incoming.on('error', () =>
reject(new Error('Mastodon response failed.')),
)
incoming.on('end', () => {
const status = incoming.statusCode ?? 502
incoming.destroy(new Error("Mastodon response is too large."));
else chunks.push(chunk);
});
incoming.on("error", () => reject(new Error("Mastodon response failed.")));
incoming.on("end", () => {
const status = incoming.statusCode ?? 502;
if (status >= 300 && status < 400)
return reject(new Error('Mastodon redirects are not allowed.'))
const responseHeaders = new Headers()
return reject(new Error("Mastodon redirects are not allowed."));
const responseHeaders = new Headers();
for (const [name, value] of Object.entries(incoming.headers)) {
if (value !== undefined)
responseHeaders.set(
name,
Array.isArray(value) ? value.join(', ') : value,
)
responseHeaders.set(name, Array.isArray(value) ? value.join(", ") : value);
}
resolve(
new Response(status === 204 ? null : Buffer.concat(chunks), {
status,
headers: responseHeaders,
}),
)
})
);
});
},
)
);
const timeout = setTimeout(() => {
outgoing.destroy(new Error('Mastodon request timed out.'))
}, 20_000)
outgoing.on('close', () => clearTimeout(timeout))
outgoing.on('error', () => reject(new Error('Mastodon request failed.')))
outgoing.end(body)
})
outgoing.destroy(new Error("Mastodon request timed out."));
}, 20_000);
outgoing.on("close", () => clearTimeout(timeout));
outgoing.on("error", () => reject(new Error("Mastodon request failed.")));
outgoing.end(body);
});
}
+40 -49
View File
@@ -1,54 +1,45 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest'
import {
isPublicAddress,
mastodonOrigins,
requireMastodonOrigin,
} from './transport.server'
import { afterEach, expect, it, vi } from "vitest";
import { isPublicAddress, mastodonOrigins, requireMastodonOrigin } from "./transport.server";
afterEach(() => vi.unstubAllEnvs())
afterEach(() => vi.unstubAllEnvs());
it.each([
'127.0.0.1',
'10.0.0.1',
'172.16.0.1',
'192.168.1.1',
'100.91.91.87',
'169.254.169.254',
'0.0.0.0',
'224.0.0.1',
'198.18.0.1',
'192.0.2.1',
'::1',
'::ffff:127.0.0.1',
'fe80::1',
'fd00::1',
'2001:db8::1',
'2002:7f00:1::1',
])('rejects non-public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(false))
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"100.91.91.87",
"169.254.169.254",
"0.0.0.0",
"224.0.0.1",
"198.18.0.1",
"192.0.2.1",
"::1",
"::ffff:127.0.0.1",
"fe80::1",
"fd00::1",
"2001:db8::1",
"2002:7f00:1::1",
])("rejects non-public destination %s", (address) => expect(isPublicAddress(address)).toBe(false));
it.each(["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])(
"accepts public destination %s",
(address) => expect(isPublicAddress(address)).toBe(true),
);
it.each([
'1.1.1.1',
'8.8.8.8',
'2606:4700:4700::1111',
])('accepts public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(true))
it.each([
'http://mastodon.invalid',
'https://mastodon.invalid/path',
'https://mastodon.invalid:8443',
'https://127.0.0.1',
'https://user:password@mastodon.invalid',
])('rejects unsafe configured origin %s', (origin) => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', origin)
expect(() => mastodonOrigins()).toThrow()
})
it('requires exact configured origin equality', () => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', 'https://mastodon.invalid')
expect(requireMastodonOrigin('https://mastodon.invalid')).toBe(
'https://mastodon.invalid',
)
expect(() =>
requireMastodonOrigin('https://mastodon.invalid.attacker.invalid'),
).toThrow('not configured')
})
"http://mastodon.invalid",
"https://mastodon.invalid/path",
"https://mastodon.invalid:8443",
"https://127.0.0.1",
"https://user:[email protected]",
])("rejects unsafe configured origin %s", (origin) => {
vi.stubEnv("TWITTER_LITE_MASTODON_ORIGINS", origin);
expect(() => mastodonOrigins()).toThrow("Mastodon origins must be HTTPS");
});
it("requires exact configured origin equality", () => {
vi.stubEnv("TWITTER_LITE_MASTODON_ORIGINS", "https://mastodon.invalid");
expect(requireMastodonOrigin("https://mastodon.invalid")).toBe("https://mastodon.invalid");
expect(() => requireMastodonOrigin("https://mastodon.invalid.attacker.invalid")).toThrow(
"not configured",
);
});
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { validateWorkspaceSearch } from "./search";
it("discards malformed navigation state rather than breaking the page", () => {
const state = validateWorkspaceSearch({
q: ["a"],
accounts: "yes",
view: "unknown",
run: "../private",
article: { id: 1 },
password: "secret",
});
expect(state).not.toHaveProperty("password");
expect(state.q).toBeUndefined();
expect(state.accounts).toBeUndefined();
expect(state.view).toBeUndefined();
expect(state.run).toBeUndefined();
expect(state.article).toBeUndefined();
});
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod";
const text = z.string().max(500).optional().catch(undefined);
const flag = z.literal(true).optional().catch(undefined);
const workspaceSearch = z.object({
q: text,
article: text,
conversation: text,
view: z.enum(["now", "later", "waiting", "done", "all"]).optional().catch(undefined),
accounts: flag,
review: flag,
entry: text,
deck: text,
run: z
.union([z.uuid(), z.literal("new")])
.optional()
.catch(undefined),
citation: text,
vitalSection: z.enum(["Body", "Environment"]).optional().catch(undefined),
vitalTab: text,
vitalHistory: flag,
vitalDetail: text,
environmentTab: z.enum(["Overview", "Climate control"]).optional().catch(undefined),
environmentRange: z.enum(["24H", "48H", "7D"]).optional().catch(undefined),
environmentMetric: text,
environmentDetail: text,
returnTo: z.string().max(4096).optional().catch(undefined),
});
export type WorkspaceSearch = z.infer<typeof workspaceSearch>;
export function validateWorkspaceSearch(search: Record<string, unknown>): WorkspaceSearch {
return workspaceSearch.parse(search);
}
@@ -0,0 +1,50 @@
import { useDebouncer } from "@tanstack/react-pacer";
import { useRouter } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useUrlState } from "./use-url-state";
// Only the uncommitted text is local. Results and restored views read the URL.
export function useSearchInput(selection: "article" | "conversation") {
const [search, updateSearch] = useUrlState();
const router = useRouter();
const [draft, setDraft] = useState<string | null>(null);
const composing = useRef(false);
const debouncer = useDebouncer(
(value: string) => {
updateSearch({ q: value || undefined, [selection]: undefined }, { replace: true });
},
{ wait: 300 },
);
useEffect(
() =>
router.subscribe("onBeforeNavigate", () => {
debouncer.cancel();
setDraft(null);
}),
[router, debouncer],
);
function change(value: string) {
setDraft(value);
if (!composing.current) debouncer.maybeExecute(value);
}
return {
value: draft ?? search.q ?? "",
onChange: (event: React.ChangeEvent<HTMLInputElement>) => change(event.target.value),
onCompositionStart: () => {
composing.current = true;
debouncer.cancel();
},
onCompositionEnd: (event: React.CompositionEvent<HTMLInputElement>) => {
composing.current = false;
change(event.currentTarget.value);
},
clear: () => {
debouncer.cancel();
setDraft(null);
updateSearch({ q: undefined, [selection]: undefined }, { replace: true });
},
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { validateWorkspaceSearch, type WorkspaceSearch } from "./search";
export function useUrlState() {
const search = useSearch({ from: "__root__" });
const navigate = useNavigate();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const update = (patch: Partial<WorkspaceSearch>, options?: { replace?: boolean }) => {
if (!mounted.current) return;
void navigate({
to: ".",
search: (previous) => validateWorkspaceSearch({ ...previous, ...patch }),
replace: options?.replace ?? false,
resetScroll: false,
});
};
return [search, update] as const;
}
+64 -97
View File
@@ -1,154 +1,121 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { requestMastodon } from '../mastodon/client.server'
import { getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import { mapMastodonPost } from './mastodon.server'
import { mastodonFeedInput } from './mastodon-source'
import type { ResearchPage } from './types'
import { eq } from "drizzle-orm";
import { z } from "zod";
import { requestMastodon } from "../mastodon/client.server";
import { getDatabase } from "../storage/database.server";
import { connections } from "../storage/schema";
import { mapMastodonPost } from "./mastodon.server";
import { mastodonFeedInput } from "./mastodon-source";
import type { ResearchPage } from "./types";
export class MastodonFeedError extends Error {
constructor(
readonly code:
| 'account-unavailable'
| 'rate-limit'
| 'search-unavailable'
| 'request-failed',
readonly code: "account-unavailable" | "rate-limit" | "search-unavailable" | "request-failed",
message: string,
) {
super(message)
super(message);
}
}
const PAGE_SIZE = 20
const PAGE_SIZE = 20;
async function requireOk(response: Response, search = false) {
if (response.ok) return response
if (response.ok) return response;
if (response.status === 401)
throw new MastodonFeedError(
'account-unavailable',
'Reconnect your account.',
)
throw new MastodonFeedError("account-unavailable", "Reconnect your account.");
if (response.status === 429)
throw new MastodonFeedError(
'rate-limit',
'Rate limit reached. Wait a moment and try again.',
)
throw new MastodonFeedError("rate-limit", "Rate limit reached. Wait a moment and try again.");
if (search && [400, 422, 501, 503].includes(response.status))
throw new MastodonFeedError(
'search-unavailable',
'Search is unavailable. Check server support and your search query.',
)
"search-unavailable",
"Search is unavailable. Check server support and your search query.",
);
throw new MastodonFeedError(
'request-failed',
'Could not load from Mastodon. Check the connection and requested resource.',
)
"request-failed",
"Could not load from Mastodon. Check the connection and requested resource.",
);
}
/** Extract only a numeric max_id; never follow an upstream-provided URL. */
export function nextMastodonCursor(
link: string | null,
origin: string,
path: string,
) {
for (const part of link?.split(',') ?? []) {
const match = part.match(/<([^>]+)>;\s*rel="next"/)
if (!match?.[1]) continue
export function nextMastodonCursor(link: string | null, origin: string, path: string) {
for (const part of link?.split(",") ?? []) {
const match = part.match(/<([^>]+)>;\s*rel="next"/);
if (!match?.[1]) continue;
try {
const url = new URL(match[1])
const cursor = url.searchParams.get('max_id')
if (
url.origin === origin &&
url.pathname === path &&
cursor &&
/^\d{1,32}$/.test(cursor)
)
return cursor
const url = new URL(match[1]);
const cursor = url.searchParams.get("max_id");
if (url.origin === origin && url.pathname === path && cursor && /^\d{1,32}$/.test(cursor))
return cursor;
} catch {
/* A malformed continuation must not become a request target. */
}
}
return undefined
return undefined;
}
export async function fetchMastodonPage(
raw: unknown,
request = requestMastodon,
): Promise<ResearchPage> {
const { connectionId, source, cursor } = mastodonFeedInput.parse(raw)
const { connectionId, source, cursor } = mastodonFeedInput.parse(raw);
const connection = getDatabase()
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new MastodonFeedError(
'account-unavailable',
'Connect a Mastodon account.',
)
const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
let path: string
if (source.kind === 'search') {
if (cursor && !/^\d{1,8}$/.test(cursor))
throw new Error('Invalid search cursor.')
path = '/api/v2/search'
params.set('q', source.query)
params.set('type', 'statuses')
params.set('resolve', 'false')
params.set('offset', cursor ?? '0')
.get();
if (connection?.platform !== "mastodon" || connection.status !== "connected")
throw new MastodonFeedError("account-unavailable", "Connect a Mastodon account.");
const params = new URLSearchParams({ limit: String(PAGE_SIZE) });
let path: string;
if (source.kind === "search") {
if (cursor && !/^\d{1,8}$/.test(cursor)) throw new Error("Invalid search cursor.");
path = "/api/v2/search";
params.set("q", source.query);
params.set("type", "statuses");
params.set("resolve", "false");
params.set("offset", cursor ?? "0");
} else {
if (cursor && !/^\d{1,32}$/.test(cursor))
throw new Error('Invalid timeline cursor.')
if (cursor) params.set('max_id', cursor)
if (source.kind === 'user') {
let id = source.target
if (cursor && !/^\d{1,32}$/.test(cursor)) throw new Error("Invalid timeline cursor.");
if (cursor) params.set("max_id", cursor);
if (source.kind === "user") {
let id = source.target;
if (!/^\d{1,32}$/.test(id)) {
const lookup = await requireOk(
await request(
connectionId,
'/api/v1/accounts/lookup',
new URLSearchParams({ acct: id.replace(/^@/, '') }),
"/api/v1/accounts/lookup",
new URLSearchParams({ acct: id.replace(/^@/, "") }),
),
)
id = z
.object({ id: z.string().regex(/^\d{1,32}$/) })
.parse(await lookup.json()).id
);
id = z.object({ id: z.string().regex(/^\d{1,32}$/) }).parse(await lookup.json()).id;
}
path = `/api/v1/accounts/${id}/statuses`
} else if (source.kind === 'list')
path = `/api/v1/timelines/list/${source.target}`
else path = `/api/v1/timelines/tag/${encodeURIComponent(source.target)}`
path = `/api/v1/accounts/${id}/statuses`;
} else if (source.kind === "list") path = `/api/v1/timelines/list/${source.target}`;
else path = `/api/v1/timelines/tag/${encodeURIComponent(source.target)}`;
}
const response = await requireOk(
await request(connectionId, path, params),
source.kind === 'search',
)
const data: unknown = await response.json()
source.kind === "search",
);
const data: unknown = await response.json();
const statuses =
source.kind === 'search'
source.kind === "search"
? z.object({ statuses: z.array(z.unknown()) }).parse(data).statuses
: z.array(z.unknown()).parse(data)
: z.array(z.unknown()).parse(data);
const nextCursor =
source.kind === 'search'
source.kind === "search"
? statuses.length === PAGE_SIZE
? String(Number(cursor ?? '0') + PAGE_SIZE)
? String(Number(cursor ?? "0") + PAGE_SIZE)
: undefined
: nextMastodonCursor(
response.headers.get('link'),
connection.origin,
path,
)
: nextMastodonCursor(response.headers.get("link"), connection.origin, path);
return {
posts: statuses.map((post) => mapMastodonPost(post, connection.origin)),
...(nextCursor ? { nextCursor } : {}),
}
};
}
export async function fetchMastodonLists(connectionId: string) {
const response = await requireOk(
await requestMastodon(connectionId, '/api/v1/lists'),
)
const response = await requireOk(await requestMastodon(connectionId, "/api/v1/lists"));
return z
.array(z.object({ id: z.string(), title: z.string() }))
.parse(await response.json())
.map((list) => ({ id: list.id, name: list.title, isPrivate: true }))
.map((list) => ({ id: list.id, name: list.title, isPrivate: true }));
}
+70 -68
View File
@@ -1,112 +1,114 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import { fetchMastodonPage, nextMastodonCursor } from './mastodon-feed.server'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connections } from "../storage/schema";
import { fetchMastodonPage, nextMastodonCursor } from "./mastodon-feed.server";
let database: AppDatabase
vi.mock('../storage/database.server', async (original) => ({
...(await original<typeof import('../storage/database.server')>()),
let database: AppDatabase;
vi.mock("../storage/database.server", async (original) => ({
...(await original<typeof import("../storage/database.server")>()),
getDatabase: () => database,
}))
}));
beforeEach(() => {
database = openDatabase(':memory:')
database = openDatabase(":memory:");
database
.insert(connections)
.values({
id: 'account-a',
platform: 'mastodon',
origin: 'https://social.invalid',
accountId: '1',
displayName: 'A',
status: 'connected',
id: "account-a",
platform: "mastodon",
origin: "https://social.invalid",
accountId: "1",
displayName: "A",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
const source = { platform: 'mastodon', kind: 'hashtag', target: '猫' }
.run();
});
afterEach(() => database.$client.close());
const source = { platform: "mastodon", kind: "hashtag", target: "猫" };
describe('Mastodon feed requests', () => {
it('uses the column connection and numeric continuation without following links', async () => {
const request = vi.fn().mockResolvedValue(
new Response('[]', {
describe("Mastodon feed requests", () => {
it("uses the column connection and numeric continuation without following links", async () => {
const request = vi.fn<NonNullable<Parameters<typeof fetchMastodonPage>[1]>>().mockResolvedValue(
new Response("[]", {
headers: {
link: '<https://social.invalid/api/v1/timelines/tag/%E7%8C%AB?max_id=100>; rel="next"',
},
}),
)
);
const result = await fetchMastodonPage(
{ connectionId: 'account-a', source, cursor: '200' },
{ connectionId: "account-a", source, cursor: "200" },
request,
)
);
expect(request).toHaveBeenCalledWith(
'account-a',
'/api/v1/timelines/tag/%E7%8C%AB',
new URLSearchParams({ limit: '20', max_id: '200' }),
)
expect(result.nextCursor).toBe('100')
"account-a",
"/api/v1/timelines/tag/%E7%8C%AB",
new URLSearchParams({ limit: "20", max_id: "200" }),
);
expect(result.nextCursor).toBe("100");
expect(
nextMastodonCursor(
'<https://evil.invalid/api/v1/timelines/tag/cat?max_id=10>; rel="next"',
'https://social.invalid',
'/api/v1/timelines/tag/cat',
"https://social.invalid",
"/api/v1/timelines/tag/cat",
),
).toBeUndefined()
})
it('resolves remote account handles in the selected instance before reading statuses', async () => {
).toBeUndefined();
});
it("resolves remote account handles in the selected instance before reading statuses", async () => {
const request = vi
.fn()
.mockResolvedValueOnce(Response.json({ id: '42' }))
.mockResolvedValueOnce(Response.json([]))
.fn<NonNullable<Parameters<typeof fetchMastodonPage>[1]>>()
.mockResolvedValueOnce(Response.json({ id: "42" }))
.mockResolvedValueOnce(Response.json([]));
await fetchMastodonPage(
{
connectionId: 'account-a',
connectionId: "account-a",
source: {
platform: 'mastodon',
kind: 'user',
target: '@[email protected]',
platform: "mastodon",
kind: "user",
target: "@[email protected]",
},
},
request,
)
);
expect(request.mock.calls[0]).toEqual([
'account-a',
'/api/v1/accounts/lookup',
new URLSearchParams({ acct: '[email protected]' }),
])
expect(request.mock.calls[1]?.[1]).toBe('/api/v1/accounts/42/statuses')
})
it('distinguishes rate limits, expired accounts, and unavailable search', async () => {
"account-a",
"/api/v1/accounts/lookup",
new URLSearchParams({ acct: "[email protected]" }),
]);
expect(request.mock.calls[1]?.[1]).toBe("/api/v1/accounts/42/statuses");
});
it("distinguishes rate limits, expired accounts, and unavailable search", async () => {
for (const [status, code] of [
[429, 'rate-limit'],
[401, 'account-unavailable'],
[422, 'search-unavailable'],
[429, "rate-limit"],
[401, "account-unavailable"],
[422, "search-unavailable"],
] as const) {
const request = vi.fn().mockResolvedValue(new Response('{}', { status }))
const request = vi
.fn<NonNullable<Parameters<typeof fetchMastodonPage>[1]>>()
.mockResolvedValue(new Response("{}", { status }));
await expect(
fetchMastodonPage(
{
connectionId: 'account-a',
source: { platform: 'mastodon', kind: 'search', query: 'test' },
connectionId: "account-a",
source: { platform: "mastodon", kind: "search", query: "test" },
},
request,
),
).rejects.toMatchObject({ code })
).rejects.toMatchObject({ code });
}
})
it('rejects invalid cursors and missing accounts before sending a request', async () => {
const request = vi.fn()
});
it("rejects invalid cursors and missing accounts before sending a request", async () => {
const request = vi.fn<NonNullable<Parameters<typeof fetchMastodonPage>[1]>>();
await expect(
fetchMastodonPage(
{ connectionId: 'account-a', source, cursor: 'https://evil.invalid' },
{ connectionId: "account-a", source, cursor: "https://evil.invalid" },
request,
),
).rejects.toThrow()
).rejects.toThrow("Invalid timeline cursor");
await expect(
fetchMastodonPage({ connectionId: 'missing', source }, request),
).rejects.toMatchObject({ code: 'account-unavailable' })
expect(request).not.toHaveBeenCalled()
})
})
fetchMastodonPage({ connectionId: "missing", source }, request),
).rejects.toMatchObject({ code: "account-unavailable" });
expect(request).not.toHaveBeenCalled();
});
});
@@ -1,35 +1,28 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { mastodonFeedInput } from './mastodon-source'
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { mastodonFeedInput } from "./mastodon-source";
export const loadMastodonPosts = createServerFn({ method: 'GET' })
export const loadMastodonPosts = createServerFn({ method: "GET" })
.validator(mastodonFeedInput)
.handler(async ({ data }) => {
const { fetchMastodonPage, MastodonFeedError } = await import(
'./mastodon-feed.server'
)
const { fetchMastodonPage, MastodonFeedError } = await import("./mastodon-feed.server");
try {
return { ok: true as const, page: await fetchMastodonPage(data) }
return { ok: true as const, page: await fetchMastodonPage(data) };
} catch (error) {
return {
ok: false as const,
error: {
code:
error instanceof MastodonFeedError ? error.code : 'request-failed',
code: error instanceof MastodonFeedError ? error.code : "request-failed",
message:
error instanceof MastodonFeedError
? error.message
: 'Could not load from Mastodon.',
retryable:
!(error instanceof MastodonFeedError) ||
error.code !== 'account-unavailable',
error instanceof MastodonFeedError ? error.message : "Could not load from Mastodon.",
retryable: !(error instanceof MastodonFeedError) || error.code !== "account-unavailable",
},
}
};
}
})
export const loadMastodonLists = createServerFn({ method: 'GET' })
});
export const loadMastodonLists = createServerFn({ method: "GET" })
.validator(z.object({ connectionId: z.string().min(1) }).strict())
.handler(async ({ data }) => {
const { fetchMastodonLists } = await import('./mastodon-feed.server')
return { lists: await fetchMastodonLists(data.connectionId) }
})
const { fetchMastodonLists } = await import("./mastodon-feed.server");
return { lists: await fetchMastodonLists(data.connectionId) };
});
+13 -13
View File
@@ -1,24 +1,24 @@
import { z } from 'zod'
import { z } from "zod";
export const mastodonSourceSchema = z.discriminatedUnion('kind', [
export const mastodonSourceSchema = z.discriminatedUnion("kind", [
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('user'),
platform: z.literal("mastodon"),
kind: z.literal("user"),
target: z.string().trim().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('list'),
platform: z.literal("mastodon"),
kind: z.literal("list"),
target: z.string().regex(/^\d{1,32}$/),
})
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('hashtag'),
platform: z.literal("mastodon"),
kind: z.literal("hashtag"),
target: z
.string()
.trim()
@@ -28,17 +28,17 @@ export const mastodonSourceSchema = z.discriminatedUnion('kind', [
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('search'),
platform: z.literal("mastodon"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
})
.strict(),
])
export type MastodonSource = z.infer<typeof mastodonSourceSchema>
]);
export type MastodonSource = z.infer<typeof mastodonSourceSchema>;
export const mastodonFeedInput = z
.object({
connectionId: z.string().min(1),
source: mastodonSourceSchema,
cursor: z.string().max(128).optional(),
})
.strict()
.strict();
+50 -76
View File
@@ -1,25 +1,21 @@
import sanitizeHtml from 'sanitize-html'
import { z } from 'zod'
import type { ResearchPost } from './types'
import sanitizeHtml from "sanitize-html";
import { z } from "zod";
import type { ResearchPost } from "./types";
const webUrl = z
.string()
.url()
.refine((value) => {
const url = new URL(value)
return (
['https:', 'http:'].includes(url.protocol) &&
!url.username &&
!url.password
)
})
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password;
});
const accountSchema = z.object({
id: z.string(),
acct: z.string(),
display_name: z.string(),
username: z.string(),
avatar: z.string().optional(),
})
});
const baseStatusSchema = z.object({
id: z.string(),
uri: webUrl,
@@ -37,98 +33,76 @@ const baseStatusSchema = z.object({
description: z.string().nullable(),
}),
),
})
});
const statusSchema = baseStatusSchema.extend({
reblog: baseStatusSchema.nullable().optional(),
})
});
export function cleanMastodonContent(content: string) {
const html = sanitizeHtml(content, {
allowedTags: [
'p',
'br',
'a',
'span',
'strong',
'em',
'b',
'i',
'code',
'pre',
'blockquote',
],
allowedAttributes: { a: ['href', 'rel', 'target'] },
allowedSchemes: ['https', 'http'],
allowedTags: ["p", "br", "a", "span", "strong", "em", "b", "i", "code", "pre", "blockquote"],
allowedAttributes: { a: ["href", "rel", "target"] },
allowedSchemes: ["https", "http"],
allowProtocolRelative: false,
transformTags: {
a: sanitizeHtml.simpleTransform('a', {
target: '_blank',
rel: 'noreferrer noopener',
a: sanitizeHtml.simpleTransform("a", {
target: "_blank",
rel: "noreferrer noopener",
}),
},
})
const escapedText = sanitizeHtml(html.replace(/<\/p>|<br\s*\/?>/g, '\n'), {
});
const escapedText = sanitizeHtml(html.replace(/<\/p>|<br\s*\/?>/g, "\n"), {
allowedTags: [],
allowedAttributes: {},
})
const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>' }
});
const entities: Record<string, string> = { amp: "&", lt: "<", gt: ">" };
const text = escapedText
.replace(/&(amp|lt|gt);/g, (_, name: string) => entities[name] ?? '')
.trim()
return { html, text }
.replace(/&(amp|lt|gt);/g, (_, name: string) => entities[name] ?? "")
.trim();
return { html, text };
}
export function mapMastodonPost(raw: unknown, origin: string): ResearchPost {
const wrapper = statusSchema.parse(raw)
const post = wrapper.reblog ?? wrapper
const wrapper = statusSchema.parse(raw);
const post = wrapper.reblog ?? wrapper;
const author = (account: z.infer<typeof accountSchema>) => ({
name: account.display_name || account.username,
handle: account.acct.includes('@')
? account.acct
: `${account.acct}@${new URL(origin).host}`,
})
const timestamp = Date.parse(post.created_at)
const media: NonNullable<ResearchPost['media']> =
post.media_attachments.flatMap((item) => {
if (
!['image', 'video', 'gifv'].includes(item.type) ||
!webUrl.safeParse(item.url).success
)
return []
return [
{
type:
item.type === 'image'
? ('photo' as const)
: item.type === 'gifv'
? ('gif' as const)
: ('video' as const),
url: item.url as string,
...(webUrl.safeParse(item.preview_url).success
? { previewUrl: item.preview_url as string }
: {}),
...(item.description ? { alt: item.description } : {}),
},
]
})
handle: account.acct.includes("@") ? account.acct : `${account.acct}@${new URL(origin).host}`,
});
const timestamp = Date.parse(post.created_at);
const media: NonNullable<ResearchPost["media"]> = post.media_attachments.flatMap((item) => {
if (!["image", "video", "gifv"].includes(item.type) || !webUrl.safeParse(item.url).success)
return [];
return [
{
type:
item.type === "image"
? ("photo" as const)
: item.type === "gifv"
? ("gif" as const)
: ("video" as const),
url: item.url as string,
...(webUrl.safeParse(item.preview_url).success
? { previewUrl: item.preview_url as string }
: {}),
...(item.description ? { alt: item.description } : {}),
},
];
});
return {
key: `mastodon:${wrapper.uri}`,
platform: 'mastodon',
platform: "mastodon",
nativeId: post.id,
url: post.url ?? post.uri,
...cleanMastodonContent(post.content),
author: {
...author(post.account),
...(webUrl.safeParse(post.account.avatar).success
? { avatarUrl: post.account.avatar }
: {}),
...(webUrl.safeParse(post.account.avatar).success ? { avatarUrl: post.account.avatar } : {}),
},
...(Number.isFinite(timestamp)
? { createdAt: new Date(timestamp).toISOString() }
: {}),
...(Number.isFinite(timestamp) ? { createdAt: new Date(timestamp).toISOString() } : {}),
...(post.spoiler_text ? { contentWarning: post.spoiler_text } : {}),
sensitive: post.sensitive,
...(wrapper.reblog ? { boostedBy: author(wrapper.account) } : {}),
media,
}
};
}

Some files were not shown because too many files have changed in this diff Show More