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
83 changes: 83 additions & 0 deletions __tests__/unit/hooks/useContentEditableEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* The composer must empty itself after a post — including when it still has
* focus.
*
* This is the bug that made Ctrl+Enter look broken in production. The shortcut
* worked and the post was created; the composer simply kept showing the text,
* because the effect that syncs `content` into the contentEditable bailed out
* whenever the editor was the active element — which it always is right after
* you press a key in it. So the natural response was to press again, and the
* server rejected that as a duplicate ("You just posted this"), which looked
* like a second failure.
*
* Clicking the button never showed it: a click moves focus to the button first,
* so the guard did not apply. A shortcut and a button that disagree about
* whether the composer clears is the shape worth pinning here.
*/

import { renderHook, act } from '@testing-library/react';
import { useContentEditableEditor } from '@/hooks/useContentEditableEditor';

jest.mock('@/utils/markdownEditor', () => ({
markdownToHtml: (md: string) => md,
htmlToMarkdown: (html: string) => html,
getSelectionRange: () => ({ start: 0, end: 0 }),
setSelectionRange: jest.fn(),
}));

/**
* Mount the hook and give it a real element to own. `tabIndex` is what makes a
* div focusable in jsdom — without it `focus()` is a no-op and the guard under
* test would never engage, so the test would pass for the wrong reason.
*/
function mount(initial: string) {
const editor = document.createElement('div');
editor.contentEditable = 'true';
editor.tabIndex = 0;
document.body.appendChild(editor);

const view = renderHook(
({ content }: { content: string }) =>
useContentEditableEditor({ content, onContentChange: jest.fn() }),
{ initialProps: { content: initial } }
);

act(() => {
view.result.current.editorRef.current = editor;
});

return { editor, rerender: view.rerender };
}

describe('useContentEditableEditor content sync', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('clears the editor when content is reset while it still has focus', () => {
const { editor, rerender } = mount('a draft');
editor.innerHTML = 'a draft';
editor.focus();
// Guard the guard: if focus did not take, this test proves nothing.
expect(document.activeElement).toBe(editor);

// What a successful post does: content state goes back to empty.
act(() => rerender({ content: '' }));

expect(editor.textContent?.trim()).toBe('');
});

it('still refuses to overwrite text being typed while focused', () => {
// The relaxed guard exists for a real reason, and only the empty case is
// exempt — an external change to non-empty text must not clobber a
// half-written post.
const { editor, rerender } = mount('');
editor.innerHTML = 'what I am typing';
editor.focus();
expect(document.activeElement).toBe(editor);

act(() => rerender({ content: 'something the app decided' }));

expect(editor.textContent).toBe('what I am typing');
});
});
43 changes: 43 additions & 0 deletions scripts/check-data-invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,48 @@ async function checkCatHandle() {
notes.push(`cat: @${CAT_HANDLE} resolves to the Cat`);
}

/**
* Functions that reference a column, table or type that does not exist.
*
* Nineteen of them on 2026-08-28, silently, for months: likes, dislikes,
* replies, deleting a post and quote replies were all dead in production, along
* with four AI-withdrawal functions and both nearby searches. Every one looked
* healthy — defined, routable, called by the app — because plpgsql only plans a
* statement when it runs, so a write to a missing column raises 42703 at call
* time and never before.
*
* Nothing else in the stack sees this. Unit tests mock the database;
* check-rpc-exists proves a function is DEFINED, which all of these were;
* migration replay proves the SQL applies, and creating a function never
* validates its body.
*
* A ratchet rather than a demand for zero: eleven remain after the timeline
* ones were repaired, and each of those needs a decision rather than a
* mechanical edit (does `ai_creator_withdrawals` want a `completed_at` column,
* or should the write go?). Demanding zero tomorrow would make this red about
* work that is queued, which is how a gate teaches people to ignore it.
* `SELECT * FROM list_broken_plpgsql_functions()` names them.
*/
const BROKEN_FUNCTION_BASELINE = 11;

async function checkBrokenFunctions() {
const count = Number(await rpc('count_broken_plpgsql_functions'));

if (count > BROKEN_FUNCTION_BASELINE) {
violation(
'functions.reference_missing_objects',
`${count} plpgsql function(s) reference something that does not exist, up from ` +
`${BROKEN_FUNCTION_BASELINE}. A new one will fail only when a user triggers it, with ` +
`42703 and no other symptom — run list_broken_plpgsql_functions() to see which`,
[]
);
} else {
notes.push(
`functions: ${count} reference a missing object (baseline ${BROKEN_FUNCTION_BASELINE}, never rises)`
);
}
}

async function checkOrphanedProfiles() {
const count = Number(await rpc('count_orphaned_profiles'));

Expand Down Expand Up @@ -516,6 +558,7 @@ async function main() {
checkOrphanedProfiles,
checkEmailDerivedUsernames,
checkCatHandle,
checkBrokenFunctions,
checkOrphanedCatConversations,
checkOrphanedActors,
];
Expand Down
57 changes: 31 additions & 26 deletions src/components/timeline/EditPostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,29 +118,15 @@ export function EditPostModal({

{/* Modal */}
<div className="relative w-full max-w-xl rounded-md border border-subtle bg-surface-page shadow-sm animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-subtle px-4 py-3">
{/* Header — dismissal only. The action that commits the edit lives at
the end of the form, next to the character count that says whether
it is allowed, rather than diagonally opposite it. */}
<div className="flex items-center gap-3 border-b border-subtle px-4 py-3">
<button onClick={onClose} className={TIMELINE_SURFACE.iconButton} aria-label="Close">
<X className="w-5 h-5" />
</button>

<h2 className="text-lg font-semibold text-fg-primary">Edit post</h2>

<Button
onClick={handleSave}
disabled={isSaving || !hasChanges || isOverLimit || !content.trim()}
size="sm"
className={TIMELINE_SURFACE.buttonPrimary}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{TIMELINE_COPY.savingButton}
</>
) : (
'Save'
)}
</Button>
</div>

{/* Content */}
Expand Down Expand Up @@ -194,14 +180,33 @@ export function EditPostModal({
</button>
</div>

{/* Character count */}
<div
className={cn(
'text-sm',
isOverLimit ? 'text-status-negative font-medium' : 'text-fg-tertiary'
)}
>
{charCount.toLocaleString()} / {maxChars.toLocaleString()}
{/* Character count and the commit action, in reading order: what
you have written, then whether you may save it, then Save. */}
<div className="flex items-center gap-3">
<div
className={cn(
'text-sm tabular-nums',
isOverLimit ? 'text-status-negative font-medium' : 'text-fg-tertiary'
)}
>
{charCount.toLocaleString()} / {maxChars.toLocaleString()}
</div>

<Button
onClick={handleSave}
disabled={isSaving || !hasChanges || isOverLimit || !content.trim()}
size="sm"
className={TIMELINE_SURFACE.buttonPrimary}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{TIMELINE_COPY.savingButton}
</>
) : (
'Save'
)}
</Button>
</div>
</div>
</div>
Expand Down
Loading
Loading