Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions __tests__/unit/components/timeline-composer-tools-on-focus.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<button type="button" aria-label="Write with AI">
Write with AI
</button>
),
}));
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(<TimelineComposer />);

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(<TimelineComposer />);

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(<TimelineComposer />);

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(<TimelineComposer />);

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(<TimelineComposer />);

// Never focused, but there is something to act on.
expect(screen.getByLabelText(AI_BUTTON)).toBeInTheDocument();
});
});
8 changes: 8 additions & 0 deletions __tests__/unit/services/timeline-time-ago.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe('getTimeAgo', () => {
expect(out).toMatch(/\d/);
});

it('writes the date in English, whatever the browser locale is', () => {
// The app ships <html lang="en"> 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);
Expand Down
171 changes: 106 additions & 65 deletions src/components/timeline/TimelineComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn('mx-auto max-w-2xl transition-colors', TIMELINE_SURFACE.composer)}>
<div
className={cn('mx-auto max-w-2xl transition-colors', TIMELINE_SURFACE.composer)}
onFocus={() => setFocused(true)}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocused(false);
}
}}
>
<div className="flex gap-3">
<div className="pt-0.5 sm:pt-1 flex-shrink-0">
<AvatarLink
Expand Down Expand Up @@ -230,88 +265,94 @@ const TimelineComposer = React.memo(function TimelineComposer({

<div className="mt-4 flex items-center justify-between border-t border-subtle pt-3">
<div className="flex flex-wrap items-center gap-2">
{!parentEventId && (
{toolsExpanded && !parentEventId && (
<PostAiButton onDraft={postComposer.setContent} disabled={postComposer.isPosting} />
)}
{parentEventId && parentPostText && (
{toolsExpanded && parentEventId && parentPostText && (
<ReplyAiButton
parentText={parentPostText}
parentAuthor={parentAuthorName}
onDraft={postComposer.setContent}
disabled={postComposer.isPosting}
/>
)}
{postComposer.content.trim() && (
{toolsExpanded && postComposer.content.trim() && (
<PostAiEditMenu
text={postComposer.content}
onRevised={postComposer.setContent}
disabled={postComposer.isPosting}
/>
)}
<ComposerImageChip
active={composerImage.showPicker || Boolean(postComposer.image)}
disabled={postComposer.isPosting}
onToggle={composerImage.togglePicker}
/>
{!simpleMode && <TextFormatToolbar onFormat={handleFormat} />}

{!simpleMode && allowProjectSelection && postComposer.userProjects.length > 0 && (
<ProjectToggleButton
showProjects={showProjects}
selectedCount={postComposer.selectedProjects.length}
onToggle={showProjects ? handleCloseProjects : handleOpenProjects}
{toolsExpanded && (
<ComposerImageChip
active={composerImage.showPicker || Boolean(postComposer.image)}
disabled={postComposer.isPosting}
onToggle={composerImage.togglePicker}
/>
)}
{toolsExpanded && !simpleMode && <TextFormatToolbar onFormat={handleFormat} />}

{simpleMode ? (
<div className="flex items-center gap-2">
{TIMELINE_VISIBILITY_OPTIONS.map(preset => {
const Icon = preset.Icon;
const isActive = postComposer.visibility === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => postComposer.setVisibility(preset.key)}
disabled={postComposer.isPosting}
className={cn(
TIMELINE_SURFACE.chip,
isActive && TIMELINE_SURFACE.chipActive
)}
title={preset.description}
>
<span className="inline-flex items-center gap-1">
<Icon className="w-4 h-4" />
{preset.label}
</span>
</button>
);
})}
</div>
) : (
<button
type="button"
onClick={() =>
postComposer.setVisibility(
postComposer.visibility === 'public' ? 'private' : 'public'
)
}
disabled={postComposer.isPosting}
className={TIMELINE_SURFACE.iconButton}
title={
postComposer.visibility === 'public'
? 'Public - Everyone can see'
: 'Private - Only you can see'
}
aria-label={`Post visibility: ${postComposer.visibility}`}
>
{postComposer.visibility === 'public' ? (
<Globe className="w-4 h-4" />
) : (
<Lock className="w-4 h-4" />
)}
</button>
)}
{toolsExpanded &&
!simpleMode &&
allowProjectSelection &&
postComposer.userProjects.length > 0 && (
<ProjectToggleButton
showProjects={showProjects}
selectedCount={postComposer.selectedProjects.length}
onToggle={showProjects ? handleCloseProjects : handleOpenProjects}
/>
)}

{toolsExpanded &&
(simpleMode ? (
<div className="flex items-center gap-2">
{TIMELINE_VISIBILITY_OPTIONS.map(preset => {
const Icon = preset.Icon;
const isActive = postComposer.visibility === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => postComposer.setVisibility(preset.key)}
disabled={postComposer.isPosting}
className={cn(
TIMELINE_SURFACE.chip,
isActive && TIMELINE_SURFACE.chipActive
)}
title={preset.description}
>
<span className="inline-flex items-center gap-1">
<Icon className="w-4 h-4" />
{preset.label}
</span>
</button>
);
})}
</div>
) : (
<button
type="button"
onClick={() =>
postComposer.setVisibility(
postComposer.visibility === 'public' ? 'private' : 'public'
)
}
disabled={postComposer.isPosting}
className={TIMELINE_SURFACE.iconButton}
title={
postComposer.visibility === 'public'
? 'Public - Everyone can see'
: 'Private - Only you can see'
}
aria-label={`Post visibility: ${postComposer.visibility}`}
>
{postComposer.visibility === 'public' ? (
<Globe className="w-4 h-4" />
) : (
<Lock className="w-4 h-4" />
)}
</button>
))}
</div>

<div className="flex items-center gap-3">
Expand Down
8 changes: 7 additions & 1 deletion src/services/timeline/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,14 @@ export function getTimeAgo(timestamp: string): string {
return `${diffDays}d`;
}

// Pinned to en-US, matching <html lang="en">. 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' }),
Expand Down
Loading