perf/UX audit round 2, fix eggplant reaction button
Some checks failed
pl-api CI / Test for pl-api formatting (22.x) (push) Has been cancelled
pl-fe CI / Test and upload artifacts (22.x) (push) Has been cancelled
pl-fe CI / deploy (push) Has been cancelled
pl-hooks CI / Test for a successful build (22.x) (push) Has been cancelled

Runtime: memoize selectors in report modal, event discussion,
followed tag filter, and upload form to avoid unnecessary allocations.

Bundle: lazy-load crypto icons (500+ SVGs), remove datepicker CSS from
main entry (already in lazy wrapper), drop unused Inter 200/300 weights.

Stability: add null guards in compose upload and conversation components,
add keyboard handler to reply mentions button, surface local translation
unavailability instead of swallowing errors.

Fix eggplant button: preferences toggle was still writing to the old
showWrenchButton setting key, so the eggplant reaction button could
never be enabled.
This commit is contained in:
2026-02-14 17:06:15 +00:00
parent 389f060080
commit 63bee46b53
11 changed files with 65 additions and 33 deletions

View File

@ -84,7 +84,19 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
if (to.length > 2) { if (to.length > 2) {
accounts.push( accounts.push(
<span key='more' className='cursor-pointer hover:underline' role='button' onClick={handleOpenMentionsModal} tabIndex={0}> <span
key='more'
className='cursor-pointer hover:underline'
role='button'
onClick={handleOpenMentionsModal}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenMentionsModal(e as any);
}
}}
tabIndex={0}
>
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.length - 2 }} /> <FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.length - 2 }} />
</span>, </span>,
); );

View File

