diff --git a/src/Navigation.tsx b/src/Navigation.tsx index efd1125295..ec9c1c4c77 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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}} /> + DebugComposerScreen} + options={{title: title(msg`Composer V2`), requireAuth: true}} + /> SharedPreferencesTesterScreen} diff --git a/src/components/ComposerV2/hooks/ThreadStoreContext.tsx b/src/components/ComposerV2/hooks/ThreadStoreContext.tsx new file mode 100644 index 0000000000..4c1ff58ec0 --- /dev/null +++ b/src/components/ComposerV2/hooks/ThreadStoreContext.tsx @@ -0,0 +1,31 @@ +import {createContext, useContext} from 'react' + +import {type createThreadStore} from '#/components/ComposerV2/store' + +export type ThreadStore = ReturnType + +const ThreadStoreContext = createContext(null) + +export function ThreadStoreProvider({ + store, + children, +}: { + store: ThreadStore + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function useThreadStore(): ThreadStore { + const store = useContext(ThreadStoreContext) + if (!store) { + throw new Error( + 'useThreadStore must be used inside a ', + ) + } + return store +} diff --git a/src/components/ComposerV2/hooks/index.ts b/src/components/ComposerV2/hooks/index.ts new file mode 100644 index 0000000000..f1a72c0cb9 --- /dev/null +++ b/src/components/ComposerV2/hooks/index.ts @@ -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' diff --git a/src/components/ComposerV2/hooks/useThreadPost.ts b/src/components/ComposerV2/hooks/useThreadPost.ts new file mode 100644 index 0000000000..6dc85ec266 --- /dev/null +++ b/src/components/ComposerV2/hooks/useThreadPost.ts @@ -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], + ) +} diff --git a/src/components/ComposerV2/hooks/useThreadPostRichText.ts b/src/components/ComposerV2/hooks/useThreadPostRichText.ts new file mode 100644 index 0000000000..90f03d15e9 --- /dev/null +++ b/src/components/ComposerV2/hooks/useThreadPostRichText.ts @@ -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]) +} diff --git a/src/components/ComposerV2/hooks/useThreadState.ts b/src/components/ComposerV2/hooks/useThreadState.ts new file mode 100644 index 0000000000..9fd295a08b --- /dev/null +++ b/src/components/ComposerV2/hooks/useThreadState.ts @@ -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) +} diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts index 626a1b8fa5..d454b54a85 100644 --- a/src/components/ComposerV2/store/index.ts +++ b/src/components/ComposerV2/store/index.ts @@ -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() } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 22bb50572a..a0d9b32de0 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -34,6 +34,7 @@ export type CommonNavigatorParams = { ProfileLabelerLikedBy: {name: string} Debug: undefined DebugMod: undefined + DebugComposer: undefined SharedPreferencesTester: undefined Log: undefined Support: undefined diff --git a/src/routes.ts b/src/routes.ts index f7e73ff2c2..bb60612d90 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -39,6 +39,7 @@ export const router = new Router({ // debug Debug: '/sys/debug', DebugMod: '/sys/debug-mod', + DebugComposer: '/sys/debug-composer', Log: '/sys/log', // settings LanguageSettings: '/settings/language', diff --git a/src/view/screens/DebugComposer/DebugComposer.tsx b/src/view/screens/DebugComposer/DebugComposer.tsx new file mode 100644 index 0000000000..8c57a4793b --- /dev/null +++ b/src/view/screens/DebugComposer/DebugComposer.tsx @@ -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 ( + + + + + + + + ) +} + +function PostList() { + const state = useThreadState() + const postIds = Object.keys(state.posts) + return ( + + {postIds.map((postId, i) => ( + + ))} + + ) +} + +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 ( + + + + [{index}] {postId} + + {index > 0 && ( + + )} + + store.actions.setPostText(postId, text)} + onFacetCommitted={facet => { + if (facet.type === 'url') { + store.actions.addUri(postId, facet.value) + } + }} + /> + + + ) +} + +function PostFooter({postId}: {postId: string}) { + const {shortenedGraphemeLength} = useThreadPostRichText(postId) + const store = useThreadStore() + return ( + + + graphemes: {shortenedGraphemeLength} / 300 + + + + + ) +} + +function Toolbar() { + const store = useThreadStore() + return ( + + + + ) +} + +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 ( + + + {dump} + + + ) +} diff --git a/src/view/screens/DebugComposer/index.tsx b/src/view/screens/DebugComposer/index.tsx new file mode 100644 index 0000000000..d8a633adf1 --- /dev/null +++ b/src/view/screens/DebugComposer/index.tsx @@ -0,0 +1,24 @@ +import {lazy, Suspense} from 'react' + +import * as Layout from '#/components/Layout' + +const DebugComposer = lazy(() => import('./DebugComposer')) + +export function DebugComposerScreen() { + return ( + + + + + Composer V2 + + + + + + + + + + ) +}