Skip to content
Open
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,11 @@ Sources: [group creation](app/Http/Requests/CreateOrgRequest.php), [invitation e

### Muting and blocking

**Mute** someone to remove their commits and replies from your logs, threads, search, and suggestions and stop their notifications. Muting is silent, leaves follows intact, and still lets you visit their profile, which shows a **Muted** badge. Commits you starred stay in Starred.
**Mute** someone to remove their commits and replies from your logs, threads, search, and suggestions and stop their notifications. Muting is silent, leaves follows intact, and still lets you visit their profile, which shows a **Muted** badge. Commits you starred stay in Starred. Hover cards show the same badge, and logging out or deleting your account forgets them, so whoever uses that tab next doesn't see who you muted or blocked.

**Block** someone to stop messaging and interactions such as following, replies, stars, pastéis, and Tom's replies between the two accounts, and hide each other's commits while signed in. Their profile and follower lists become unavailable to you. Blocking removes follows in both directions. Unblocking does not restore those follows. Manage and reverse either choice in Settings → Safety. Blocking is an account-level interaction control, not a way to make public posts inaccessible to signed-out visitors.

Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](app/Actions/CreateUserMute.php), [block action](app/Actions/CreateUserBlock.php), [public routes](routes/web.php).
Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](app/Actions/CreateUserMute.php), [block action](app/Actions/CreateUserBlock.php), [public routes](routes/web.php), [hover cards](resources/js/components/user-preview.tsx).

### Reporting commits

Expand Down
9 changes: 8 additions & 1 deletion resources/js/components/delete-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { clearUserPreviews } from '@/components/user-preview';
import { WordmarkText } from '@/components/wordmark-text';
import { clearRecentSearches } from '@/hooks/use-recent-searches';

