checkpoint preclassify uri types
This commit is contained in:
@@ -18,6 +18,9 @@ jest.mock('#/state/session/agent', () => ({
|
|||||||
import {type ResolvedLink, type resolveLink} from '#/lib/api/resolve'
|
import {type ResolvedLink, type resolveLink} from '#/lib/api/resolve'
|
||||||
import {createThreadStore} from '#/components/ComposerV2/store'
|
import {createThreadStore} from '#/components/ComposerV2/store'
|
||||||
|
|
||||||
|
const POST_URL = 'https://bsky.app/profile/test.bsky.social/post/abc'
|
||||||
|
const EXTERNAL_URL = 'https://example.com'
|
||||||
|
|
||||||
function makeIdGenerator() {
|
function makeIdGenerator() {
|
||||||
let i = 0
|
let i = 0
|
||||||
return () => `id-${++i}`
|
return () => `id-${++i}`
|
||||||
@@ -59,13 +62,10 @@ function makeStore() {
|
|||||||
|
|
||||||
const fakePostView = (uri: string, cid: string) =>
|
const fakePostView = (uri: string, cid: string) =>
|
||||||
({uri, cid}) as unknown as AppBskyFeedDefs.PostView
|
({uri, cid}) as unknown as AppBskyFeedDefs.PostView
|
||||||
|
|
||||||
const fakeGeneratorView = (uri: string, cid: string) =>
|
const fakeGeneratorView = (uri: string, cid: string) =>
|
||||||
({uri, cid}) as unknown as AppBskyFeedDefs.GeneratorView
|
({uri, cid}) as unknown as AppBskyFeedDefs.GeneratorView
|
||||||
|
|
||||||
const fakeListView = (uri: string, cid: string) =>
|
const fakeListView = (uri: string, cid: string) =>
|
||||||
({uri, cid}) as unknown as AppBskyGraphDefs.ListView
|
({uri, cid}) as unknown as AppBskyGraphDefs.ListView
|
||||||
|
|
||||||
const fakeStarterPackView = (uri: string, cid: string) =>
|
const fakeStarterPackView = (uri: string, cid: string) =>
|
||||||
({uri, cid}) as unknown as AppBskyGraphDefs.StarterPackView
|
({uri, cid}) as unknown as AppBskyGraphDefs.StarterPackView
|
||||||
|
|
||||||
@@ -99,39 +99,96 @@ const starterPackLink: ResolvedLink = {
|
|||||||
|
|
||||||
const externalLink: ResolvedLink = {
|
const externalLink: ResolvedLink = {
|
||||||
type: 'external',
|
type: 'external',
|
||||||
uri: 'https://example.com',
|
uri: EXTERNAL_URL,
|
||||||
title: 'Example',
|
title: 'Example',
|
||||||
description: 'A description',
|
description: 'A description',
|
||||||
thumb: undefined,
|
thumb: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('addUri routes outcomes', () => {
|
describe('addUri pre-classifies bsky post URLs to the quote slot', () => {
|
||||||
test('post → quote (with view), embed cleared', async () => {
|
test('post URL → quote.pending → quote.resolved (with view)', async () => {
|
||||||
const d = deferred<ResolvedLink>()
|
const d = deferred<ResolvedLink>()
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://bsky.app/post')
|
store.actions.addUri(root, POST_URL)
|
||||||
expect(store.getState().posts[root].embed?.state).toBe('pending')
|
|
||||||
|
const pending = store.getState().posts[root].quote
|
||||||
|
if (pending?.state !== 'pending') throw new Error('expected pending')
|
||||||
|
expect(pending.uri).toBe(POST_URL)
|
||||||
|
expect(store.getState().posts[root].embed).toBeUndefined()
|
||||||
|
|
||||||
d.resolve(postLink)
|
d.resolve(postLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const post = store.getState().posts[root]
|
const quote = store.getState().posts[root].quote
|
||||||
expect(post.embed).toBeUndefined()
|
if (quote?.state !== 'resolved') throw new Error('expected resolved')
|
||||||
expect(post.quote).toEqual({
|
expect(quote.uri).toBe('at://post')
|
||||||
uri: 'at://post',
|
expect(quote.cid).toBe('cp')
|
||||||
cid: 'cp',
|
expect(quote.view).toBe(
|
||||||
view: postLink.kind === 'post' ? postLink.view : undefined,
|
postLink.kind === 'post' ? postLink.view : undefined,
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('addUri is a no-op when quote is already set', () => {
|
||||||
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.setQuoteEmbed(root, {uri: 'at://existing', cid: 'cx'})
|
||||||
|
const before = store.getState()
|
||||||
|
store.actions.addUri(root, POST_URL)
|
||||||
|
expect(store.getState()).toBe(before)
|
||||||
|
expect(mockResolveLink).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('post URL with media still routes to quote (orthogonal to media)', async () => {
|
||||||
|
const d = deferred<ResolvedLink>()
|
||||||
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.addMedia(root, [
|
||||||
|
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
|
||||||
|
])
|
||||||
|
store.actions.addUri(root, POST_URL)
|
||||||
|
d.resolve(postLink)
|
||||||
|
await flushPromises()
|
||||||
|
expect(store.getState().posts[root].quote?.state).toBe('resolved')
|
||||||
|
expect(store.getState().posts[root].media).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('post resolution failure produces quote.failed with bound retry()', async () => {
|
||||||
|
const d1 = deferred<ResolvedLink>()
|
||||||
|
const d2 = deferred<ResolvedLink>()
|
||||||
|
mockResolveLink
|
||||||
|
.mockReturnValueOnce(d1.promise)
|
||||||
|
.mockReturnValueOnce(d2.promise)
|
||||||
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.addUri(root, POST_URL)
|
||||||
|
d1.reject(new Error('post deleted'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const failed = store.getState().posts[root].quote
|
||||||
|
if (failed?.state !== 'failed') throw new Error('expected failed')
|
||||||
|
expect(failed.error).toContain('post deleted')
|
||||||
|
expect(typeof failed.retry).toBe('function')
|
||||||
|
|
||||||
|
failed.retry()
|
||||||
|
expect(store.getState().posts[root].quote?.state).toBe('pending')
|
||||||
|
|
||||||
|
d2.resolve(postLink)
|
||||||
|
await flushPromises()
|
||||||
|
expect(store.getState().posts[root].quote?.state).toBe('resolved')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addUri pre-classifies non-post URLs to the embed slot', () => {
|
||||||
test('feed → embed.feed', async () => {
|
test('feed → embed.feed', async () => {
|
||||||
const d = deferred<ResolvedLink>()
|
const d = deferred<ResolvedLink>()
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://bsky.app/feed')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
|
expect(store.getState().posts[root].embed?.state).toBe('pending')
|
||||||
d.resolve(feedLink)
|
d.resolve(feedLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.getState().posts[root].embed?.state).toBe('feed')
|
expect(store.getState().posts[root].embed?.state).toBe('feed')
|
||||||
@@ -142,7 +199,7 @@ describe('addUri routes outcomes', () => {
|
|||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://bsky.app/list')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
d.resolve(listLink)
|
d.resolve(listLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.getState().posts[root].embed?.state).toBe('list')
|
expect(store.getState().posts[root].embed?.state).toBe('list')
|
||||||
@@ -153,7 +210,7 @@ describe('addUri routes outcomes', () => {
|
|||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://bsky.app/sp')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
d.resolve(starterPackLink)
|
d.resolve(starterPackLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.getState().posts[root].embed?.state).toBe('starter-pack')
|
expect(store.getState().posts[root].embed?.state).toBe('starter-pack')
|
||||||
@@ -164,7 +221,7 @@ describe('addUri routes outcomes', () => {
|
|||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://example.com')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
d.resolve(externalLink)
|
d.resolve(externalLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const embed = store.getState().posts[root].embed
|
const embed = store.getState().posts[root].embed
|
||||||
@@ -172,55 +229,46 @@ describe('addUri routes outcomes', () => {
|
|||||||
expect(embed.title).toBe('Example')
|
expect(embed.title).toBe('Example')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('post outcome dropped when quote is already set', async () => {
|
test('addUri is a no-op when embed has settled (resolved)', async () => {
|
||||||
const d = deferred<ResolvedLink>()
|
const d = deferred<ResolvedLink>()
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.setQuoteEmbed(root, {uri: 'at://existing', cid: 'cx'})
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
store.actions.addUri(root, 'https://bsky.app/post')
|
|
||||||
d.resolve(postLink)
|
|
||||||
await flushPromises()
|
|
||||||
expect(store.getState().posts[root].quote).toEqual({
|
|
||||||
uri: 'at://existing',
|
|
||||||
cid: 'cx',
|
|
||||||
})
|
|
||||||
expect(store.getState().posts[root].embed).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('non-post outcome with media present is silently dropped', async () => {
|
|
||||||
const d = deferred<ResolvedLink>()
|
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
|
||||||
const store = makeStore()
|
|
||||||
const root = rootId(store)
|
|
||||||
store.actions.addMedia(root, [
|
|
||||||
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
|
|
||||||
])
|
|
||||||
store.actions.addUri(root, 'https://example.com')
|
|
||||||
d.resolve(externalLink)
|
d.resolve(externalLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.getState().posts[root].embed).toBeUndefined()
|
expect(store.getState().posts[root].embed?.state).toBe('external')
|
||||||
expect(store.getState().posts[root].media).toHaveLength(1)
|
|
||||||
|
store.actions.addUri(root, 'https://other.example')
|
||||||
|
// Settled slot blocks the second addUri.
|
||||||
|
expect(mockResolveLink).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.getState().posts[root].embed?.state).toBe('external')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('post outcome with media present routes to quote', async () => {
|
test('addUri replaces a pending embed (e.g. user pastes a different URL)', () => {
|
||||||
const d = deferred<ResolvedLink>()
|
mockResolveLink.mockReturnValue(new Promise(() => {}))
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
|
store.actions.addUri(root, 'https://other.example')
|
||||||
|
expect(mockResolveLink).toHaveBeenCalledTimes(2)
|
||||||
|
const embed = store.getState().posts[root].embed
|
||||||
|
if (embed?.state !== 'pending') throw new Error('expected pending')
|
||||||
|
expect(embed.uri).toBe('https://other.example')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addUri is a no-op when media is set (target is embed)', () => {
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addMedia(root, [
|
store.actions.addMedia(root, [
|
||||||
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
|
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
|
||||||
])
|
])
|
||||||
store.actions.addUri(root, 'https://bsky.app/post')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
d.resolve(postLink)
|
expect(store.getState().posts[root].embed).toBeUndefined()
|
||||||
await flushPromises()
|
expect(mockResolveLink).not.toHaveBeenCalled()
|
||||||
expect(store.getState().posts[root].quote?.uri).toBe('at://post')
|
|
||||||
expect(store.getState().posts[root].media).toHaveLength(1)
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('addUri failure and retry', () => {
|
test('embed resolution failure produces embed.failed with bound retry()', async () => {
|
||||||
test('rejection produces a failed embed with a bound retry()', async () => {
|
|
||||||
const d1 = deferred<ResolvedLink>()
|
const d1 = deferred<ResolvedLink>()
|
||||||
const d2 = deferred<ResolvedLink>()
|
const d2 = deferred<ResolvedLink>()
|
||||||
mockResolveLink
|
mockResolveLink
|
||||||
@@ -228,7 +276,7 @@ describe('addUri failure and retry', () => {
|
|||||||
.mockReturnValueOnce(d2.promise)
|
.mockReturnValueOnce(d2.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://example.com')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
d1.reject(new Error('network down'))
|
d1.reject(new Error('network down'))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -245,35 +293,42 @@ describe('addUri failure and retry', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('addUri cancellation', () => {
|
describe('addUri cancellation by ignoring stale responses', () => {
|
||||||
test('a second addUri invalidates the first response', async () => {
|
|
||||||
const d1 = deferred<ResolvedLink>()
|
|
||||||
const d2 = deferred<ResolvedLink>()
|
|
||||||
mockResolveLink
|
|
||||||
.mockReturnValueOnce(d1.promise)
|
|
||||||
.mockReturnValueOnce(d2.promise)
|
|
||||||
const store = makeStore()
|
|
||||||
const root = rootId(store)
|
|
||||||
store.actions.addUri(root, 'https://a.example')
|
|
||||||
store.actions.addUri(root, 'https://b.example')
|
|
||||||
d1.resolve(externalLink)
|
|
||||||
await flushPromises()
|
|
||||||
const pending = store.getState().posts[root].embed
|
|
||||||
if (pending?.state !== 'pending') throw new Error('expected pending')
|
|
||||||
expect(pending.uri).toBe('https://b.example')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('removeEmbed before the response lands keeps embed undefined', async () => {
|
test('removeEmbed before the response lands keeps embed undefined', async () => {
|
||||||
const d = deferred<ResolvedLink>()
|
const d = deferred<ResolvedLink>()
|
||||||
mockResolveLink.mockReturnValueOnce(d.promise)
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.addUri(root, 'https://example.com')
|
store.actions.addUri(root, EXTERNAL_URL)
|
||||||
store.actions.removeEmbed(root)
|
store.actions.removeEmbed(root)
|
||||||
d.resolve(externalLink)
|
d.resolve(externalLink)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.getState().posts[root].embed).toBeUndefined()
|
expect(store.getState().posts[root].embed).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('removeQuoteEmbed before the response lands keeps quote undefined', async () => {
|
||||||
|
const d = deferred<ResolvedLink>()
|
||||||
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.addUri(root, POST_URL)
|
||||||
|
store.actions.removeQuoteEmbed(root)
|
||||||
|
d.resolve(postLink)
|
||||||
|
await flushPromises()
|
||||||
|
expect(store.getState().posts[root].quote).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeEmbed does not invalidate an in-flight quote resolution', async () => {
|
||||||
|
const d = deferred<ResolvedLink>()
|
||||||
|
mockResolveLink.mockReturnValueOnce(d.promise)
|
||||||
|
const store = makeStore()
|
||||||
|
const root = rootId(store)
|
||||||
|
store.actions.addUri(root, POST_URL)
|
||||||
|
store.actions.removeEmbed(root) // unrelated slot
|
||||||
|
d.resolve(postLink)
|
||||||
|
await flushPromises()
|
||||||
|
expect(store.getState().posts[root].quote?.state).toBe('resolved')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setQuoteEmbed / removeQuoteEmbed', () => {
|
describe('setQuoteEmbed / removeQuoteEmbed', () => {
|
||||||
@@ -281,10 +336,10 @@ describe('setQuoteEmbed / removeQuoteEmbed', () => {
|
|||||||
const store = makeStore()
|
const store = makeStore()
|
||||||
const root = rootId(store)
|
const root = rootId(store)
|
||||||
store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'})
|
store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'})
|
||||||
expect(store.getState().posts[root].quote).toEqual({
|
const quote = store.getState().posts[root].quote
|
||||||
uri: 'at://x',
|
if (quote?.state !== 'resolved') throw new Error('expected resolved')
|
||||||
cid: 'c',
|
expect(quote.uri).toBe('at://x')
|
||||||
})
|
expect(quote.cid).toBe('c')
|
||||||
expect(store.getState().isDirty).toBe(true)
|
expect(store.getState().isDirty).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {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 {type resolveLink} from '#/lib/api/resolve'
|
||||||
import {startUriResolution} from '#/components/ComposerV2/store/linkResolution'
|
import {
|
||||||
|
type LinkResolutionOutcome,
|
||||||
|
startUriResolution,
|
||||||
|
} from '#/components/ComposerV2/store/linkResolution'
|
||||||
import type * as types from '#/components/ComposerV2/store/types'
|
import type * as types from '#/components/ComposerV2/store/types'
|
||||||
import {
|
import {
|
||||||
startImageUpload,
|
startImageUpload,
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
} from '#/components/ComposerV2/store/uploads'
|
} from '#/components/ComposerV2/store/uploads'
|
||||||
import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem'
|
import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem'
|
||||||
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
|
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
|
||||||
|
import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUriTarget'
|
||||||
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
|
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
|
||||||
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
|
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
|
||||||
|
|
||||||
@@ -43,17 +47,18 @@ export function createThreadStore(options: {
|
|||||||
const uploadTasks = new Map<string, UploadTask>()
|
const uploadTasks = new Map<string, UploadTask>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generation counter per post for embed link resolution. Cancellation is
|
* Per-slot generation counters. Quote and embed are orthogonal slots, so
|
||||||
* implemented by ignoring stale resolution callbacks: every action that
|
* each has its own counter; cancelling one doesn't invalidate the other.
|
||||||
* starts or invalidates a resolution (addUri, removeEmbed, removePost,
|
* Every action that starts or invalidates a resolution for a slot bumps
|
||||||
* destroy) bumps the post's gen, and the worker callback compares its
|
* that slot's gen, and the worker callback compares its captured gen
|
||||||
* captured gen against the current value before writing to state.
|
* against the current value before writing to state.
|
||||||
*/
|
*/
|
||||||
|
const quoteGenByPost = new Map<string, number>()
|
||||||
const embedGenByPost = new Map<string, number>()
|
const embedGenByPost = new Map<string, number>()
|
||||||
|
|
||||||
function bumpEmbedGen(postId: string): number {
|
function bumpGen(map: Map<string, number>, postId: string): number {
|
||||||
const next = (embedGenByPost.get(postId) ?? 0) + 1
|
const next = (map.get(postId) ?? 0) + 1
|
||||||
embedGenByPost.set(postId, next)
|
map.set(postId, next)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +140,11 @@ export function createThreadStore(options: {
|
|||||||
if (!(postId in s.posts)) return null
|
if (!(postId in s.posts)) return null
|
||||||
// Cancel any in-flight uploads for media on this post before dropping it.
|
// Cancel any in-flight uploads for media on this post before dropping it.
|
||||||
for (const m of s.posts[postId].media) cancelUploadTask(m.id)
|
for (const m of s.posts[postId].media) cancelUploadTask(m.id)
|
||||||
// Bump (and drop) the embed gen so a stale resolution callback for
|
// Bump (and drop) both gens so any stale resolution callbacks for
|
||||||
// this post can never write back into state.
|
// this post can never write back into state.
|
||||||
bumpEmbedGen(postId)
|
bumpGen(quoteGenByPost, postId)
|
||||||
|
bumpGen(embedGenByPost, postId)
|
||||||
|
quoteGenByPost.delete(postId)
|
||||||
embedGenByPost.delete(postId)
|
embedGenByPost.delete(postId)
|
||||||
delete s.posts[postId]
|
delete s.posts[postId]
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
@@ -284,26 +291,64 @@ export function createThreadStore(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic URI handler. Sets `embed` to `pending` synchronously, kicks off
|
* Generic URI handler. Pre-classifies the URI from its URL pattern to
|
||||||
* `resolveLink`, and routes the outcome:
|
* decide which slot the eventual data will land in:
|
||||||
* - Bluesky post -> goes to the post's `quote` field (clears embed). If
|
|
||||||
* `quote` is already set, the new outcome is dropped silently to
|
|
||||||
* preserve the user's prior selection.
|
|
||||||
* - Feed / list / starter-pack / external -> stays on `embed`, unless the
|
|
||||||
* post has media in which case it's dropped silently (embed cleared).
|
|
||||||
* - Failure -> embed is set to a `failed` state with a bound `retry()` that
|
|
||||||
* re-runs `addUri` for the same URI.
|
|
||||||
*
|
*
|
||||||
* Cancellation is gen-based: any later addUri / removeEmbed / removePost /
|
* - Bluesky post URL -> `quote` slot (coexists with media).
|
||||||
* destroy invalidates this call's outcome before it can land.
|
* - Anything else (feed / list / starter-pack / external) -> `embed` slot
|
||||||
|
* (mutually exclusive with media).
|
||||||
|
*
|
||||||
|
* Conflict checks happen synchronously based on the target slot:
|
||||||
|
* - If targeting quote and quote is already set -> no-op (preserves prior).
|
||||||
|
* - If targeting embed and embed is already set -> no-op.
|
||||||
|
* - If targeting embed and media is set -> no-op.
|
||||||
|
*
|
||||||
|
* Otherwise, pending state is written to the target slot synchronously and
|
||||||
|
* `resolveLink` runs in the background. The outcome lands in the same slot
|
||||||
|
* (resolved or failed). Cancellation is per-slot gen-based.
|
||||||
*/
|
*/
|
||||||
function addUri(postId: string, uri: string) {
|
function addUri(postId: string, uri: string) {
|
||||||
if (!(postId in state.posts)) return
|
const post = state.posts[postId]
|
||||||
const gen = bumpEmbedGen(postId)
|
if (!post) return
|
||||||
|
|
||||||
|
const target = classifyUriTarget(uri)
|
||||||
|
|
||||||
|
if (target === 'quote') {
|
||||||
|
// No-op only when the slot has a settled value. Pending and failed
|
||||||
|
// states are replaceable (failed.retry() relies on this).
|
||||||
|
if (post.quote?.state === 'resolved') return
|
||||||
|
const gen = bumpGen(quoteGenByPost, postId)
|
||||||
|
mutateState(s => {
|
||||||
|
const p = s.posts[postId]
|
||||||
|
if (!p) return null
|
||||||
|
s.posts[postId] = setPostQuote(p, {state: 'pending', uri})
|
||||||
|
s.isDirty = true
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
startUriResolution({
|
||||||
|
postId,
|
||||||
|
uri,
|
||||||
|
resolveLink: resolveLinkOverride,
|
||||||
|
onResolve: handleQuoteResolution(gen, uri),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// target === 'embed'
|
||||||
|
// Same rule as quote: settled values block; pending / failed are
|
||||||
|
// replaceable (so retry() works on a failed embed).
|
||||||
|
const embedSettled =
|
||||||
|
post.embed !== undefined &&
|
||||||
|
post.embed.state !== 'pending' &&
|
||||||
|
post.embed.state !== 'failed'
|
||||||
|
if (embedSettled) return
|
||||||
|
if (post.media.length > 0) return
|
||||||
|
|
||||||
|
const gen = bumpGen(embedGenByPost, postId)
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const p = s.posts[postId]
|
||||||
if (!post) return null
|
if (!p) return null
|
||||||
s.posts[postId] = setPostEmbed(post, {state: 'pending', uri})
|
s.posts[postId] = setPostEmbed(p, {state: 'pending', uri})
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
@@ -311,51 +356,75 @@ export function createThreadStore(options: {
|
|||||||
postId,
|
postId,
|
||||||
uri,
|
uri,
|
||||||
resolveLink: resolveLinkOverride,
|
resolveLink: resolveLinkOverride,
|
||||||
onResolve: handleEmbedResolution(gen),
|
onResolve: handleEmbedResolution(gen, uri),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEmbedResolution(gen: number) {
|
function handleQuoteResolution(gen: number, uri: string) {
|
||||||
return (postId: string, outcome: types.LinkResolutionOutcome) => {
|
return (postId: string, outcome: LinkResolutionOutcome) => {
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
if (embedGenByPost.get(postId) !== gen) return
|
if (quoteGenByPost.get(postId) !== gen) return
|
||||||
if (outcome.kind === 'post') {
|
|
||||||
mutateState(s => {
|
// Pre-classification said this was a post URL. If resolveLink disagrees
|
||||||
const post = s.posts[postId]
|
// (deleted post, embedding disabled, network error, etc.), surface as
|
||||||
if (!post) return null
|
// failed in the quote slot.
|
||||||
if (post.quote !== undefined) {
|
if (outcome.kind !== 'post') {
|
||||||
// Quote already set: drop the post outcome, just clear pending.
|
const error =
|
||||||
s.posts[postId] = setPostEmbed(post, undefined)
|
outcome.embed.state === 'failed'
|
||||||
return s
|
? outcome.embed.error
|
||||||
}
|
: 'Could not resolve post'
|
||||||
s.posts[postId] = setPostEmbed(
|
const failed: types.PostEmbedQuote = {
|
||||||
{
|
state: 'failed',
|
||||||
...post,
|
uri,
|
||||||
quote: {
|
error,
|
||||||
uri: outcome.record.uri,
|
retry: () => addUri(postId, uri),
|
||||||
cid: outcome.record.cid,
|
}
|
||||||
view: outcome.view,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// outcome.kind === 'embed'
|
|
||||||
const post = state.posts[postId]
|
|
||||||
if (!post) return
|
|
||||||
if (post.media.length > 0) {
|
|
||||||
// Embed-vs-media collision: silent drop, clear pending.
|
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const p = s.posts[postId]
|
const p = s.posts[postId]
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
s.posts[postId] = setPostEmbed(p, undefined)
|
s.posts[postId] = setPostQuote(p, failed)
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
return
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmbedResolution(gen: number, uri: string) {
|
||||||
|
return (postId: string, outcome: LinkResolutionOutcome) => {
|
||||||
|
if (destroyed) return
|
||||||
|
if (embedGenByPost.get(postId) !== gen) return
|
||||||
|
|
||||||
|
// Pre-classification said this was a non-post URL. If resolveLink
|
||||||
|
// surprises us with a post outcome, treat as failure rather than
|
||||||
|
// silently moving slots.
|
||||||
|
if (outcome.kind === 'post') {
|
||||||
|
const failed: types.PostEmbed = {
|
||||||
|
state: 'failed',
|
||||||
|
uri,
|
||||||
|
error: 'Unexpected post outcome for non-post URL',
|
||||||
|
retry: () => addUri(postId, uri),
|
||||||
|
}
|
||||||
|
mutateState(s => {
|
||||||
|
const p = s.posts[postId]
|
||||||
|
if (!p) return null
|
||||||
|
s.posts[postId] = setPostEmbed(p, failed)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const embed = outcome.embed
|
const embed = outcome.embed
|
||||||
const stored: types.PostEmbed =
|
const stored: types.PostEmbed =
|
||||||
embed.state === 'failed'
|
embed.state === 'failed'
|
||||||
@@ -371,7 +440,7 @@ export function createThreadStore(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeEmbed(postId: string) {
|
function removeEmbed(postId: string) {
|
||||||
bumpEmbedGen(postId)
|
bumpGen(embedGenByPost, postId)
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
if (!post) return null
|
||||||
@@ -382,22 +451,37 @@ export function createThreadStore(options: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setQuoteEmbed(postId: string, quote: types.PostEmbedQuote) {
|
/**
|
||||||
|
* Direct setter for an already-resolved quote. Used for draft restore and
|
||||||
|
* any UI flow that already has the post ref+view in hand. Bumps the quote
|
||||||
|
* gen so any in-flight resolution is invalidated.
|
||||||
|
*/
|
||||||
|
function setQuoteEmbed(
|
||||||
|
postId: string,
|
||||||
|
ref: {uri: string; cid: string; view?: AppBskyFeedDefs.PostView},
|
||||||
|
) {
|
||||||
|
bumpGen(quoteGenByPost, postId)
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
if (!post) return null
|
||||||
s.posts[postId] = {...post, quote}
|
s.posts[postId] = setPostQuote(post, {
|
||||||
|
state: 'resolved',
|
||||||
|
uri: ref.uri,
|
||||||
|
cid: ref.cid,
|
||||||
|
view: ref.view,
|
||||||
|
})
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeQuoteEmbed(postId: string) {
|
function removeQuoteEmbed(postId: string) {
|
||||||
|
bumpGen(quoteGenByPost, postId)
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
if (!post) return null
|
||||||
if (post.quote === undefined) return null
|
if (post.quote === undefined) return null
|
||||||
s.posts[postId] = {...post, quote: undefined}
|
s.posts[postId] = setPostQuote(post, undefined)
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
@@ -479,6 +563,17 @@ export function createThreadStore(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single chokepoint for replacing a post's quote slot. Quote is
|
||||||
|
* orthogonal to media so no selectionsRemaining recomputation is needed.
|
||||||
|
*/
|
||||||
|
function setPostQuote(
|
||||||
|
post: types.ThreadPost,
|
||||||
|
quote: types.PostEmbedQuote | undefined,
|
||||||
|
): types.ThreadPost {
|
||||||
|
return {...post, quote}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actions: {
|
actions: {
|
||||||
setPostText,
|
setPostText,
|
||||||
@@ -500,6 +595,7 @@ export function createThreadStore(options: {
|
|||||||
destroyed = true
|
destroyed = true
|
||||||
for (const task of uploadTasks.values()) task.cancel()
|
for (const task of uploadTasks.values()) task.cancel()
|
||||||
uploadTasks.clear()
|
uploadTasks.clear()
|
||||||
|
quoteGenByPost.clear()
|
||||||
embedGenByPost.clear()
|
embedGenByPost.clear()
|
||||||
},
|
},
|
||||||
getState() {
|
getState() {
|
||||||
|
|||||||
@@ -11,13 +11,62 @@
|
|||||||
* The `__resolveLink` test seam (passed through from createThreadStore)
|
* The `__resolveLink` test seam (passed through from createThreadStore)
|
||||||
* lets tests inject a controllable promise.
|
* lets tests inject a controllable promise.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
type AppBskyGraphDefs,
|
||||||
|
type ComAtprotoRepoStrongRef,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ResolvedLink,
|
type ResolvedLink,
|
||||||
type resolveLink as defaultResolveLink,
|
type resolveLink as defaultResolveLink,
|
||||||
} from '#/lib/api/resolve'
|
} from '#/lib/api/resolve'
|
||||||
import {resolveLink as importedResolveLink} 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 {createPublicAgent} from '#/state/session/agent'
|
||||||
import {type LinkResolutionOutcome} 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}
|
||||||
|
| {
|
||||||
|
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 = {
|
export type StartUriResolutionOptions = {
|
||||||
postId: string
|
postId: string
|
||||||
|
|||||||
@@ -83,47 +83,16 @@ export type PostEmbedMedia =
|
|||||||
| (PostEmbedMediaGif & {kind: 'gif'})
|
| (PostEmbedMediaGif & {kind: 'gif'})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a link-resolution reporter (the worker, or a test) sends in. Failed
|
* What's stored on a post's `embed` field. The failed variant has a bound
|
||||||
* inputs carry just the error string; the store wraps the failure with a
|
* `retry()` so UI can call it directly without having to look up the post
|
||||||
* bound `retry()` method when it stores the status.
|
* id. The pending variant is set synchronously by addUri while resolution
|
||||||
*
|
* is in flight; the resolved variants (external/feed/list/starter-pack)
|
||||||
* `pending` is included so the store can construct the initial pending state
|
* land when the worker reports back.
|
||||||
* with the same type vocabulary, but the worker never emits `pending` - it
|
|
||||||
* only emits terminal outcomes (the post-resolution variants and `failed`).
|
|
||||||
*/
|
|
||||||
export type EmbedResolution =
|
|
||||||
| {state: 'pending'; uri: string}
|
|
||||||
| {state: 'failed'; uri: string; error: string}
|
|
||||||
| {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What's stored on a post's `embed` field. Mirrors PostMediaUploadStatus'
|
|
||||||
* shape: the failed variant has a bound `retry()` so UI can call it directly
|
|
||||||
* without having to look up the post id.
|
|
||||||
*
|
*
|
||||||
* Note: `retry` is a function reference and won't survive JSON serialization.
|
* Note: `retry` is a function reference and won't survive JSON serialization.
|
||||||
* On restore (OS-resume / draft load), the store re-attaches it.
|
* On restore (OS-resume / draft load), the store re-attaches it.
|
||||||
|
*
|
||||||
|
* (Worker-side input and outcome types live in linkResolution.ts.)
|
||||||
*/
|
*/
|
||||||
export type PostEmbed =
|
export type PostEmbed =
|
||||||
| {state: 'pending'; uri: string}
|
| {state: 'pending'; uri: string}
|
||||||
@@ -152,23 +121,24 @@ export type PostEmbed =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker output for an `addUri` call. The store routes `kind: 'post'` to the
|
* What's stored on a post's `quote` field. Mirrors `PostEmbed`'s shape: the
|
||||||
* post's `quote` field and everything else to the `embed` field.
|
* pending variant is set synchronously by addUri while the post is being
|
||||||
|
* resolved; the resolved variant lands when the worker reports back; the
|
||||||
|
* failed variant carries a bound `retry()`.
|
||||||
|
*
|
||||||
|
* `view` is optional on the resolved variant because `setQuoteEmbed` (used
|
||||||
|
* for direct programmatic insertion, e.g. draft restore) may not have a
|
||||||
|
* hydrated post view to hand.
|
||||||
*/
|
*/
|
||||||
export type LinkResolutionOutcome =
|
export type PostEmbedQuote =
|
||||||
|
| {state: 'pending'; uri: string}
|
||||||
|
| {state: 'failed'; uri: string; error: string; retry: () => void}
|
||||||
| {
|
| {
|
||||||
kind: 'post'
|
state: 'resolved'
|
||||||
record: ComAtprotoRepoStrongRef.Main
|
uri: string
|
||||||
view: AppBskyFeedDefs.PostView
|
cid: string
|
||||||
|
view?: AppBskyFeedDefs.PostView
|
||||||
}
|
}
|
||||||
| {kind: 'embed'; embed: Exclude<EmbedResolution, {state: 'pending'}>}
|
|
||||||
|
|
||||||
export type PostEmbedQuote = {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
/** Hydrated post view; populated when addUri resolves a post. */
|
|
||||||
view?: AppBskyFeedDefs.PostView
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThreadPost = {
|
export type ThreadPost = {
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which slot on a post a given URI is destined for. Bluesky post URLs go to
|
||||||
|
* the `quote` slot; everything else (feed/list/starter-pack records and
|
||||||
|
* generic external links) goes to the `embed` slot.
|
||||||
|
*
|
||||||
|
* Used by addUri to set the pending state on the correct slot synchronously,
|
||||||
|
* surface conflict no-ops upfront, and avoid a "data moves between slots"
|
||||||
|
* race after async resolution.
|
||||||
|
*/
|
||||||
|
export type EmbedTargetSlot = 'quote' | 'embed'
|
||||||
|
|
||||||
|
export function classifyUriTarget(uri: string): EmbedTargetSlot {
|
||||||
|
if (isBskyPostUrl(uri)) return 'quote'
|
||||||
|
return 'embed'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user