feat: add URL-driven workspace state and streamed Codex research
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user