diff --git a/__tests__/unit/components/timeline-composer-tools-on-focus.test.tsx b/__tests__/unit/components/timeline-composer-tools-on-focus.test.tsx new file mode 100644 index 000000000..5a5adc975 --- /dev/null +++ b/__tests__/unit/components/timeline-composer-tools-on-focus.test.tsx @@ -0,0 +1,137 @@ +/** + * The composer's tools appear when you compose, and survive being clicked. + * + * At rest the composer showed six controls around an empty box — an AI + * drafter, an image picker and three visibility chips — all of them for a post + * nobody had written yet. They now appear on focus. + * + * The second test is the one that matters. The standard way this pattern + * breaks is that focus leaves the editor on mousedown, the toolbar unmounts + * before the click lands, and the button silently does nothing — a bug that + * looks like "the AI button is broken" and never appears in a render-only + * test. The component checks `relatedTarget` on blur to stay mounted while + * focus moves inside itself; this asserts that it does. + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import TimelineComposer from '@/components/timeline/TimelineComposer'; + +jest.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'a@b.c', user_metadata: {} }, + profile: { username: 'mao', name: 'Mao', avatar_url: null }, + }), +})); + +const composerState = { + content: '', + setContent: jest.fn(), + isPosting: false, + handlePost: jest.fn(), + visibility: 'public' as const, + setVisibility: jest.fn(), + image: null, + setImage: jest.fn(), + error: null, + postSuccess: false, + userProjects: [], + selectedProjects: [], + toggleProjectSelection: jest.fn(), +}; + +jest.mock('@/hooks/usePostComposerNew', () => ({ + usePostComposer: () => composerState, +})); + +jest.mock('@/hooks/useContentEditableEditor', () => ({ + useContentEditableEditor: () => ({ + editorRef: { current: null }, + handleInput: jest.fn(), + handlePaste: jest.fn(), + handleKeyDown: jest.fn(), + handleFormat: jest.fn(), + }), +})); + +jest.mock('@/components/mentions/useContentEditableMentions', () => ({ + useContentEditableMentions: () => ({ editorProps: {}, menuProps: { suggestions: [] } }), +})); + +jest.mock('@/components/mentions/MentionSuggestions', () => ({ + __esModule: true, + default: () => null, +})); + +// The AI drafter stands in for "a toolbar button": focusing it must not +// unmount the toolbar it lives in. +jest.mock('@/components/timeline/PostAiButton', () => ({ + __esModule: true, + default: () => ( + + ), +})); +jest.mock('@/components/timeline/ReplyAiButton', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/timeline/PostAiEditMenu', () => ({ __esModule: true, default: () => null })); + +const AI_BUTTON = /write with ai/i; + +describe('composer tools appear on focus', () => { + beforeEach(() => { + composerState.content = ''; + composerState.image = null; + }); + + it('stays out of the way until the composer is focused', () => { + render(); + + expect(screen.queryByLabelText(AI_BUTTON)).not.toBeInTheDocument(); + + // The primary action is never hidden behind a focus. + expect(screen.getByRole('button', { name: /share update|post/i })).toBeInTheDocument(); + }); + + it('reveals the tools once focused', () => { + render(); + + fireEvent.focus(screen.getByLabelText('Compose new post')); + + expect(screen.getByLabelText(AI_BUTTON)).toBeInTheDocument(); + }); + + it('does NOT collapse when focus moves from the editor to a tool', () => { + render(); + + const editor = screen.getByLabelText('Compose new post'); + fireEvent.focus(editor); + const aiButton = screen.getByLabelText(AI_BUTTON); + + // Focus leaving the editor FOR the button — relatedTarget is inside the + // composer, so the toolbar must survive. Without the relatedTarget check + // the button unmounts here and the click never lands. + fireEvent.blur(editor, { relatedTarget: aiButton }); + + expect(screen.getByLabelText(AI_BUTTON)).toBeInTheDocument(); + }); + + it('collapses when focus leaves the composer entirely', () => { + render(); + + const editor = screen.getByLabelText('Compose new post'); + fireEvent.focus(editor); + expect(screen.getByLabelText(AI_BUTTON)).toBeInTheDocument(); + + fireEvent.blur(editor, { relatedTarget: document.body }); + + expect(screen.queryByLabelText(AI_BUTTON)).not.toBeInTheDocument(); + }); + + it('stays open while there is a draft, focused or not', () => { + composerState.content = 'half a thought'; + render(); + + // Never focused, but there is something to act on. + expect(screen.getByLabelText(AI_BUTTON)).toBeInTheDocument(); + }); +}); diff --git a/__tests__/unit/services/timeline-time-ago.test.ts b/__tests__/unit/services/timeline-time-ago.test.ts index c24099bbc..559367331 100644 --- a/__tests__/unit/services/timeline-time-ago.test.ts +++ b/__tests__/unit/services/timeline-time-ago.test.ts @@ -52,6 +52,14 @@ describe('getTimeAgo', () => { expect(out).toMatch(/\d/); }); + it('writes the date in English, whatever the browser locale is', () => { + // The app ships and no translations. Taking the browser's + // locale rendered "22. Juli" next to a "1d" in the same metadata line for + // anyone on a non-English system. + const out = getTimeAgo(at(30 * DAY)); + expect(out).toMatch(/^[A-Z][a-z]{2} \d{1,2}$/); + }); + it('keeps the year on older posts so they cannot read as recent', () => { const twoYearsAgo = new Date(); twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); diff --git a/src/components/timeline/TimelineComposer.tsx b/src/components/timeline/TimelineComposer.tsx index 531d08c91..3c11d5ee7 100644 --- a/src/components/timeline/TimelineComposer.tsx +++ b/src/components/timeline/TimelineComposer.tsx @@ -163,8 +163,43 @@ const TimelineComposer = React.memo(function TimelineComposer({ [postComposer.content, postComposer.isPosting] ); + /** + * The composer's tools appear when you start composing. + * + * At rest it used to show six controls around an empty box — an AI drafter, + * an image picker, a formatting toolbar, a project selector and three + * visibility chips — which is more chrome than content and buries the first + * post further down the feed. Every one of them is for a post you have not + * written yet. + * + * Expanded means: focused, or there is something to act on (text, an image, + * an open panel). It deliberately does NOT collapse the moment focus leaves + * the editor — `onBlur` is checked against `relatedTarget` so moving focus + * from the editor to a toolbar button keeps the toolbar mounted. Without + * that check the button unmounts between mousedown and click and the press + * silently does nothing, which is the standard way this pattern breaks. + * + * The submit button stays visible at all times, disabled until there is + * something to post, so the primary action is never hidden behind a focus. + */ + const [focused, setFocused] = useState(false); + const toolsExpanded = + focused || + Boolean(postComposer.content.trim()) || + Boolean(postComposer.image) || + showProjects || + composerImage.showPicker; + return ( -
+
setFocused(true)} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocused(false); + } + }} + >
- {!parentEventId && ( + {toolsExpanded && !parentEventId && ( )} - {parentEventId && parentPostText && ( + {toolsExpanded && parentEventId && parentPostText && ( )} - {postComposer.content.trim() && ( + {toolsExpanded && postComposer.content.trim() && ( )} - - {!simpleMode && } - - {!simpleMode && allowProjectSelection && postComposer.userProjects.length > 0 && ( - )} + {toolsExpanded && !simpleMode && } - {simpleMode ? ( -
- {TIMELINE_VISIBILITY_OPTIONS.map(preset => { - const Icon = preset.Icon; - const isActive = postComposer.visibility === preset.key; - return ( - - ); - })} -
- ) : ( - - )} + {toolsExpanded && + !simpleMode && + allowProjectSelection && + postComposer.userProjects.length > 0 && ( + + )} + + {toolsExpanded && + (simpleMode ? ( +
+ {TIMELINE_VISIBILITY_OPTIONS.map(preset => { + const Icon = preset.Icon; + const isActive = postComposer.visibility === preset.key; + return ( + + ); + })} +
+ ) : ( + + ))}
diff --git a/src/services/timeline/formatters/index.ts b/src/services/timeline/formatters/index.ts index 041484f27..1d4e1fddd 100644 --- a/src/services/timeline/formatters/index.ts +++ b/src/services/timeline/formatters/index.ts @@ -214,8 +214,14 @@ export function getTimeAgo(timestamp: string): string { return `${diffDays}d`; } + // Pinned to en-US, matching . Passing `undefined` here takes + // the BROWSER's locale, which rendered "22. Juli" inside an otherwise + // entirely English interface for anyone with a non-English system — a post + // dated in one language next to a "1d" in another. The app ships no + // translations; when it does, this should follow the app's locale, not the + // browser's, for exactly the same reason. const sameYear = eventTime.getFullYear() === now.getFullYear(); - return eventTime.toLocaleDateString(undefined, { + return eventTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }),