diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d..1a300aeae 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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 diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index fbac726a6..0841afeca 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -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'; @@ -55,7 +56,13 @@ export default function DeleteUser() {
user && clearRecentSearches(user.id)} + onSuccess={() => { + clearUserPreviews(); + + if (user) { + clearRecentSearches(user.id); + } + }} > {({ processing }) => ( diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index 0af87e97e..e5c456903 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -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'; @@ -44,6 +45,7 @@ export function UserMenuContent({ user }: Props) { cleanup(); clearStashes(user.id); clearRecentSearches(user.id); + clearUserPreviews(); router.flushAll(); }; @@ -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" > diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index dfc245cbd..533eb8465 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -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(); +let cacheGeneration = 0; async function fetchPreview(url: string): Promise { + const generation = cacheGeneration; const response = await fetch(url, { headers: { Accept: 'application/json', @@ -68,11 +70,36 @@ async function fetchPreview(url: string): Promise { } 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(); +} + +/** + * 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( @@ -344,7 +371,8 @@ function UserPreviewContent({ ); const [pendingFollows, setPendingFollows] = useState(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. @@ -361,6 +389,7 @@ function UserPreviewContent({ } const follows = !isFollowing; + const change = unlessCleared(onChange); setPendingFollows(follows); router.visit( @@ -375,7 +404,7 @@ function UserPreviewContent({ onSuccess: () => { setIsFollowing(follows); router.flushAll(); - onChange({ + change({ ...data, isFollowing: follows, user: { diff --git a/tests/Unit/AuthorHover.test.tsx b/tests/Unit/AuthorHover.test.tsx index 029bd2b90..d07580796 100644 --- a/tests/Unit/AuthorHover.test.tsx +++ b/tests/Unit/AuthorHover.test.tsx @@ -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'); @@ -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((resolve) => (respond = resolve)), + ); + const trigger = React.Children.toArray( + UserPreview({ user, children: 'Raced' }).props.children, + )[0] as ReactElement< + HTMLAttributes & { ref: { current: unknown } } + >; + trigger.props.ref.current = { + getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 20 }), + }; + + trigger.props.onPointerEnter?.({ + pointerType: 'mouse', + } as PointerEvent); + 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', diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index 68026c200..9aea14556 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -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; @@ -57,7 +58,7 @@ vi.doMock('@inertiajs/react', async () => ({ usePage: () => ({ component, props: { - auth: { user: { id: 'viewer' } }, + auth: { user: viewer }, log, commits: loadedCommits, showsWhoToFollow: true, @@ -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'); @@ -120,6 +121,7 @@ afterEach(() => { log = undefined; loadedCommits = undefined; suggestedUsers = undefined; + viewer = { id: 'viewer' }; refreshSharedState( new Map([ ['who-to-follow-dismissing', 0], @@ -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); diff --git a/tests/Unit/UserMenuLogout.test.tsx b/tests/Unit/UserMenuLogout.test.tsx new file mode 100644 index 000000000..9606db1cb --- /dev/null +++ b/tests/Unit/UserMenuLogout.test.tsx @@ -0,0 +1,36 @@ +/// +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); +});