Add debug-composer route

This commit is contained in:
Eric Bailey
2026-05-12 09:15:33 -05:00
parent e245d760cf
commit 54438cd26e
11 changed files with 319 additions and 3 deletions
+6
View File
@@ -55,6 +55,7 @@ import {
import {useCloseAllActiveElements} from '#/state/util'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugComposerScreen} from '#/view/screens/DebugComposer'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="DebugComposer"
getComponent={() => DebugComposerScreen}
options={{title: title(msg`Composer V2`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
@@ -0,0 +1,31 @@
import {createContext, useContext} from 'react'
import {type createThreadStore} from '#/components/ComposerV2/store'
export type ThreadStore = ReturnType<typeof createThreadStore>
const ThreadStoreContext = createContext<ThreadStore | null>(null)
export function ThreadStoreProvider({
store,
children,
}: {
store: ThreadStore
children: React.ReactNode
}) {
return (
<ThreadStoreContext.Provider value={store}>
{children}
</ThreadStoreContext.Provider>
)
}
export function useThreadStore(): ThreadStore {
const store = useContext(ThreadStoreContext)
if (!store) {
throw new Error(
'useThreadStore must be used inside a <ThreadStoreProvider>',
)
}
return store
}
+8
View File
@@ -0,0 +1,8 @@
export {
type ThreadStore,
ThreadStoreProvider,
useThreadStore,
} from '#/components/ComposerV2/hooks/ThreadStoreContext'
export {useThreadPost} from '#/components/ComposerV2/hooks/useThreadPost'
export {useThreadPostRichText} from '#/components/ComposerV2/hooks/useThreadPostRichText'
export {useThreadState} from '#/components/ComposerV2/hooks/useThreadState'
@@ -0,0 +1,18 @@
import {useSyncExternalStore} from 'react'
import {useThreadStore} from '#/components/ComposerV2/hooks/ThreadStoreContext'
import {type ThreadPost} from '#/components/ComposerV2/store/types'
/**
* Subscribe to a single post by id. Returns undefined if no such post
* exists. Rerenders only when this specific post's reference changes -
* unrelated post mutations don't propagate here because the store's
* actions spread `{...post, ...}` only on the touched post.
*/
export function useThreadPost(postId: string): ThreadPost | undefined {
const store = useThreadStore()
return useSyncExternalStore(
store.subscribe,
() => store.getState().posts[postId],
)
}
@@ -0,0 +1,30 @@
import {useMemo} from 'react'
import {RichText} from '@atproto/api'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {useThreadPost} from '#/components/ComposerV2/hooks/useThreadPost'
/**
* Derived RichText for a post's text. Recomputes only when the underlying
* `text` changes (not on unrelated mutations like alt-text or upload
* status). detectFacetsWithoutResolution is regex-only, so this is cheap
* to run per-keystroke for typical post lengths.
*
* `shortenedGraphemeLength` matches the value the existing draft adapter
* stores - the count after URL shortening, used for the post char limit.
*/
export function useThreadPostRichText(postId: string): {
richtext: RichText
shortenedGraphemeLength: number
} {
const post = useThreadPost(postId)
const text = post?.text ?? ''
return useMemo(() => {
const richtext = new RichText({text})
richtext.detectFacetsWithoutResolution()
return {
richtext,
shortenedGraphemeLength: shortenLinks(richtext).graphemeLength,
}
}, [text])
}
@@ -0,0 +1,13 @@
import {useSyncExternalStore} from 'react'
import {useThreadStore} from '#/components/ComposerV2/hooks/ThreadStoreContext'
import {type ThreadState} from '#/components/ComposerV2/store/types'
/**
* Subscribe to the full thread state. Rerenders whenever any post changes.
* For finer-grained subscriptions use `useThreadPost(id)`.
*/
export function useThreadState(): ThreadState {
const store = useThreadStore()
return useSyncExternalStore(store.subscribe, store.getState)
}
+6 -3
View File
@@ -62,14 +62,17 @@ export function createThreadStore(options: {
/**
* Action bodies mutate `s` in place. Returning `null` signals a no-op (the
* state ref is preserved and listeners are not notified). Otherwise we
* shallow-clone the top-level object so getState() returns a new reference,
* which is what useSyncExternalStore needs to trigger a rerender.
* shallow-clone both the top-level state and the inner `posts` object so
* any consumer of either reference sees a fresh value. (Some actions
* mutate `s.posts` in place, e.g. removePost's `delete s.posts[id]`;
* cloning posts here means selectors and React Compiler memoization can
* use ref equality reliably.)
*/
function mutateState(fn: (s: types.ThreadState) => types.ThreadState | null) {
if (destroyed) return
const next = fn(state)
if (next === null) return
state = {...next}
state = {...next, posts: {...next.posts}}
for (const listener of listeners) listener()
}
+1
View File
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
ProfileLabelerLikedBy: {name: string}
Debug: undefined
DebugMod: undefined
DebugComposer: undefined
SharedPreferencesTester: undefined
Log: undefined
Support: undefined
+1
View File
@@ -39,6 +39,7 @@ export const router = new Router<AllNavigatableRoutes>({
// debug
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
DebugComposer: '/sys/debug-composer',
Log: '/sys/log',
// settings
LanguageSettings: '/settings/language',
@@ -0,0 +1,181 @@
/**
* Minimal end-to-end playground for the ComposerV2 store. Wires a single
* Composer text input to the store's root post, surfaces the live state
* as a monospace dump, and exposes a few action buttons so we can poke at
* the surface (addPost, removeEmbed, removeQuote) without building the
* full UI yet.
*
* Useful for verifying:
* - useSyncExternalStore actually rerenders on store mutations
* - onFacetCommitted -> addUri produces the expected pending/resolved/
* failed embed or quote states
* - addUri validation (embedding-disabled, embed/media exclusion) shows
* up the way we expect
*/
import {useMemo, useState} from 'react'
import {ScrollView, View} from 'react-native'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Composer} from '#/components/Composer'
import {
ThreadStoreProvider,
useThreadPost,
useThreadPostRichText,
useThreadState,
useThreadStore,
} from '#/components/ComposerV2/hooks'
import {createThreadStore} from '#/components/ComposerV2/store'
import {Text} from '#/components/Typography'
export default function DebugComposer() {
const agent = useAgent()
const [store] = useState(() => createThreadStore({agent}))
return (
<ThreadStoreProvider store={store}>
<View style={[a.p_md, a.gap_md]}>
<PostList />
<Toolbar />
<StateDump />
</View>
</ThreadStoreProvider>
)
}
function PostList() {
const state = useThreadState()
const postIds = Object.keys(state.posts)
return (
<View style={[a.gap_md]}>
{postIds.map((postId, i) => (
<PostRow key={postId} postId={postId} index={i} />
))}
</View>
)
}
function PostRow({postId, index}: {postId: string; index: number}) {
const post = useThreadPost(postId)
const store = useThreadStore()
const t = useTheme()
// Composer is uncontrolled - defaultValue is read once on mount and the
// input owns its text after that. We mirror every change back into the
// store via onChange.
const initialText = useMemo(() => post?.text ?? '', [])
if (!post) return null
return (
<View
style={[
a.p_sm,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
a.gap_sm,
]}>
<View style={[a.flex_row, a.justify_between, a.align_center]}>
<Text style={[a.text_xs, {fontFamily: 'monospace'}]}>
[{index}] {postId}
</Text>
{index > 0 && (
<Button
label={`Remove post ${index}`}
size="tiny"
color="secondary"
onPress={() => store.actions.removePost(postId)}>
<ButtonText>x</ButtonText>
</Button>
)}
</View>
<Composer
label="Post text"
placeholder="What's up? Paste a URL to test addUri."
defaultValue={initialText}
onChange={text => store.actions.setPostText(postId, text)}
onFacetCommitted={facet => {
if (facet.type === 'url') {
store.actions.addUri(postId, facet.value)
}
}}
/>
<PostFooter postId={postId} />
</View>
)
}
function PostFooter({postId}: {postId: string}) {
const {shortenedGraphemeLength} = useThreadPostRichText(postId)
const store = useThreadStore()
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_sm, a.align_center]}>
<Text style={[a.text_xs, {fontFamily: 'monospace'}]}>
graphemes: {shortenedGraphemeLength} / 300
</Text>
<Button
label="Remove embed"
size="tiny"
color="secondary"
onPress={() => store.actions.removeEmbed(postId)}>
<ButtonText>- embed</ButtonText>
</Button>
<Button
label="Remove quote"
size="tiny"
color="secondary"
onPress={() => store.actions.removeQuoteEmbed(postId)}>
<ButtonText>- quote</ButtonText>
</Button>
</View>
)
}
function Toolbar() {
const store = useThreadStore()
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
<Button
label="Append post"
size="small"
color="secondary"
onPress={() => {
// Read live state at click time so we always append after the
// current last post (instead of capturing a stale id at render).
const ids = Object.keys(store.getState().posts)
const lastId = ids[ids.length - 1]
if (lastId) store.actions.addPost('after', lastId)
}}>
<ButtonText>+ post</ButtonText>
</Button>
</View>
)
}
function StateDump() {
const state = useThreadState()
const t = useTheme()
// Functions (e.g. retry on failed states) are dropped by JSON.stringify;
// BlobRef and AppBskyFeedDefs view types serialize as plain objects.
const dump = useMemo(() => JSON.stringify(state, null, 2), [state])
return (
<View
style={[
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_md]}>
<Text style={[a.text_xs, {fontFamily: 'monospace'}]}>{dump}</Text>
</ScrollView>
</View>
)
}
+24
View File
@@ -0,0 +1,24 @@
import {lazy, Suspense} from 'react'
import * as Layout from '#/components/Layout'
const DebugComposer = lazy(() => import('./DebugComposer'))
export function DebugComposerScreen() {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>Composer V2</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content keyboardShouldPersistTaps="handled">
<Suspense fallback={null}>
<DebugComposer />
</Suspense>
</Layout.Content>
</Layout.Screen>
)
}