diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index c7fb0cd81b..33dd3963d2 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -47,7 +47,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({ rootClose onHide={handleClose} show={open} - target={anchorRef.current} + target={anchorRef} placement='top-end' flip offset={offset} diff --git a/app/javascript/mastodon/components/carousel/index.tsx b/app/javascript/mastodon/components/carousel/index.tsx index f5d772fd38..bc287aa969 100644 --- a/app/javascript/mastodon/components/carousel/index.tsx +++ b/app/javascript/mastodon/components/carousel/index.tsx @@ -76,6 +76,11 @@ export const Carousel = < // Handle slide change const [slideIndex, setSlideIndex] = useState(0); const wrapperRef = useRef(null); + // Handle slide heights + const [currentSlideHeight, setCurrentSlideHeight] = useState( + () => wrapperRef.current?.scrollHeight ?? 0, + ); + const previousSlideHeight = usePrevious(currentSlideHeight); const handleSlideChange = useCallback( (direction: number) => { setSlideIndex((prev) => { @@ -101,16 +106,11 @@ export const Carousel = < [items.length, onChangeSlide], ); - // Handle slide heights - const [currentSlideHeight, setCurrentSlideHeight] = useState( - wrapperRef.current?.scrollHeight ?? 0, - ); - const previousSlideHeight = usePrevious(currentSlideHeight); - const observerRef = useRef( - new ResizeObserver(() => { - handleSlideChange(0); - }), - ); + const observerRef = useRef(null); + observerRef.current ??= new ResizeObserver(() => { + handleSlideChange(0); + }); + const wrapperStyles = useSpring({ x: `-${slideIndex * 100}%`, height: currentSlideHeight, @@ -200,7 +200,7 @@ export const Carousel = < }; type CarouselSlideWrapperProps = { - observer: ResizeObserver; + observer: ResizeObserver | null; className: string; active: boolean; item: SlideProps; @@ -217,7 +217,7 @@ const CarouselSlideWrapper = ({ }: CarouselSlideWrapperProps) => { const handleRef = useCallback( (instance: HTMLDivElement | null) => { - if (instance) { + if (observer && instance) { observer.observe(instance); } }, diff --git a/app/javascript/mastodon/components/column_search_header.tsx b/app/javascript/mastodon/components/column_search_header.tsx index 90b6c4d89f..c74634e65e 100644 --- a/app/javascript/mastodon/components/column_search_header.tsx +++ b/app/javascript/mastodon/components/column_search_header.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -12,11 +12,15 @@ export const ColumnSearchHeader: React.FC<{ const inputRef = useRef(null); const [value, setValue] = useState(''); - useEffect(() => { + // Reset the component when it turns from active to inactive. + // [More on this pattern](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes) + const [previousActive, setPreviousActive] = useState(active); + if (active !== previousActive) { + setPreviousActive(active); if (!active) { setValue(''); } - }, [active]); + } const handleChange = useCallback( ({ target: { value } }: React.ChangeEvent) => { diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx index b6a04b9027..4edc78f50b 100644 --- a/app/javascript/mastodon/components/dropdown/index.tsx +++ b/app/javascript/mastodon/components/dropdown/index.tsx @@ -109,7 +109,7 @@ export const Dropdown: FC< placement='bottom-start' onHide={handleClose} flip - target={buttonRef.current} + target={buttonRef} popperConfig={{ strategy: 'fixed', modifiers: [matchWidth], diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 44ff5185ec..3a26c676ae 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -42,16 +42,10 @@ import { IconButton } from './icon_button'; let id = 0; -export interface RenderItemFnHandlers { - onClick: React.MouseEventHandler; - onKeyUp: React.KeyboardEventHandler; -} - export type RenderItemFn = ( item: Item, index: number, - handlers: RenderItemFnHandlers, - focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void, + onClick: React.MouseEventHandler, ) => React.ReactNode; type ItemClickFn = (item: Item, index: number) => void; @@ -101,7 +95,6 @@ export const DropdownMenu = ({ onItemClick, }: DropdownMenuProps) => { const nodeRef = useRef(null); - const focusedItemRef = useRef(null); useEffect(() => { const handleDocumentClick = (e: MouseEvent) => { @@ -163,8 +156,11 @@ export const DropdownMenu = ({ document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('keydown', handleKeyDown, { capture: true }); - if (focusedItemRef.current && openedViaKeyboard) { - focusedItemRef.current.focus({ preventScroll: true }); + if (openedViaKeyboard) { + const firstMenuItem = nodeRef.current?.querySelector< + HTMLAnchorElement | HTMLButtonElement + >('li:first-child > :is(a, button)'); + firstMenuItem?.focus({ preventScroll: true }); } return () => { @@ -175,13 +171,6 @@ export const DropdownMenu = ({ }; }, [onClose, openedViaKeyboard]); - const handleFocusedItemRef = useCallback( - (c: HTMLAnchorElement | HTMLButtonElement | null) => { - focusedItemRef.current = c as HTMLElement; - }, - [], - ); - const handleItemClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); @@ -207,15 +196,6 @@ export const DropdownMenu = ({ [onClose, onItemClick, items], ); - const handleItemKeyUp = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - handleItemClick(e); - } - }, - [handleItemClick], - ); - const nativeRenderItem = (option: Item, i: number) => { if (!isMenuItem(option)) { return null; @@ -232,9 +212,7 @@ export const DropdownMenu = ({ if (isActionItem(option)) { element = ( diff --git a/app/javascript/mastodon/components/exit_animation_wrapper.tsx b/app/javascript/mastodon/components/exit_animation_wrapper.tsx index ab0642b8b2..dba7d3e92c 100644 --- a/app/javascript/mastodon/components/exit_animation_wrapper.tsx +++ b/app/javascript/mastodon/components/exit_animation_wrapper.tsx @@ -27,22 +27,23 @@ export const ExitAnimationWrapper: React.FC<{ */ children: (delayedIsActive: boolean) => React.ReactNode; }> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { - const [delayedIsActive, setDelayedIsActive] = useState(false); + const [delayedIsActive, setDelayedIsActive] = useState( + isActive && !withEntryDelay, + ); useEffect(() => { - if (isActive && !withEntryDelay) { - setDelayedIsActive(true); + const withDelay = !isActive || withEntryDelay; - return () => ''; - } else { - const timeout = setTimeout(() => { + const timeout = setTimeout( + () => { setDelayedIsActive(isActive); - }, delayMs); + }, + withDelay ? delayMs : 0, + ); - return () => { - clearTimeout(timeout); - }; - } + return () => { + clearTimeout(timeout); + }; }, [isActive, delayMs, withEntryDelay]); if (!isActive && !delayedIsActive) { diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 38c3306f30..81510e8bd6 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -27,7 +27,6 @@ export const HoverCardController: React.FC = () => { const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout(); - const location = useLocation(); const handleClose = useCallback(() => { cancelEnterTimeout(); @@ -36,9 +35,12 @@ export const HoverCardController: React.FC = () => { setAnchor(null); }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]); - useEffect(() => { + const location = useLocation(); + const [previousLocation, setPreviousLocation] = useState(location); + if (location !== previousLocation) { + setPreviousLocation(location); handleClose(); - }, [handleClose, location]); + } useEffect(() => { let isScrolling = false; diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 9d32ab1f52..de9cbc19bb 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, forwardRef } from 'react'; +import { useCallback, forwardRef } from 'react'; import classNames from 'classnames'; @@ -55,23 +55,6 @@ export const IconButton = forwardRef( }, buttonRef, ) => { - const [activate, setActivate] = useState(false); - const [deactivate, setDeactivate] = useState(false); - - useEffect(() => { - if (!animate) { - return; - } - - if (activate && !active) { - setActivate(false); - setDeactivate(true); - } else if (!activate && active) { - setActivate(true); - setDeactivate(false); - } - }, [setActivate, setDeactivate, animate, active, activate]); - const handleClick: React.MouseEventHandler = useCallback( (e) => { e.preventDefault(); @@ -112,8 +95,8 @@ export const IconButton = forwardRef( active, disabled, inverted, - activate, - deactivate, + activate: animate && active, + deactivate: animate && !active, overlayed: overlay, 'icon-button--with-counter': typeof counter !== 'undefined', }); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index 51668ec476..2b7134185e 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -35,6 +35,9 @@ const messages = defineMessages({ }, }); +const isPollExpired = (expiresAt: Model.Poll['expires_at']) => + new Date(expiresAt).getTime() < Date.now(); + interface PollProps { pollId: string; status: Status; @@ -58,8 +61,7 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { if (!poll) { return false; } - const expiresAt = poll.expires_at; - return poll.expired || new Date(expiresAt).getTime() < Date.now(); + return poll.expired || isPollExpired(poll.expires_at); }, [poll]); const timeRemaining = useMemo(() => { if (!poll) { diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx index bbb7c031d9..723fa32aa1 100644 --- a/app/javascript/mastodon/components/status/boost_button.tsx +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -14,7 +14,7 @@ import type { Status } from '@/mastodon/models/status'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import type { SomeRequired } from '@/mastodon/utils/types'; -import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; +import type { RenderItemFn } from '../dropdown_menu'; import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu'; import { IconButton } from '../icon_button'; @@ -74,18 +74,12 @@ const StandaloneBoostButton: FC = ({ status, counters }) => { ); }; -const renderMenuItem: RenderItemFn = ( - item, - index, - handlers, - focusRefCallback, -) => ( +const renderMenuItem: RenderItemFn = (item, index, onClick) => ( ); @@ -118,6 +112,18 @@ const BoostOrQuoteMenu: FC = ({ status, counters }) => { const statusId = status.get('id') as string; const wasBoosted = !!status.get('reblogged'); + let count: number | undefined; + if (counters) { + count = 0; + // Ensure count is a valid integer. + if (Number.isInteger(status.get('reblogs_count'))) { + count += status.get('reblogs_count') as number; + } + if (Number.isInteger(status.get('quotes_count'))) { + count += status.get('quotes_count') as number; + } + } + const showLoginPrompt = useCallback(() => { dispatch( openModal({ @@ -193,12 +199,7 @@ const BoostOrQuoteMenu: FC = ({ status, counters }) => { )} icon='retweet' iconComponent={boostIcon} - counter={ - counters - ? (status.get('reblogs_count') as number) + - (status.get('quotes_count') as number) - : undefined - } + counter={count} active={isReblogged} /> @@ -208,16 +209,10 @@ const BoostOrQuoteMenu: FC = ({ status, counters }) => { interface ReblogMenuItemProps { item: ActionMenuItem; index: number; - handlers: RenderItemFnHandlers; - focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void; + onClick: React.MouseEventHandler; } -const ReblogMenuItem: FC = ({ - index, - item, - handlers, - focusRefCallback, -}) => { +const ReblogMenuItem: FC = ({ index, item, onClick }) => { const { text, highlighted, disabled } = item; return ( @@ -228,8 +223,7 @@ const ReblogMenuItem: FC = ({ key={`${text}-${index}`} >