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
61 changes: 61 additions & 0 deletions __tests__/unit/components/cat-reply-badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* A reply written by the agent has to say so.
*
* `is_cat_reply: true` was stamped onto every Cat reply, under a comment saying
* it was "marked so the UI can render a Cat reply distinctly rather than
* leaving a reader to work out from the avatar that this one was written by an
* agent". Nothing read it — two writers, zero readers — so an answer from the
* Cat looked exactly like an answer from a person, unless you happened to
* recognise the handle.
*
* That is the difference that matters when copying how @grok works: the model's
* replies are visibly the model's. Attribution of machine-written text to a
* human reader is not a styling detail.
*/

import { render, screen } from '@testing-library/react';
import { PostHeader } from '@/components/timeline/PostHeader';
import type { TimelineDisplayEvent } from '@/types/timeline';

jest.mock('next/link', () => ({
__esModule: true,
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));

function postBy(
username: string,
extra: Partial<TimelineDisplayEvent> = {}
): TimelineDisplayEvent {
return {
id: 'e1',
actor: { id: 'a1', name: username === 'cat' ? 'Cat' : 'A Person', username, type: 'user' },
eventTimestamp: new Date().toISOString(),
...extra,
} as unknown as TimelineDisplayEvent;
}

describe('a Cat reply is labelled', () => {
it('marks a post authored by the Cat', () => {
render(<PostHeader event={postBy('cat')} />);
expect(screen.getByText('AI')).toBeInTheDocument();
});

it('marks a reply flagged as the Cat even if attribution changes', () => {
render(<PostHeader event={postBy('someone', { metadata: { is_cat_reply: true } })} />);
expect(screen.getByText('AI')).toBeInTheDocument();
});

it('leaves an ordinary person unmarked', () => {
render(<PostHeader event={postBy('mao')} />);
expect(screen.queryByText('AI')).not.toBeInTheDocument();
});

it('is not fooled by a handle that merely starts with cat', () => {
// `catalogue` must not be badged as the platform agent — the same rule the
// mention menu applies when deciding what @cat means.
render(<PostHeader event={postBy('catalogue')} />);
expect(screen.queryByText('AI')).not.toBeInTheDocument();
});
});
138 changes: 138 additions & 0 deletions __tests__/unit/hooks/usePostLikeDislike.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Liking and disliking are mutually exclusive, and the UI has to say so.
*
* The RPCs delete the opposing row when you switch sides. The client did not
* model that: `handleLike` only ever wrote `userLiked`/`likesCount`, so after
* liking a post and then disliking it, BOTH buttons rendered as active and the
* like count kept a number with no row behind it.
*
* Observed in production 2026-08-28 on orangecat.ch/timeline: like → dislike
* left `aria-pressed="true"` on both, and `timeline_event_stats` held
* `like_count: 1, dislike_count: 1` for an event with no like row at all.
*/

import { renderHook, act } from '@testing-library/react';
import { usePostLikeDislike } from '@/hooks/usePostLikeDislike';
import type { TimelineDisplayEvent } from '@/types/timeline';

const toggleLike = jest.fn();
const toggleDislike = jest.fn();
jest.mock('@/services/timeline', () => ({
timelineService: {
toggleLike: (...a: unknown[]) => toggleLike(...a),
toggleDislike: (...a: unknown[]) => toggleDislike(...a),
},
}));

function eventWith(overrides: Partial<TimelineDisplayEvent>): TimelineDisplayEvent {
return {
id: 'e1',
userLiked: false,
likesCount: 0,
userDisliked: false,
dislikesCount: 0,
...overrides,
} as TimelineDisplayEvent;
}

describe('liking and disliking are exclusive', () => {
beforeEach(() => jest.clearAllMocks());

it('clears the like when you dislike a post you had liked', async () => {
const onUpdate = jest.fn();
toggleDislike.mockResolvedValue({
success: true,
disliked: true,
dislikeCount: 1,
liked: false,
likeCount: 0,
});

const { result } = renderHook(() =>
usePostLikeDislike({
event: eventWith({ userLiked: true, likesCount: 1 }),
onUpdate,
})
);

await act(async () => {
await result.current.handleDislike();
});

// Optimistic pass: the like goes immediately, not after the round trip.
expect(onUpdate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ userDisliked: true, userLiked: false, likesCount: 0 })
);
// Server pass: both totals come from the response.
expect(onUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ userDisliked: true, dislikesCount: 1, userLiked: false, likesCount: 0 })
);
});

