Add debug-composer route
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
|
||||
ProfileLabelerLikedBy: {name: string}
|
||||
Debug: undefined
|
||||
DebugMod: undefined
|
||||
DebugComposer: undefined
|
||||
SharedPreferencesTester: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user