[Glitch] Update to latest eslint-plugin-react-hooks

Port 9addad8ce5 to glitch-soc

Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Renaud Chaput
2025-11-10 15:50:04 +01:00
committed by Claire
parent 01f7a6796f
commit 5b75667c03
25 changed files with 328 additions and 303 deletions

View File

@@ -47,7 +47,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
rootClose rootClose
onHide={handleClose} onHide={handleClose}
show={open} show={open}
target={anchorRef.current} target={anchorRef}
placement='top-end' placement='top-end'
flip flip
offset={offset} offset={offset}

View File

@@ -76,6 +76,11 @@ export const Carousel = <
// Handle slide change // Handle slide change
const [slideIndex, setSlideIndex] = useState(0); const [slideIndex, setSlideIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
// Handle slide heights
const [currentSlideHeight, setCurrentSlideHeight] = useState(
() => wrapperRef.current?.scrollHeight ?? 0,
);
const previousSlideHeight = usePrevious(currentSlideHeight);
const handleSlideChange = useCallback( const handleSlideChange = useCallback(
(direction: number) => { (direction: number) => {
setSlideIndex((prev) => { setSlideIndex((prev) => {
@@ -101,16 +106,11 @@ export const Carousel = <
[items.length, onChangeSlide], [items.length, onChangeSlide],
); );
// Handle slide heights const observerRef = useRef<ResizeObserver | null>(null);
const [currentSlideHeight, setCurrentSlideHeight] = useState( observerRef.current ??= new ResizeObserver(() => {
wrapperRef.current?.scrollHeight ?? 0, handleSlideChange(0);
); });
const previousSlideHeight = usePrevious(currentSlideHeight);
const observerRef = useRef<ResizeObserver>(
new ResizeObserver(() => {
handleSlideChange(0);
}),
);
const wrapperStyles = useSpring({ const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`, x: `-${slideIndex * 100}%`,
height: currentSlideHeight, height: currentSlideHeight,
@@ -200,7 +200,7 @@ export const Carousel = <
}; };
type CarouselSlideWrapperProps<SlideProps extends CarouselSlideProps> = { type CarouselSlideWrapperProps<SlideProps extends CarouselSlideProps> = {
observer: ResizeObserver; observer: ResizeObserver | null;
className: string; className: string;
active: boolean; active: boolean;
item: SlideProps; item: SlideProps;
@@ -217,7 +217,7 @@ const CarouselSlideWrapper = <SlideProps extends CarouselSlideProps>({
}: CarouselSlideWrapperProps<SlideProps>) => { }: CarouselSlideWrapperProps<SlideProps>) => {
const handleRef = useCallback( const handleRef = useCallback(
(instance: HTMLDivElement | null) => { (instance: HTMLDivElement | null) => {
if (instance) { if (observer && instance) {
observer.observe(instance); observer.observe(instance);
} }
}, },

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useEffect, useRef } from 'react'; import { useCallback, useState, useRef } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@@ -12,11 +12,15 @@ export const ColumnSearchHeader: React.FC<{
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(''); 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) { if (!active) {
setValue(''); setValue('');
} }
}, [active]); }
const handleChange = useCallback( const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => { ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -109,7 +109,7 @@ export const Dropdown: FC<
placement='bottom-start' placement='bottom-start'
onHide={handleClose} onHide={handleClose}
flip flip
target={buttonRef.current} target={buttonRef}
popperConfig={{ popperConfig={{
strategy: 'fixed', strategy: 'fixed',
modifiers: [matchWidth], modifiers: [matchWidth],

View File

@@ -27,22 +27,23 @@ export const ExitAnimationWrapper: React.FC<{
*/ */
children: (delayedIsActive: boolean) => React.ReactNode; children: (delayedIsActive: boolean) => React.ReactNode;
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { }> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
const [delayedIsActive, setDelayedIsActive] = useState(false); const [delayedIsActive, setDelayedIsActive] = useState(
isActive && !withEntryDelay,
);
useEffect(() => { useEffect(() => {
if (isActive && !withEntryDelay) { const withDelay = !isActive || withEntryDelay;
setDelayedIsActive(true);
return () => ''; const timeout = setTimeout(
} else { () => {
const timeout = setTimeout(() => {
setDelayedIsActive(isActive); setDelayedIsActive(isActive);
}, delayMs); },
withDelay ? delayMs : 0,
);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
}; };
}
}, [isActive, delayMs, withEntryDelay]); }, [isActive, delayMs, withEntryDelay]);
if (!isActive && !delayedIsActive) { if (!isActive && !delayedIsActive) {

View File

@@ -27,7 +27,6 @@ export const HoverCardController: React.FC = () => {
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout();
const location = useLocation();
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
cancelEnterTimeout(); cancelEnterTimeout();
@@ -36,9 +35,12 @@ export const HoverCardController: React.FC = () => {
setAnchor(null); setAnchor(null);
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]); }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
useEffect(() => { const location = useLocation();
const [previousLocation, setPreviousLocation] = useState(location);
if (location !== previousLocation) {
setPreviousLocation(location);
handleClose(); handleClose();
}, [handleClose, location]); }
useEffect(() => { useEffect(() => {
let isScrolling = false; let isScrolling = false;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, forwardRef } from 'react'; import { useCallback, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -59,23 +59,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
}, },
buttonRef, 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<HTMLButtonElement> = useCallback( const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
@@ -116,8 +99,8 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
active, active,
disabled, disabled,
inverted, inverted,
activate, activate: animate && active,
deactivate, deactivate: animate && !active,
overlayed: overlay, overlayed: overlay,
'icon-button--with-counter': typeof counter !== 'undefined', 'icon-button--with-counter': typeof counter !== 'undefined',
}); });

View File

@@ -35,6 +35,9 @@ const messages = defineMessages({
}, },
}); });
const isPollExpired = (expiresAt: Model.Poll['expires_at']) =>
new Date(expiresAt).getTime() < Date.now();
interface PollProps { interface PollProps {
pollId: string; pollId: string;
status: Status; status: Status;
@@ -58,8 +61,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
if (!poll) { if (!poll) {
return false; return false;
} }
const expiresAt = poll.expires_at; return poll.expired || isPollExpired(poll.expires_at);
return poll.expired || new Date(expiresAt).getTime() < Date.now();
}, [poll]); }, [poll]);
const timeRemaining = useMemo(() => { const timeRemaining = useMemo(() => {
if (!poll) { if (!poll) {

View File

@@ -44,6 +44,7 @@ export const RemoveQuoteHint: React.FC<{
if (!firstHintId) { if (!firstHintId) {
firstHintId = uniqueId; firstHintId = uniqueId;
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOnlyHint(true); setIsOnlyHint(true);
} }
@@ -64,8 +65,8 @@ export const RemoveQuoteHint: React.FC<{
flip flip
offset={[12, 10]} offset={[12, 10]}
placement='bottom-end' placement='bottom-end'
target={anchorRef.current} target={anchorRef}
container={anchorRef.current} container={anchorRef}
> >
{({ props, placement }) => ( {({ props, placement }) => (
<div <div

View File

@@ -86,6 +86,7 @@ export const ScrollContext: React.FC<ScrollContextProps> = ({
) => ) =>
// Hack to allow accessing scrollBehavior._stateStorage // Hack to allow accessing scrollBehavior._stateStorage
shouldUpdateScroll.call( shouldUpdateScroll.call(
// eslint-disable-next-line react-hooks/immutability
scrollBehavior, scrollBehavior,
prevLocationContext, prevLocationContext,
locationContext, locationContext,

View File

@@ -101,16 +101,17 @@ const Preview: React.FC<{
position: FocalPoint; position: FocalPoint;
onPositionChange: (arg0: FocalPoint) => void; onPositionChange: (arg0: FocalPoint) => void;
}> = ({ mediaId, position, onPositionChange }) => { }> = ({ mediaId, position, onPositionChange }) => {
const draggingRef = useRef<boolean>(false);
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null); const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const [dragging, setDragging] = useState<'started' | 'moving' | null>(null);
const [x, y] = position; const [x, y] = position;
const style = useSpring({ const style = useSpring({
to: { to: {
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}, },
immediate: draggingRef.current, immediate: dragging === 'moving',
}); });
const media = useAppSelector((state) => const media = useAppSelector((state) =>
( (
@@ -123,8 +124,6 @@ const Preview: React.FC<{
me ? state.accounts.get(me) : undefined, me ? state.accounts.get(me) : undefined,
); );
const [dragging, setDragging] = useState(false);
const setRef = useCallback( const setRef = useCallback(
(e: HTMLImageElement | HTMLVideoElement | null) => { (e: HTMLImageElement | HTMLVideoElement | null) => {
nodeRef.current = e; nodeRef.current = e;
@@ -140,20 +139,20 @@ const Preview: React.FC<{
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e); const { x, y } = getPointerPosition(nodeRef.current, e);
draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves
setDragging('moving'); // This will disable the animation for quicker feedback, only do this if the mouse actually moves
onPositionChange([x, y]); onPositionChange([x, y]);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
setDragging(false); setDragging(null);
draggingRef.current = false;
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
}; };
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
setDragging(true); setDragging('started');
onPositionChange([x, y]); onPositionChange([x, y]);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);

View File

@@ -31,15 +31,13 @@ export const AnnualReport: React.FC<{
year: string; year: string;
}> = ({ year }) => { }> = ({ year }) => {
const [response, setResponse] = useState<AnnualReportResponse | null>(null); const [response, setResponse] = useState<AnnualReportResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const currentAccount = useAppSelector((state) => const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined, me ? state.accounts.get(me) : undefined,
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
setLoading(true);
apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`) apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`)
.then((data) => { .then((data) => {
dispatch(importFetchedStatuses(data.statuses)); dispatch(importFetchedStatuses(data.statuses));

View File

@@ -55,6 +55,8 @@ const getFrequentlyUsedLanguages = createSelector(
.toArray(), .toArray(),
); );
const isTextLongEnoughForGuess = (text: string) => text.length > 20;
const LanguageDropdownMenu: React.FC<{ const LanguageDropdownMenu: React.FC<{
value: string; value: string;
guess?: string; guess?: string;
@@ -375,14 +377,27 @@ export const LanguageDropdown: React.FC = () => {
); );
useEffect(() => { useEffect(() => {
if (text.length > 20) { if (isTextLongEnoughForGuess(text)) {
debouncedGuess(text, setGuess); debouncedGuess(text, setGuess);
} else { } else {
debouncedGuess.cancel(); debouncedGuess.cancel();
setGuess('');
} }
}, [text, setGuess]); }, [text, setGuess]);
// Keeping track of the previous render's text length here
// to be able to reset the guess when the text length drops
// below the threshold needed to make a guess
const [wasLongText, setWasLongText] = useState(() =>
isTextLongEnoughForGuess(text),
);
if (wasLongText !== isTextLongEnoughForGuess(text)) {
setWasLongText(isTextLongEnoughForGuess(text));
if (wasLongText) {
setGuess('');
}
}
return ( return (
<div ref={targetRef}> <div ref={targetRef}>
<button <button

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useRef, useEffect } from 'react'; import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
import { import {
defineMessages, defineMessages,
@@ -97,173 +97,13 @@ export const Search: React.FC<{
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1); const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]); const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
useEffect(() => {
setValue(initialValue ?? '');
setQuickActions([]);
}, [initialValue]);
const searchOptions: SearchOption[] = [];
const unfocus = useCallback(() => { const unfocus = useCallback(() => {
document.querySelector('.ui')?.parentElement?.focus(); document.querySelector('.ui')?.parentElement?.focus();
setExpanded(false); setExpanded(false);
}, []); }, []);
if (searchEnabled) { const insertText = useCallback((text: string) => {
searchOptions.push(
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
);
}
const recentOptions: SearchOption[] = recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({ pathname: '/search', search: queryParams.toString() });
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search));
},
}));
const navigableOptions = hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions);
const insertText = (text: string) => {
setValue((currentValue) => { setValue((currentValue) => {
if (currentValue === '') { if (currentValue === '') {
return text; return text;
@@ -273,7 +113,181 @@ export const Search: React.FC<{
return `${currentValue} ${text}`; return `${currentValue} ${text}`;
} }
}); });
}; }, []);
const searchOptions = useMemo(() => {
if (!searchEnabled) {
return [];
} else {
const options: SearchOption[] = [
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList
type='disjunction'
value={['reply', 'sensitive']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
];
return options;
}
}, [insertText]);
const recentOptions: SearchOption[] = useMemo(
() =>
recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({
pathname: '/search',
search: queryParams.toString(),
});
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search));
},
})),
[dispatch, history, recent, unfocus],
);
const navigableOptions: SearchOption[] = useMemo(
() =>
hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions),
[hasValue, quickActions, recentOptions, searchOptions],
);
const submit = useCallback( const submit = useCallback(
(q: string, type?: SearchType) => { (q: string, type?: SearchType) => {

View File

@@ -55,6 +55,11 @@ type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
const glitchProbability = 1 - 0.0420215528; const glitchProbability = 1 - 0.0420215528;
const totalElefriends = 3; const totalElefriends = 3;
const pickRandomFriend = () =>
Math.random() < glitchProbability
? Math.floor(Math.random() * totalElefriends)
: totalElefriends;
const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -75,11 +80,7 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
false, false,
) as boolean, ) as boolean,
); );
const [elefriend, setElefriend] = useState( const [elefriend, setElefriend] = useState(pickRandomFriend());
Math.random() < glitchProbability
? Math.floor(Math.random() * totalElefriends)
: totalElefriends,
);
useEffect(() => { useEffect(() => {
dispatch(mountCompose()); dispatch(mountCompose());

View File

@@ -19,14 +19,12 @@ const messages = defineMessages({
const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const [domains, setDomains] = useState<string[]>([]); const [domains, setDomains] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [next, setNext] = useState<string | undefined>(); const [next, setNext] = useState<string | undefined>();
const hasMore = !!next; const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null); const columnRef = useRef<ColumnRef>(null);
useEffect(() => { useEffect(() => {
setLoading(true);
void apiGetDomainBlocks() void apiGetDomainBlocks()
.then(({ domains, links }) => { .then(({ domains, links }) => {
const next = links.refs.find((link) => link.rel === 'next'); const next = links.refs.find((link) => link.rel === 'next');
@@ -40,7 +38,7 @@ const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
}, [setLoading, setDomains, setNext]); }, []);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
setLoading(true); setLoading(true);

View File

@@ -35,12 +35,17 @@ export const Announcement: FC<AnnouncementProps> = ({
}, [active, id, dispatch, read]); }, [active, id, dispatch, read]);
// But visually show the announcement as read only when it goes out of view. // But visually show the announcement as read only when it goes out of view.
const [unread, setUnread] = useState(!read); const [isVisuallyRead, setIsVisuallyRead] = useState(read);
useEffect(() => { const [previousActive, setPreviousActive] = useState(active);
if (!active && unread !== !read) { if (active !== previousActive) {
setUnread(!read); setPreviousActive(active);
// This marks the announcement as read in the UI only after it
// went from active to inactive.
if (!active && isVisuallyRead !== read) {
setIsVisuallyRead(read);
} }
}, [active, unread, read]); }
return ( return (
<AnimateEmojiProvider> <AnimateEmojiProvider>
@@ -63,7 +68,7 @@ export const Announcement: FC<AnnouncementProps> = ({
<ReactionsBar reactions={announcement.reactions} id={announcement.id} /> <ReactionsBar reactions={announcement.reactions} id={announcement.id} />
{unread && <span className='announcements__unread' />} {!isVisuallyRead && <span className='announcements__unread' />}
</AnimateEmojiProvider> </AnimateEmojiProvider>
); );
}; };

View File

@@ -164,12 +164,11 @@ const ListMembers: React.FC<{
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]); const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]); const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(!!id);
const [mode, setMode] = useState<Mode>('remove'); const [mode, setMode] = useState<Mode>('remove');
useEffect(() => { useEffect(() => {
if (id) { if (id) {
setLoading(true);
dispatch(fetchList(id)); dispatch(fetchList(id));
void apiGetAccounts(id) void apiGetAccounts(id)

View File

@@ -27,17 +27,15 @@ export const ListPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const lists = useAppSelector((state) => getOrderedLists(state)); const lists = useAppSelector((state) => getOrderedLists(state));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
setLoading(true);
void dispatch(fetchLists()).then(() => { void dispatch(fetchLists()).then(() => {
setLoading(false); setLoading(false);
return ''; return '';
}); });
}, [dispatch, setLoading]); }, [dispatch]);
return ( return (
<CollapsiblePanel <CollapsiblePanel

View File

@@ -225,7 +225,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
/> />
<div className='explore__search-header'> <div className='explore__search-header'>
<Search singleColumn initialValue={trimmedValue} /> <Search singleColumn initialValue={trimmedValue} key={trimmedValue} />
</div> </div>
<div className='account__section-headline'> <div className='account__section-headline'>

View File

@@ -53,8 +53,6 @@ export const DomainBlockModal: React.FC<{
}, [dispatch]); }, [dispatch]);
useEffect(() => { useEffect(() => {
setLoading(true);
apiRequest<DomainBlockPreviewResponse>('GET', 'v1/domain_blocks/preview', { apiRequest<DomainBlockPreviewResponse>('GET', 'v1/domain_blocks/preview', {
params: { domain }, params: { domain },
timeout: 5000, timeout: 5000,
@@ -68,7 +66,7 @@ export const DomainBlockModal: React.FC<{
setPreview('error'); setPreview('error');
setLoading(false); setLoading(false);
}); });
}, [setPreview, setLoading, domain]); }, [domain]);
return ( return (
<div className='modal-root__modal safety-action-modal' aria-live='polite'> <div className='modal-root__modal safety-action-modal' aria-live='polite'>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
@@ -41,40 +41,44 @@ const isHashtagLink = (
}; };
interface TargetParams { interface TargetParams {
hashtag?: string; element: HTMLAnchorElement | null;
accountId?: string; hashtag: string;
accountId: string;
} }
export const HashtagMenuController: React.FC = () => { export const HashtagMenuController: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { signedIn } = useIdentity(); const { signedIn } = useIdentity();
const [open, setOpen] = useState(false);
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({}); const [target, setTarget] = useState<TargetParams | null>(null);
const targetRef = useRef<HTMLAnchorElement | null>(null); const { element = null, accountId, hashtag } = target ?? {};
const location = useLocation(); const open = !!element;
const account = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
useEffect(() => { const location = useLocation();
setOpen(false); const [previousLocation, setPreviousLocation] = useState(location);
targetRef.current = null; if (location !== previousLocation) {
}, [setOpen, location]); setPreviousLocation(location);
setTarget(null);
}
useEffect(() => { useEffect(() => {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('a'); const targetElement = (e.target as HTMLElement).closest('a');
if (e.button !== 0 || e.ctrlKey || e.metaKey) { if (e.button !== 0 || e.ctrlKey || e.metaKey) {
return; return;
} }
if (!isHashtagLink(target)) { if (!isHashtagLink(targetElement)) {
return; return;
} }
const hashtag = target.text.replace(/^#/, ''); const hashtag = targetElement.text.replace(/^#/, '');
const accountId = target.getAttribute('data-menu-hashtag'); const accountId = targetElement.getAttribute('data-menu-hashtag');
if (!hashtag || !accountId) { if (!hashtag || !accountId) {
return; return;
@@ -82,9 +86,7 @@ export const HashtagMenuController: React.FC = () => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
targetRef.current = target; setTarget({ element: targetElement, hashtag, accountId });
setOpen(true);
setTargetParams({ hashtag, accountId });
}; };
document.addEventListener('click', handleClick, { capture: true }); document.addEventListener('click', handleClick, { capture: true });
@@ -92,12 +94,11 @@ export const HashtagMenuController: React.FC = () => {
return () => { return () => {
document.removeEventListener('click', handleClick); document.removeEventListener('click', handleClick);
}; };
}, [setTargetParams, setOpen]); }, []);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setTarget(null);
targetRef.current = null; }, []);
}, [setOpen]);
const menu = useMemo(() => { const menu = useMemo(() => {
const arr: MenuItem[] = [ const arr: MenuItem[] = [
@@ -139,7 +140,7 @@ export const HashtagMenuController: React.FC = () => {
offset={offset} offset={offset}
placement='bottom' placement='bottom'
flip flip
target={targetRef} target={element}
popperConfig={popperConfig} popperConfig={popperConfig}
> >
{({ props, arrowProps, placement }) => ( {({ props, arrowProps, placement }) => (

View File

@@ -66,6 +66,7 @@ export const MediaModal: FC<MediaModalProps> = forwardRef<
_ref, _ref,
) => { ) => {
const [index, setIndex] = useState(startIndex); const [index, setIndex] = useState(startIndex);
const [zoomedIn, setZoomedIn] = useState(false);
const currentMedia = media.get(index); const currentMedia = media.get(index);
const handleChangeIndex = useCallback( const handleChangeIndex = useCallback(
@@ -134,7 +135,6 @@ export const MediaModal: FC<MediaModalProps> = forwardRef<
} }
}, []); }, []);
const [zoomedIn, setZoomedIn] = useState(false);
const zoomable = const zoomable =
currentMedia?.get('type') === 'image' && currentMedia?.get('type') === 'image' &&
((currentMedia.getIn(['meta', 'original', 'width']) as number) > ((currentMedia.getIn(['meta', 'original', 'width']) as number) >

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useSyncExternalStore } from 'react';
const breakpoints = { const breakpoints = {
narrow: 479, // Device width under which horizontal space is constrained narrow: 479, // Device width under which horizontal space is constrained
@@ -9,25 +9,20 @@ const breakpoints = {
type Breakpoint = keyof typeof breakpoints; type Breakpoint = keyof typeof breakpoints;
export const useBreakpoint = (breakpoint: Breakpoint) => { export const useBreakpoint = (breakpoint: Breakpoint) => {
const [isMatching, setIsMatching] = useState(false); const query = `(max-width: ${breakpoints[breakpoint]}px)`;
useEffect(() => { const isMatching = useSyncExternalStore(
const mediaWatcher = window.matchMedia( (callback) => {
`(max-width: ${breakpoints[breakpoint]}px)`, const mediaWatcher = window.matchMedia(query);
);
setIsMatching(mediaWatcher.matches); mediaWatcher.addEventListener('change', callback);
const handleChange = (e: MediaQueryListEvent) => { return () => {
setIsMatching(e.matches); mediaWatcher.removeEventListener('change', callback);
}; };
},
mediaWatcher.addEventListener('change', handleChange); () => window.matchMedia(query).matches,
);
return () => {
mediaWatcher.removeEventListener('change', handleChange);
};
}, [breakpoint, setIsMatching]);
return isMatching; return isMatching;
}; };

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react'; import { useState } from 'react';
/** /**
* Returns the previous state of the passed in value. * Returns the previous state of the passed in value.
@@ -6,11 +6,21 @@ import { useRef, useEffect } from 'react';
*/ */
export function usePrevious<T>(value: T): T | undefined { export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>(); const [{ previous, current }, setMemory] = useState<{
previous: T | undefined;
current: T;
}>(() => ({ previous: undefined, current: value }));
useEffect(() => { let result = previous;
ref.current = value;
}, [value]);
return ref.current; if (value !== current) {
setMemory({
previous: current,
current: value,
});
// Ensure that the returned result updates synchronously
result = current;
}
return result;
} }