Refine link/embed resolution code
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
|
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {type resolveLink} from '#/lib/api/resolve'
|
|
||||||
import {
|
import {
|
||||||
type LinkResolutionOutcome,
|
type ResolvedLink,
|
||||||
startUriResolution,
|
resolveLink as importedResolveLink,
|
||||||
} from '#/components/ComposerV2/store/linkResolution'
|
type resolveLink,
|
||||||
|
} from '#/lib/api/resolve'
|
||||||
|
import {createPublicAgent} from '#/state/session/agent'
|
||||||
import type * as types from '#/components/ComposerV2/store/types'
|
import type * as types from '#/components/ComposerV2/store/types'
|
||||||
import {
|
import {
|
||||||
startImageUpload,
|
startImageUpload,
|
||||||
@@ -18,6 +19,7 @@ import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUri
|
|||||||
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
|
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
|
||||||
import {createAsyncTaskRev} from '#/components/ComposerV2/store/utils/createAsyncTaskRev'
|
import {createAsyncTaskRev} from '#/components/ComposerV2/store/utils/createAsyncTaskRev'
|
||||||
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
|
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
|
||||||
|
import {parseResolveLinkError} from '#/components/ComposerV2/store/utils/parseResolveLinkError'
|
||||||
|
|
||||||
type Listener = () => void
|
type Listener = () => void
|
||||||
|
|
||||||
@@ -304,6 +306,7 @@ export function createThreadStore(options: {
|
|||||||
const post = state.posts[postId]
|
const post = state.posts[postId]
|
||||||
if (!post) return
|
if (!post) return
|
||||||
|
|
||||||
|
const resolve = resolveLinkOverride ?? importedResolveLink
|
||||||
const target = classifyUriTarget(uri)
|
const target = classifyUriTarget(uri)
|
||||||
|
|
||||||
if (target === 'quote') {
|
if (target === 'quote') {
|
||||||
@@ -318,12 +321,10 @@ export function createThreadStore(options: {
|
|||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
startUriResolution({
|
resolve(createPublicAgent(), uri).then(
|
||||||
postId,
|
link => applyQuoteResolved(rev, postId, uri, link),
|
||||||
uri,
|
err => applyQuoteFailed(rev, postId, uri, err),
|
||||||
resolveLink: resolveLinkOverride,
|
)
|
||||||
onResolve: handleQuoteResolution(rev, uri),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,106 +346,114 @@ export function createThreadStore(options: {
|
|||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
startUriResolution({
|
resolve(createPublicAgent(), uri).then(
|
||||||
postId,
|
link => applyEmbedResolved(rev, postId, uri, link),
|
||||||
uri,
|
err => applyEmbedFailed(rev, postId, uri, err),
|
||||||
resolveLink: resolveLinkOverride,
|
)
|
||||||
onResolve: handleEmbedResolution(rev, uri),
|
}
|
||||||
|
|
||||||
|
function applyQuoteResolved(
|
||||||
|
rev: number,
|
||||||
|
postId: string,
|
||||||
|
uri: string,
|
||||||
|
link: ResolvedLink,
|
||||||
|
) {
|
||||||
|
if (destroyed) return
|
||||||
|
if (!quoteRev.isCurrentFor(postId, rev)) return
|
||||||
|
// Pre-classification said this was a post URL. If resolveLink disagrees
|
||||||
|
// (rare, since both use the same URL patterns), surface as a generic
|
||||||
|
// failure in the quote slot.
|
||||||
|
if (link.type !== 'record' || link.kind !== 'post') {
|
||||||
|
applyQuoteFailed(rev, postId, uri, new Error('Could not resolve post'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutateState(s => {
|
||||||
|
const p = s.posts[postId]
|
||||||
|
if (!p) return null
|
||||||
|
s.posts[postId] = setPostQuote(p, {
|
||||||
|
state: 'resolved',
|
||||||
|
uri: link.record.uri,
|
||||||
|
cid: link.record.cid,
|
||||||
|
view: link.view,
|
||||||
|
})
|
||||||
|
return s
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuoteResolution(rev: number, uri: string) {
|
function applyQuoteFailed(
|
||||||
return (postId: string, outcome: LinkResolutionOutcome) => {
|
rev: number,
|
||||||
if (destroyed) return
|
postId: string,
|
||||||
if (!quoteRev.isCurrentFor(postId, rev)) return
|
uri: string,
|
||||||
|
err: unknown,
|
||||||
// Pre-classification said this was a post URL. If resolveLink disagrees
|
) {
|
||||||
// (deleted post, embedding disabled, network error, etc.), surface as
|
if (destroyed) return
|
||||||
// failed in the quote slot. Non-retryable failure codes (e.g.
|
if (!quoteRev.isCurrentFor(postId, rev)) return
|
||||||
// embedding-disabled) get a failed state with no `retry()`; the user
|
// Non-retryable failure codes (e.g. embedding-disabled) get a failed
|
||||||
// has to remove the embed manually.
|
// state with no `retry()`; the user has to remove the embed manually.
|
||||||
if (outcome.kind !== 'post') {
|
const {code, isRetryable} = parseResolveLinkError(err)
|
||||||
const code: types.LinkResolutionFailureCode =
|
mutateState(s => {
|
||||||
outcome.embed.state === 'failed' ? outcome.embed.code : 'unknown'
|
const p = s.posts[postId]
|
||||||
const error =
|
if (!p) return null
|
||||||
outcome.embed.state === 'failed'
|
s.posts[postId] = setPostQuote(p, {
|
||||||
? outcome.embed.error
|
state: 'failed',
|
||||||
: 'Could not resolve post'
|
uri,
|
||||||
const failed: types.PostEmbedQuote = {
|
error: stringifyError(err),
|
||||||
state: 'failed',
|
code,
|
||||||
uri,
|
retry: isRetryable ? () => addUri(postId, uri) : undefined,
|
||||||
error,
|
|
||||||
code,
|
|
||||||
retry:
|
|
||||||
code === 'embedding-disabled'
|
|
||||||
? undefined
|
|
||||||
: () => addUri(postId, uri),
|
|
||||||
}
|
|
||||||
mutateState(s => {
|
|
||||||
const p = s.posts[postId]
|
|
||||||
if (!p) return null
|
|
||||||
s.posts[postId] = setPostQuote(p, failed)
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mutateState(s => {
|
|
||||||
const p = s.posts[postId]
|
|
||||||
if (!p) return null
|
|
||||||
s.posts[postId] = setPostQuote(p, {
|
|
||||||
state: 'resolved',
|
|
||||||
uri: outcome.record.uri,
|
|
||||||
cid: outcome.record.cid,
|
|
||||||
view: outcome.view,
|
|
||||||
})
|
|
||||||
return s
|
|
||||||
})
|
})
|
||||||
}
|
return s
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEmbedResolution(rev: number, uri: string) {
|
function applyEmbedResolved(
|
||||||
return (postId: string, outcome: LinkResolutionOutcome) => {
|
rev: number,
|
||||||
if (destroyed) return
|
postId: string,
|
||||||
if (!embedRev.isCurrentFor(postId, rev)) return
|
uri: string,
|
||||||
|
link: ResolvedLink,
|
||||||
// Pre-classification said this was a non-post URL. If resolveLink
|
) {
|
||||||
// surprises us with a post outcome, treat as failure rather than
|
if (destroyed) return
|
||||||
// silently moving slots.
|
if (!embedRev.isCurrentFor(postId, rev)) return
|
||||||
if (outcome.kind === 'post') {
|
// Pre-classification said this was a non-post URL. If resolveLink
|
||||||
const failed: types.PostEmbed = {
|
// surprises us with a post outcome, treat as failure rather than
|
||||||
state: 'failed',
|
// silently moving slots.
|
||||||
uri,
|
if (link.type === 'record' && link.kind === 'post') {
|
||||||
error: 'Unexpected post outcome for non-post URL',
|
applyEmbedFailed(
|
||||||
code: 'unknown',
|
rev,
|
||||||
retry: () => addUri(postId, uri),
|
postId,
|
||||||
}
|
uri,
|
||||||
mutateState(s => {
|
new Error('Unexpected post outcome for non-post URL'),
|
||||||
const p = s.posts[postId]
|
)
|
||||||
if (!p) return null
|
return
|
||||||
s.posts[postId] = setPostEmbed(p, failed)
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = outcome.embed
|
|
||||||
const stored: types.PostEmbed =
|
|
||||||
embed.state === 'failed'
|
|
||||||
? {
|
|
||||||
...embed,
|
|
||||||
retry:
|
|
||||||
embed.code === 'embedding-disabled'
|
|
||||||
? undefined
|
|
||||||
: () => addUri(postId, embed.uri),
|
|
||||||
}
|
|
||||||
: embed
|
|
||||||
mutateState(s => {
|
|
||||||
const p = s.posts[postId]
|
|
||||||
if (!p) return null
|
|
||||||
s.posts[postId] = setPostEmbed(p, stored)
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
mutateState(s => {
|
||||||
|
const p = s.posts[postId]
|
||||||
|
if (!p) return null
|
||||||
|
s.posts[postId] = setPostEmbed(p, resolvedLinkToEmbed(link))
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEmbedFailed(
|
||||||
|
rev: number,
|
||||||
|
postId: string,
|
||||||
|
uri: string,
|
||||||
|
err: unknown,
|
||||||
|
) {
|
||||||
|
if (destroyed) return
|
||||||
|
if (!embedRev.isCurrentFor(postId, rev)) return
|
||||||
|
const {code, isRetryable} = parseResolveLinkError(err)
|
||||||
|
mutateState(s => {
|
||||||
|
const p = s.posts[postId]
|
||||||
|
if (!p) return null
|
||||||
|
s.posts[postId] = setPostEmbed(p, {
|
||||||
|
state: 'failed',
|
||||||
|
uri,
|
||||||
|
error: stringifyError(err),
|
||||||
|
code,
|
||||||
|
retry: isRetryable ? () => addUri(postId, uri) : undefined,
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEmbed(postId: string) {
|
function removeEmbed(postId: string) {
|
||||||
@@ -582,6 +591,36 @@ export function createThreadStore(options: {
|
|||||||
return {...post, quote}
|
return {...post, quote}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a non-post ResolvedLink into the PostEmbed shape stored on the post.
|
||||||
|
* Callers handle the post-record case separately (those go to quote).
|
||||||
|
*/
|
||||||
|
function resolvedLinkToEmbed(link: ResolvedLink): types.PostEmbed {
|
||||||
|
if (link.type === 'external') {
|
||||||
|
return {
|
||||||
|
state: 'external',
|
||||||
|
uri: link.uri,
|
||||||
|
title: link.title,
|
||||||
|
description: link.description,
|
||||||
|
thumb: link.thumb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (link.kind) {
|
||||||
|
case 'feed':
|
||||||
|
return {state: 'feed', record: link.record, view: link.view}
|
||||||
|
case 'list':
|
||||||
|
return {state: 'list', record: link.record, view: link.view}
|
||||||
|
case 'starter-pack':
|
||||||
|
return {state: 'starter-pack', record: link.record, view: link.view}
|
||||||
|
case 'post':
|
||||||
|
throw new Error('post records should route to quote, not embed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyError(err: unknown): string {
|
||||||
|
return String((err && (err as Error).message) ?? err)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actions: {
|
actions: {
|
||||||
setPostText,
|
setPostText,
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* Link metadata resolution worker for the ComposerV2 store.
|
|
||||||
*
|
|
||||||
* Runs `resolveLink` (which classifies a URI as a post / feed / list /
|
|
||||||
* starter-pack record or as an external link card) and reports the outcome
|
|
||||||
* back via the `onResolve` callback. The store routes `kind: 'post'` outcomes
|
|
||||||
* to the post's `quote` field and everything else to the `embed` field.
|
|
||||||
*
|
|
||||||
* No cancellation surface: cancellation is handled at the store level by
|
|
||||||
* incrementing a per-post generation counter and ignoring stale callbacks.
|
|
||||||
* The `__resolveLink` test seam (passed through from createThreadStore)
|
|
||||||
* lets tests inject a controllable promise.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
type AppBskyFeedDefs,
|
|
||||||
type AppBskyGraphDefs,
|
|
||||||
type ComAtprotoRepoStrongRef,
|
|
||||||
} from '@atproto/api'
|
|
||||||
|
|
||||||
import {
|
|
||||||
EmbeddingDisabledError,
|
|
||||||
type ResolvedLink,
|
|
||||||
type resolveLink as defaultResolveLink,
|
|
||||||
} from '#/lib/api/resolve'
|
|
||||||
import {resolveLink as importedResolveLink} from '#/lib/api/resolve'
|
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
|
||||||
import {createPublicAgent} from '#/state/session/agent'
|
|
||||||
import {type LinkResolutionFailureCode} from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the worker (or a test) reports back about a resolved URI. `pending`
|
|
||||||
* is not part of this type because the worker only emits terminal outcomes;
|
|
||||||
* the synchronous pending state is set by the store itself before the
|
|
||||||
* worker runs.
|
|
||||||
*/
|
|
||||||
export type EmbedResolution =
|
|
||||||
| {
|
|
||||||
state: 'failed'
|
|
||||||
uri: string
|
|
||||||
error: string
|
|
||||||
code: LinkResolutionFailureCode
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: 'external'
|
|
||||||
uri: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
thumb: ComposerImage | undefined
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: 'feed'
|
|
||||||
record: ComAtprotoRepoStrongRef.Main
|
|
||||||
view: AppBskyFeedDefs.GeneratorView
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: 'list'
|
|
||||||
record: ComAtprotoRepoStrongRef.Main
|
|
||||||
view: AppBskyGraphDefs.ListView
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: 'starter-pack'
|
|
||||||
record: ComAtprotoRepoStrongRef.Main
|
|
||||||
view: AppBskyGraphDefs.StarterPackView
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Worker output for a single URI. The store routes `kind: 'post'` to the
|
|
||||||
* post's `quote` field and everything else to the `embed` field.
|
|
||||||
*/
|
|
||||||
export type LinkResolutionOutcome =
|
|
||||||
| {
|
|
||||||
kind: 'post'
|
|
||||||
record: ComAtprotoRepoStrongRef.Main
|
|
||||||
view: AppBskyFeedDefs.PostView
|
|
||||||
}
|
|
||||||
| {kind: 'embed'; embed: EmbedResolution}
|
|
||||||
|
|
||||||
export type StartUriResolutionOptions = {
|
|
||||||
postId: string
|
|
||||||
uri: string
|
|
||||||
/** Test seam; defaults to the imported resolveLink. */
|
|
||||||
resolveLink?: typeof defaultResolveLink
|
|
||||||
onResolve: (postId: string, outcome: LinkResolutionOutcome) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startUriResolution(opts: StartUriResolutionOptions): void {
|
|
||||||
const fn = opts.resolveLink ?? importedResolveLink
|
|
||||||
fn(createPublicAgent(), opts.uri).then(
|
|
||||||
link => opts.onResolve(opts.postId, mapResolvedLink(link)),
|
|
||||||
err =>
|
|
||||||
opts.onResolve(opts.postId, {
|
|
||||||
kind: 'embed',
|
|
||||||
embed: {
|
|
||||||
state: 'failed',
|
|
||||||
uri: opts.uri,
|
|
||||||
error: String((err && (err as Error).message) ?? err),
|
|
||||||
code: parseErrorCode(err),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify a thrown error into a stable failure code that drives UI
|
|
||||||
* behavior. Today we only special-case EmbeddingDisabledError (which
|
|
||||||
* `resolveLink` throws when fetching a post the author has marked
|
|
||||||
* non-embeddable); everything else falls into 'unknown' and is retryable.
|
|
||||||
*/
|
|
||||||
export function parseErrorCode(err: unknown): LinkResolutionFailureCode {
|
|
||||||
if (err instanceof EmbeddingDisabledError) return 'embedding-disabled'
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapResolvedLink(link: ResolvedLink): LinkResolutionOutcome {
|
|
||||||
if (link.type === 'external') {
|
|
||||||
return {
|
|
||||||
kind: 'embed',
|
|
||||||
embed: {
|
|
||||||
state: 'external',
|
|
||||||
uri: link.uri,
|
|
||||||
title: link.title,
|
|
||||||
description: link.description,
|
|
||||||
thumb: link.thumb,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (link.kind === 'post') {
|
|
||||||
return {kind: 'post', record: link.record, view: link.view}
|
|
||||||
}
|
|
||||||
if (link.kind === 'feed') {
|
|
||||||
return {
|
|
||||||
kind: 'embed',
|
|
||||||
embed: {state: 'feed', record: link.record, view: link.view},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (link.kind === 'list') {
|
|
||||||
return {
|
|
||||||
kind: 'embed',
|
|
||||||
embed: {state: 'list', record: link.record, view: link.view},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'embed',
|
|
||||||
embed: {state: 'starter-pack', record: link.record, view: link.view},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {EmbeddingDisabledError} from '#/lib/api/resolve'
|
||||||
|
import {type LinkResolutionFailureCode} from '#/components/ComposerV2/store/types'
|
||||||
|
|
||||||
|
export type ParsedResolveLinkError = {
|
||||||
|
code: LinkResolutionFailureCode
|
||||||
|
/**
|
||||||
|
* Whether the failure is worth retrying. Today only EmbeddingDisabledError
|
||||||
|
* is permanent (the post's author has forbidden quoting); anything else
|
||||||
|
* is a transient or unknown failure and the caller should attach a
|
||||||
|
* `retry()` to the failed state.
|
||||||
|
*/
|
||||||
|
isRetryable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a thrown error from `resolveLink` into a stable failure code
|
||||||
|
* and a retryability flag. Centralizes the retry-policy decision so the
|
||||||
|
* store doesn't repeat the `code === 'embedding-disabled'` check at every
|
||||||
|
* failure call site.
|
||||||
|
*/
|
||||||
|
export function parseResolveLinkError(err: unknown): ParsedResolveLinkError {
|
||||||
|
if (err instanceof EmbeddingDisabledError) {
|
||||||
|
return {code: 'embedding-disabled', isRetryable: false}
|
||||||
|
}
|
||||||
|
return {code: 'unknown', isRetryable: true}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user