Add debug-composer route
This commit is contained in:
@@ -55,6 +55,7 @@ import {
|
|||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
|
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
|
||||||
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
|
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
|
||||||
|
import {DebugComposerScreen} from '#/view/screens/DebugComposer'
|
||||||
import {DebugModScreen} from '#/view/screens/DebugMod'
|
import {DebugModScreen} from '#/view/screens/DebugMod'
|
||||||
import {FeedsScreen} from '#/view/screens/Feeds'
|
import {FeedsScreen} from '#/view/screens/Feeds'
|
||||||
import {HomeScreen} from '#/view/screens/Home'
|
import {HomeScreen} from '#/view/screens/Home'
|
||||||
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
getComponent={() => DebugModScreen}
|
getComponent={() => DebugModScreen}
|
||||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="DebugComposer"
|
||||||
|
getComponent={() => DebugComposerScreen}
|
||||||
|
options={{title: title(msg`Composer V2`), requireAuth: true}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="SharedPreferencesTester"
|
name="SharedPreferencesTester"
|
||||||
getComponent={() => SharedPreferencesTesterScreen}
|
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
|
* Action bodies mutate `s` in place. Returning `null` signals a no-op (the
|
||||||
* state ref is preserved and listeners are not notified). Otherwise we
|
* state ref is preserved and listeners are not notified). Otherwise we
|
||||||
* shallow-clone the top-level object so getState() returns a new reference,
|
* shallow-clone both the top-level state and the inner `posts` object so
|
||||||
* which is what useSyncExternalStore needs to trigger a rerender.
|
* 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) {
|
function mutateState(fn: (s: types.ThreadState) => types.ThreadState | null) {
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
const next = fn(state)
|
const next = fn(state)
|
||||||
if (next === null) return
|
if (next === null) return
|
||||||
state = {...next}
|
state = {...next, posts: {...next.posts}}
|
||||||
for (const listener of listeners) listener()
|
for (const listener of listeners) listener()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
|
|||||||
ProfileLabelerLikedBy: {name: string}
|
ProfileLabelerLikedBy: {name: string}
|
||||||
Debug: undefined
|
Debug: undefined
|
||||||
DebugMod: undefined
|
DebugMod: undefined
|
||||||
|
DebugComposer: undefined
|
||||||
SharedPreferencesTester: undefined
|
SharedPreferencesTester: undefined
|
||||||
Log: undefined
|
Log: undefined
|
||||||
Support: undefined
|
Support: undefined
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const router = new Router<AllNavigatableRoutes>({
|
|||||||
// debug
|
// debug
|
||||||
Debug: '/sys/debug',
|
Debug: '/sys/debug',
|
||||||
DebugMod: '/sys/debug-mod',
|
DebugMod: '/sys/debug-mod',
|
||||||
|
DebugComposer: '/sys/debug-composer',
|
||||||
Log: '/sys/log',
|
Log: '/sys/log',
|
||||||
// settings
|
// settings
|
||||||
LanguageSettings: '/settings/language',
|
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