@ -68,7 +68,10 @@ interface IStatusFollowedTagInfo {
const StatusFollowedTagInfo: React.FC<IStatusFollowedTagInfo> = ({ status, avatarSize }) => { const StatusFollowedTagInfo: React.FC<IStatusFollowedTagInfo> = ({ status, avatarSize }) => {
const { data: followedTags } = useFollowedTags(); const { data: followedTags } = useFollowedTags();
const filteredTags = status.tags.filter(tag => followedTags?.some(followed => followed.name.toLowerCase() === tag.name.toLowerCase())); const filteredTags = useMemo(
() => status.tags.filter(tag => followedTags?.some(followed => followed.name.toLowerCase() === tag.name.toLowerCase())),
[status.tags, followedTags],
);
if (!filteredTags.length) { if (!filteredTags.length) {
return null; return null;

View File

@ -88,7 +88,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
localTranslationAvailability(status, intl.locale).then((availability) => { localTranslationAvailability(status, intl.locale).then((availability) => {
setLocalTranslate(availability === 'unavailable' ? false : availability); setLocalTranslate(availability === 'unavailable' ? false : availability);
if (availability) setLanguageModelAvailability(status.language!, intl.locale, availability); if (availability) setLanguageModelAvailability(status.language!, intl.locale, availability);
}).catch(() => {}); }).catch(() => setLocalTranslate(false));
}, [status.language, intl.locale]); }, [status.language, intl.locale]);
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => { const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { changeMediaOrder } from '@/actions/compose'; import { changeMediaOrder } from '@/actions/compose';
import HStack from '@/components/ui/hstack'; import HStack from '@/components/ui/hstack';
@ -19,7 +19,7 @@ const UploadForm: React.FC<IUploadForm> = ({ composeId, onSubmit }) => {
const { isUploading, mediaAttachments } = useCompose(composeId); const { isUploading, mediaAttachments } = useCompose(composeId);
const mediaIds = mediaAttachments.map((item) => item.id); const mediaIds = useMemo(() => mediaAttachments.map((item) => item.id), [mediaAttachments]);
const dragItem = useRef<string | null>(); const dragItem = useRef<string | null>();
const dragOverItem = useRef<string | null>(); const dragOverItem = useRef<string | null>();

View File

@ -19,9 +19,10 @@ const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDr
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { pleroma: { metadata: { description_limit: descriptionLimit } } } = useInstance(); const { pleroma: { metadata: { description_limit: descriptionLimit } } } = useInstance();
const media = useCompose(composeId).mediaAttachments.find(item => item.id === id)!; const media = useCompose(composeId).mediaAttachments.find(item => item.id === id);
const handleDescriptionChange = (description: string, position?: [number, number]) => { const handleDescriptionChange = (description: string, position?: [number, number]) => {
if (!media) return;
return dispatch(changeUploadCompose(composeId, media.id, { return dispatch(changeUploadCompose(composeId, media.id, {
description, description,
focus: position ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` : undefined, focus: position ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` : undefined,
@ -29,6 +30,7 @@ const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDr
}; };
const handleDelete = () => { const handleDelete = () => {
if (!media) return;
dispatch(undoUploadCompose(composeId, media.id)); dispatch(undoUploadCompose(composeId, media.id));
}; };
@ -40,6 +42,8 @@ const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDr
onDragEnter(id); onDragEnter(id);
}, [onDragEnter, id]); }, [onDragEnter, id]);
if (!media) return null;
return ( return (
<Upload <Upload
media={media} media={media}

View File

@ -18,11 +18,14 @@ const Conversation: React.FC<IConversation> = ({ conversationId, onMoveUp, onMov
const navigate = useNavigate(); const navigate = useNavigate();
const conversation = useAppSelector((state) => const conversation = useAppSelector((state) =>
state.conversations.items.find(x => x.id === conversationId)!, state.conversations.items.find(x => x.id === conversationId),
); );
const accounts = useAppSelector((state) => const accounts = useAppSelector((state) =>
conversation.accounts.map((accountId: string) => selectAccount(state, accountId)!), conversation?.accounts.map((accountId: string) => selectAccount(state, accountId)!) ?? [],
); );
if (!conversation) return null;
const unread = conversation.unread; const unread = conversation.unread;
const lastStatusId = conversation.last_status; const lastStatusId = conversation.last_status;

View File

@ -1,12 +1,11 @@
import genericIcon from 'cryptocurrency-icons/svg/color/generic.svg'; import genericIcon from 'cryptocurrency-icons/svg/color/generic.svg';
import React from 'react'; import React, { useEffect, useState } from 'react';
/** Get crypto icon URL by ticker symbol, or fall back to generic icon */ const modules: Record<string, () => Promise<any>> = import.meta.glob(
const getIcon = (ticker: string): string => { '../../../../node_modules/cryptocurrency-icons/svg/color/*.svg',
const modules: Record<string, any> = import.meta.glob('../../../../node_modules/cryptocurrency-icons/svg/color/*.svg', { eager: true }); );
const key = `../../../../node_modules/cryptocurrency-icons/svg/color/${ticker}.svg`;
return modules[key]?.default || genericIcon; const iconCache: Record<string, string> = {};
};
interface ICryptoIcon { interface ICryptoIcon {
ticker: string; ticker: string;
@ -15,14 +14,26 @@ interface ICryptoIcon {
imgClassName?: string; imgClassName?: string;
} }
const CryptoIcon: React.FC<ICryptoIcon> = ({ ticker, title, className, imgClassName }): JSX.Element => ( const CryptoIcon: React.FC<ICryptoIcon> = ({ ticker, title, className, imgClassName }) => {
const [icon, setIcon] = useState<string>(iconCache[ticker] || genericIcon);
useEffect(() => {
const key = `../../../../node_modules/cryptocurrency-icons/svg/color/${ticker}.svg`;
if (iconCache[ticker]) {
setIcon(iconCache[ticker]);
} else if (modules[key]) {
modules[key]().then((mod) => {
iconCache[ticker] = mod.default;
setIcon(mod.default);
}).catch(() => {});
}
}, [ticker]);
return (
<div className={className}> <div className={className}>
<img <img className={imgClassName} src={icon} alt={title || ticker} />
className={imgClassName}
src={getIcon(ticker)}
alt={title || ticker}
/>
</div> </div>
); );
};
export { CryptoIcon as default }; export { CryptoIcon as default };

View File

@ -331,8 +331,8 @@ const Preferences = () => {
</ListItem> </ListItem>
{features.emojiReacts && ( {features.emojiReacts && (
<ListItem label={<FormattedMessage id='preferences.fields.wrench_label' defaultMessage='Display wrench reaction button' />} > <ListItem label={<FormattedMessage id='preferences.fields.eggplant_label' defaultMessage='Display eggplant reaction button' />} >
<SettingToggle settings={settings} settingPath={['showWrenchButton']} onChange={onToggleChange} /> <SettingToggle settings={settings} settingPath={['showEggplantButton']} onChange={onToggleChange} />
</ListItem> </ListItem>
)} )}

View File

@ -10,8 +10,6 @@ import * as BuildConfig from '@/build-config';
import PlFe from '@/init/pl-fe'; import PlFe from '@/init/pl-fe';
import { printConsoleWarning } from '@/utils/console'; import { printConsoleWarning } from '@/utils/console';
import '@fontsource/inter/200.css';
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css'; import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css'; import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css'; import '@fontsource/inter/600.css';
@ -19,7 +17,6 @@ import '@fontsource/inter/700.css';
import '@fontsource/inter/900.css'; import '@fontsource/inter/900.css';
import '@fontsource/roboto-mono/400.css'; import '@fontsource/roboto-mono/400.css';
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css'; import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
import 'react-datepicker/dist/react-datepicker.css';
import './styles/i18n.css'; import './styles/i18n.css';
import './styles/application.scss'; import './styles/application.scss';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Button from '@/components/ui/button'; import Button from '@/components/ui/button';
@ -46,7 +46,8 @@ const OtherActionsStep = ({
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
const statusIds = useAppSelector((state) => [...new Set([...state.timelines[`account:${account.id}:with_replies`]!.items, ...selectedStatusIds])]); const timelineItems = useAppSelector((state) => state.timelines[`account:${account.id}:with_replies`]?.items);
const statusIds = useMemo(() => [...new Set([...(timelineItems || []), ...selectedStatusIds])], [timelineItems, selectedStatusIds]);
const isBlocked = block; const isBlocked = block;
const isForward = forward; const isForward = forward;
const canForward = !account.local && features.federating; const canForward = !account.local && features.federating;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { eventDiscussionCompose } from '@/actions/compose'; import { eventDiscussionCompose } from '@/actions/compose';
@ -32,7 +32,8 @@ const EventDiscussionPage: React.FC = () => {
const me = useAppSelector((state) => state.me); const me = useAppSelector((state) => state.me);
const descendantsIds = useAppSelector(state => getDescendantsIds(state, statusId).filter(id => id !== statusId)); const allDescendantsIds = useAppSelector(state => getDescendantsIds(state, statusId));
const descendantsIds = useMemo(() => allDescendantsIds.filter(id => id !== statusId), [allDescendantsIds, statusId]);
const [isLoaded, setIsLoaded] = useState<boolean>(!!status); const [isLoaded, setIsLoaded] = useState<boolean>(!!status);