feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import type { Deck, Workspace } from './model'
|
||||
import type { Connection } from '#/features/connections/model'
|
||||
import type { Deck, DeckView, Workspace } from './model'
|
||||
import { useWebMCPSupported } from './use-webmcp-supported'
|
||||
import {
|
||||
deckIdInput,
|
||||
deleteDeckToolInput,
|
||||
emptyToolInput,
|
||||
getDeckInput,
|
||||
prepareDeck,
|
||||
@@ -12,126 +14,166 @@ import {
|
||||
|
||||
export function useDeckTools({
|
||||
ready,
|
||||
saving,
|
||||
storageError,
|
||||
save,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
profiles,
|
||||
connections,
|
||||
onApplied,
|
||||
createTemporary,
|
||||
persist,
|
||||
}: {
|
||||
ready: boolean
|
||||
saving: boolean
|
||||
storageError?: string
|
||||
save: (deck: Deck) => boolean
|
||||
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
|
||||
select: (id: string) => boolean
|
||||
remove: (id: string) => boolean
|
||||
remove: (id: string, expectedRevision?: number) => Promise<boolean>
|
||||
getWorkspace: () => Workspace
|
||||
profiles: string[] | undefined
|
||||
connections: Connection[] | undefined
|
||||
onApplied: () => void
|
||||
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
|
||||
persist: (id: string) => Promise<boolean>
|
||||
}) {
|
||||
const supported = useWebMCPSupported()
|
||||
const enabled = supported && ready
|
||||
function applied(persisted: boolean) {
|
||||
onApplied()
|
||||
if (!persisted)
|
||||
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
|
||||
}
|
||||
function requireIdle() {
|
||||
if (saving) throw new Error('Wait for the current save to finish.')
|
||||
}
|
||||
function applied(success: boolean) {
|
||||
if (!success)
|
||||
throw new Error(
|
||||
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
|
||||
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
|
||||
)
|
||||
onApplied()
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'list_decks',
|
||||
name: 'list_connections',
|
||||
description:
|
||||
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
|
||||
'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)
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
profiles: profiles ?? null,
|
||||
storageError: storageError ?? null,
|
||||
}
|
||||
return { connections: connections ?? null }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
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.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
emptyToolInput.parse(input)
|
||||
return { ...getWorkspace(), storageError: storageError ?? null }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
description:
|
||||
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
|
||||
'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 },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = getDeckInput.parse(input)
|
||||
const workspace = getWorkspace()
|
||||
const deck = workspace.decks.find(
|
||||
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
|
||||
)
|
||||
if (!deck) throw new Error('Deck not found.')
|
||||
return { deck, storageError: storageError ?? null }
|
||||
}),
|
||||
toolResult(() => ({
|
||||
deck: requireDeck(getDeckInput.parse(input).deckId),
|
||||
storageError: storageError ?? null,
|
||||
})),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'set_deck',
|
||||
description:
|
||||
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
|
||||
'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(() => {
|
||||
toolResult(async () => {
|
||||
requireIdle()
|
||||
const parsed = setDeckInput.parse(input)
|
||||
if (
|
||||
parsed.deckId &&
|
||||
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
|
||||
)
|
||||
throw new Error('Deck not found.')
|
||||
if (!profiles) throw new Error('Relay profiles are not available yet.')
|
||||
const next = prepareDeck(parsed, profiles)
|
||||
applied(save(next))
|
||||
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
|
||||
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 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)
|
||||
return {
|
||||
deck,
|
||||
persisted: deck.persisted,
|
||||
posts: 'loading-asynchronously',
|
||||
}
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
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.',
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'select_deck',
|
||||
description:
|
||||
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load 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))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
return getWorkspace()
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'delete_deck',
|
||||
description:
|
||||
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
|
||||
inputSchema: deckIdInput,
|
||||
'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(() => {
|
||||
const { deckId } = deckIdInput.parse(input)
|
||||
applied(remove(deckId))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
toolResult(async () => {
|
||||
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()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user