it('clears the dislike when you like a post you had disliked', async () => {
const onUpdate = jest.fn();
toggleLike.mockResolvedValue({
success: true,
liked: true,
likeCount: 1,
disliked: false,
dislikeCount: 0,
});

const { result } = renderHook(() =>
usePostLikeDislike({
event: eventWith({ userDisliked: true, dislikesCount: 1 }),
onUpdate,
})
);

await act(async () => {
await result.current.handleLike();
});

expect(onUpdate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ userLiked: true, userDisliked: false, dislikesCount: 0 })
);
});

it('leaves the opposite alone when simply un-liking', async () => {
const onUpdate = jest.fn();
toggleLike.mockResolvedValue({ success: true, liked: false, likeCount: 0, dislikeCount: 0 });

const { result } = renderHook(() =>
usePostLikeDislike({ event: eventWith({ userLiked: true, likesCount: 1 }), onUpdate })
);

await act(async () => {
await result.current.handleLike();
});

// Un-liking retracts nothing, so `disliked` is absent from the response and
// must not be invented here.
const optimistic = onUpdate.mock.calls[0][0];
expect(optimistic).not.toHaveProperty('userDisliked');
});

it('restores both sides when the server refuses', async () => {
const onUpdate = jest.fn();
toggleDislike.mockResolvedValue({ success: false, disliked: false, dislikeCount: 0 });

const { result } = renderHook(() =>
usePostLikeDislike({
event: eventWith({ userLiked: true, likesCount: 1 }),
onUpdate,
})
);

await act(async () => {
await result.current.handleDislike();
});

// A failed switch must put the like back, not leave the post with neither.
expect(onUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ userDisliked: false, userLiked: true, likesCount: 1 })
);
});
});
6 changes: 5 additions & 1 deletion __tests__/unit/services/reaction-count.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ describe('reaction counts', () => {
it('reads the count out of the array PostgREST returns for RETURNS TABLE', async () => {
rpc.mockResolvedValue({ data: [{ like_count: 7 }], error: null });

await expect(toggleLike('event-1', 'user-1')).resolves.toEqual({
// toMatchObject, not toEqual: the response also carries the OPPOSITE
// reaction's state now, because a like retracts a dislike. This test is
// about reading the count out of PostgREST's array shape, not about the
// full result envelope.
await expect(toggleLike('event-1', 'user-1')).resolves.toMatchObject({
success: true,
liked: true,
likeCount: 7,
Expand Down
21 changes: 21 additions & 0 deletions src/components/timeline/PostHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { MoreHorizontal, Lock, Users, Pencil, Trash2 } from 'lucide-react';
import { TimelineDisplayEvent } from '@/types/timeline';
import { formatRelativeTime } from '@/utils/dates';
import { TIMELINE_SURFACE } from '@/config/timeline';
import { CAT_USERNAME } from '@/config/cat-identity';
import { normalizeUsername } from '@/config/usernames';

interface PostHeaderProps {
event: TimelineDisplayEvent;
Expand Down Expand Up @@ -67,6 +69,10 @@ export function PostHeader({
avatar: rawAuthor?.avatar,
};

const isCatAuthored =
normalizeUsername(displayAuthor.username || '') === normalizeUsername(CAT_USERNAME) ||
event.metadata?.is_cat_reply === true;

// TimelineDisplayEvent extends TimelineEvent which has eventTimestamp, createdAt, updatedAt
// Use eventTimestamp as primary, fallback to createdAt for backward compatibility
const timestamp = event.eventTimestamp || event.createdAt;
Expand All @@ -84,6 +90,21 @@ export function PostHeader({
{displayAuthor.name}
</Link>

{/* Written by the platform's agent, said out loud.
`is_cat_reply` was already being written onto every Cat reply, with a
comment saying it existed "so the UI can render a Cat reply
distinctly" — and nothing read it. Two writers, no reader, so a Cat
answer was indistinguishable from a person's unless you recognised the
handle.
The author is the primary signal because it is the fact that matters
(this was written by the agent); the metadata flag is honoured too so
a reply stays marked even if it is ever re-attributed. */}
{isCatAuthored && (
<span className="flex-shrink-0 rounded-sm bg-accent-warm px-1.5 py-0.5 text-2xs font-medium uppercase tracking-caps text-on-accent">
AI
</span>
)}

<Link
href={`/profiles/${displayAuthor.username}`}
className="text-fg-secondary text-sm"
Expand Down
77 changes: 69 additions & 8 deletions src/hooks/usePostLikeDislike.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,55 @@ export function usePostLikeDislike({ event, onUpdate }: Props): UsePostLikeDisli
const nextLiked = !originalLiked;
const nextCount = Math.max(0, originalCount + (nextLiked ? 1 : -1));

onUpdate({ userLiked: nextLiked, likesCount: nextCount });
// Liking retracts a dislike server-side, so the optimistic update has to
// clear it too — otherwise the post renders as liked AND disliked until a
// reload, with a dislike count that no longer has a row behind it.
const wasDisliked = !!event.userDisliked;
onUpdate({
userLiked: nextLiked,
likesCount: nextCount,
...(nextLiked && wasDisliked
? {
userDisliked: false,
dislikesCount: Math.max(0, (event.dislikesCount || 0) - 1),
}
: {}),
});
setIsLiking(true);

try {
const result = await timelineService.toggleLike(event.id);
if (result.success) {
onUpdate({ userLiked: result.liked, likesCount: result.likeCount });
onUpdate({
userLiked: result.liked,
likesCount: result.likeCount,
// The server reports both totals; prefer them over the guess above.
...(result.disliked !== undefined ? { userDisliked: result.disliked } : {}),
...(result.dislikeCount !== undefined ? { dislikesCount: result.dislikeCount } : {}),
});
} else {
onUpdate({ userLiked: originalLiked, likesCount: originalCount });
onUpdate({
userLiked: originalLiked,
likesCount: originalCount,
userDisliked: wasDisliked,
dislikesCount: event.dislikesCount || 0,
});
}
} catch (error) {
logger.error('Failed to toggle like', error, 'usePostLikeDislike');
onUpdate({ userLiked: originalLiked, likesCount: originalCount });
} finally {
setIsLiking(false);
}
}, [event.id, event.userLiked, event.likesCount, isLiking, onUpdate]);
}, [
event.id,
event.userLiked,
event.likesCount,
event.userDisliked,
event.dislikesCount,
isLiking,
onUpdate,
]);

const handleDislike = useCallback(async () => {
if (isDisliking) {
Expand All @@ -59,23 +91,52 @@ export function usePostLikeDislike({ event, onUpdate }: Props): UsePostLikeDisli
const nextDisliked = !originalDisliked;
const nextCount = Math.max(0, originalCount + (nextDisliked ? 1 : -1));

onUpdate({ userDisliked: nextDisliked, dislikesCount: nextCount });
// Mirror image of handleLike: a dislike retracts a like.
const wasLiked = !!event.userLiked;
onUpdate({
userDisliked: nextDisliked,
dislikesCount: nextCount,
...(nextDisliked && wasLiked
? {
userLiked: false,
likesCount: Math.max(0, (event.likesCount || 0) - 1),
}
: {}),
});
setIsDisliking(true);

try {
const result = await timelineService.toggleDislike(event.id);
if (result.success) {
onUpdate({ userDisliked: result.disliked, dislikesCount: result.dislikeCount });
onUpdate({
userDisliked: result.disliked,
dislikesCount: result.dislikeCount,
...(result.liked !== undefined ? { userLiked: result.liked } : {}),
...(result.likeCount !== undefined ? { likesCount: result.likeCount } : {}),
});
} else {
onUpdate({ userDisliked: originalDisliked, dislikesCount: originalCount });
onUpdate({
userDisliked: originalDisliked,
dislikesCount: originalCount,
userLiked: wasLiked,
likesCount: event.likesCount || 0,
});
}
} catch (error) {
logger.error('Failed to toggle dislike', error, 'usePostLikeDislike');
onUpdate({ userDisliked: originalDisliked, dislikesCount: originalCount });
} finally {
setIsDisliking(false);
}
}, [event.id, event.userDisliked, event.dislikesCount, isDisliking, onUpdate]);
}, [
event.id,
event.userDisliked,
event.dislikesCount,
event.userLiked,
event.likesCount,
isDisliking,
onUpdate,
]);

return { isLiking, handleLike, isDisliking, handleDislike };
}
Loading
Loading