Expand Down Expand Up @@ -55,7 +56,13 @@ export default function DeleteUser() {

<Form
{...UserController.destroy.form()}
onSuccess={() => user && clearRecentSearches(user.id)}
onSuccess={() => {
clearUserPreviews();

if (user) {
clearRecentSearches(user.id);
}
}}
>
{({ processing }) => (
<DialogFooter className="gap-2">
Expand Down
3 changes: 3 additions & 0 deletions resources/js/components/user-menu-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { UserInfo } from '@/components/user-info';
import { clearUserPreviews } from '@/components/user-preview';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { clearRecentSearches } from '@/hooks/use-recent-searches';
import { clearStashes } from '@/hooks/use-stash';
Expand All @@ -44,6 +45,7 @@ export function UserMenuContent({ user }: Props) {
cleanup();
clearStashes(user.id);
clearRecentSearches(user.id);
clearUserPreviews();
router.flushAll();
};

Expand Down Expand Up @@ -163,6 +165,7 @@ export function UserMenuContent({ user }: Props) {
className="block w-full cursor-pointer"
href={logout()}
as="button"
preserveState={false}
onClick={handleLogout}
data-test="logout-button"
>
Expand Down
33 changes: 31 additions & 2 deletions resources/js/components/user-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ const viewportMargin = 8;
// Where the centre of the preview's avatar sits: border, padding and half of the size-14 avatar.
const avatarCentre = 45;
const cache = new Map<string, PreviewData>();
let cacheGeneration = 0;

async function fetchPreview(url: string): Promise<PreviewData> {
const generation = cacheGeneration;
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Expand All @@ -68,11 +70,36 @@ async function fetchPreview(url: string): Promise<PreviewData> {
}

const data = (await response.json()) as PreviewData;

if (generation !== cacheGeneration) {
throw new Error(`Dropped ${url} from before the previews were cleared`);
}

cache.set(url, data);

return data;
}

export function clearUserPreviews(): void {
cacheGeneration++;
cache.clear();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Drops a change that lands after the previews were cleared. It reads the generation outside the component because the React Compiler would read it again at the comparison.
*/
function unlessCleared(
onChange: (data: PreviewData) => void,
): (data: PreviewData) => void {
const generation = cacheGeneration;

return (data) => {
if (generation === cacheGeneration) {
onChange(data);
}
};
}

function positionFor(trigger: HTMLElement): Position {
const rect = trigger.getBoundingClientRect();
const left = Math.min(
Expand Down Expand Up @@ -344,7 +371,8 @@ function UserPreviewContent({
);

const [pendingFollows, setPendingFollows] = useState<boolean | null>(null);
const shownIsFollowing = pendingFollows ?? isFollowing;
const shownIsFollowing =
auth.user !== null && (pendingFollows ?? isFollowing);

/**
* The button flips right away, but like every other follow control the shared state only changes once the server confirms, so there is nothing to roll back.
Expand All @@ -361,6 +389,7 @@ function UserPreviewContent({
}

const follows = !isFollowing;
const change = unlessCleared(onChange);
setPendingFollows(follows);

router.visit(
Expand All @@ -375,7 +404,7 @@ function UserPreviewContent({
onSuccess: () => {
setIsFollowing(follows);
router.flushAll();
onChange({
change({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...data,
isFollowing: follows,
user: {
Expand Down
77 changes: 76 additions & 1 deletion tests/Unit/AuthorHover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const { UserAvatar } =
await import('../../resources/js/components/user-avatar');
const { ActorAvatar } =
await import('../../resources/js/pages/notification/index');
const { UserPreview } =
const { UserPreview, clearUserPreviews } =
await import('../../resources/js/components/user-preview');
const { SuggestedUserRow } =
await import('../../resources/js/components/who-to-follow');
Expand Down Expand Up @@ -177,6 +177,81 @@ test('profile preview stays open when its menu is dismissed under the mouse', ()
assert.ok(!stateChanges.includes(false));
});

function previewContent(user: User) {
showPreview = true;
const preview = UserPreview({ user, children: user.handle });
showPreview = false;

return (
React.Children.toArray(preview.props.children)[1] as ReactElement<{
children: ReactElement<{
data: { followsYou: boolean; isMuting: boolean } | null;
onChange: (data: object) => void;
}>;
}>
).props.children.props;
}

const mutedPreview = (user: User) => ({
isFollowing: false,
followsYou: true,
isMuting: true,
isBlocking: false,
isUnavailable: false,
user,
});

test('profile preview opens without the cached relationship once previews are cleared', () => {
const user = { id: 'muted', handle: 'muted' } as User;

previewContent(user).onChange(mutedPreview(user));
assert.equal(previewContent(user).data?.isMuting, true);

clearUserPreviews();

assert.equal(previewContent(user).data, null);
});

test('profile preview drops a response that arrives after previews are cleared', async () => {
const user = { id: 'raced', handle: 'raced' } as User;
let respond = (_response: Response) => {};
vi.useFakeTimers();
vi.stubGlobal('window', {
matchMedia: () => ({ matches: true }),
innerWidth: 1440,
innerHeight: 900,
});
vi.stubGlobal(
'fetch',
() => new Promise<Response>((resolve) => (respond = resolve)),
);
const trigger = React.Children.toArray(
UserPreview({ user, children: 'Raced' }).props.children,
)[0] as ReactElement<
HTMLAttributes<HTMLElement> & { ref: { current: unknown } }
>;
trigger.props.ref.current = {
getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 20 }),
};

trigger.props.onPointerEnter?.({
pointerType: 'mouse',
} as PointerEvent<HTMLElement>);
vi.runAllTimers();
clearUserPreviews();
respond(Response.json(mutedPreview(user)));
vi.useRealTimers();
await new Promise((resolve) => setTimeout(resolve));
vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) });

assert.equal(previewContent(user).data, null);
assert.ok(
!stateChanges.some(
(change) => (change as { isMuting?: boolean } | null)?.isMuting,
),
);
});

test('suggested user row wraps the author in a user preview anchored to their avatar', () => {
const user = {
id: 'author',
Expand Down
31 changes: 29 additions & 2 deletions tests/Unit/FollowState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let component = 'user/show';
let log: string | undefined;
let loadedCommits: { data: { id: string }[] } | undefined;
let suggestedUsers: SuggestedUser[] | undefined;
let viewer: { id: string } | null = { id: 'viewer' };
type VisitOptions = {
only?: string[];
headers?: Record<string, string>;
Expand Down Expand Up @@ -57,7 +58,7 @@ vi.doMock('@inertiajs/react', async () => ({
usePage: () => ({
component,
props: {
auth: { user: { id: 'viewer' } },
auth: { user: viewer },
log,
commits: loadedCommits,
showsWhoToFollow: true,
Expand Down Expand Up @@ -98,7 +99,7 @@ vi.doMock('../../resources/js/lib/chat', async () => ({

const { FollowButton } =
await import('../../resources/js/components/follow-button');
const { UserPreview } =
const { UserPreview, clearUserPreviews } =
await import('../../resources/js/components/user-preview');
const { SuggestedUserRow, WhoToFollow, WhoToFollowInLog } =
await import('../../resources/js/components/who-to-follow');
Expand All @@ -120,6 +121,7 @@ afterEach(() => {
log = undefined;
loadedCommits = undefined;
suggestedUsers = undefined;
viewer = { id: 'viewer' };
refreshSharedState(
new Map<string, boolean | number | string>([
['who-to-follow-dismissing', 0],
Expand Down Expand Up @@ -382,6 +384,31 @@ test('unfollowing through a cached preview keeps its follower count', () => {
assert.equal(changes.at(-1)?.user.followers_count, 0);
});

test('a follow from their preview that lands after logging out stays out of the cleared previews', () => {
const user = suggestion('cleared');
const changes: { user: Profile }[] = [];

(
previewFollowButton(user, false, (data) => changes.push(data)).props
.onClick as () => void
)();
clearUserPreviews();
visits.at(-1)?.onSuccess?.();

assert.deepEqual(changes, []);
});

test('after logging out a preview offers to follow someone the last account followed', () => {
const user = suggestion('previous');
followFromProfile(user);
viewer = null;

assert.equal(
previewFollowButton(user, false).props['aria-label'],
'Follow @previous',
);
});

test('a preview unfollow that fails after a block keeps who to follow unfollowed', () => {
const user = suggestion('raced');
followFromProfile(user);
Expand Down
36 changes: 36 additions & 0 deletions tests/Unit/UserMenuLogout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// <reference types="node" />
import assert from 'node:assert/strict';
import * as React from 'react';
import type { ReactElement } from 'react';
import { test, vi } from 'vite-plus/test';
import type { User } from '../../resources/js/types';

vi.doMock('react', () => ({
...React,
useCallback: (callback: unknown) => callback,
}));
vi.doMock('@inertiajs/react', async () => ({
...(await vi.importActual('@inertiajs/react')),
usePage: () => ({ props: { auth: { isAdmin: false }, repository: '' } }),
}));

const { UserMenuContent } =
await import('../../resources/js/components/user-menu-content');

test('logging out remounts the page instead of keeping what the signed in account had open', () => {
const items = React.Children.toArray(
UserMenuContent({ user: { id: 'author', handle: 'author' } as User })
.props.children,
);
const logout = (
items.at(-1) as ReactElement<{
children: ReactElement<{
preserveState?: boolean;
'data-test': string;
}>;
}>
).props.children;

assert.equal(logout.props['data-test'], 'logout-button');
assert.equal(logout.props.preserveState, false);
});