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 (
-
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' }),