From 63bee46b53eaa7e6b341f6271dede6b6a88b3c27 Mon Sep 17 00:00:00 2001 From: matty Date: Sat, 14 Feb 2026 17:06:15 +0000 Subject: [PATCH] perf/UX audit round 2, fix eggplant reaction button 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. --- .../src/components/status-reply-mentions.tsx | 14 +++++- packages/pl-fe/src/components/status.tsx | 5 ++- .../pl-fe/src/components/translate-button.tsx | 2 +- .../compose/components/upload-form.tsx | 4 +- .../features/compose/components/upload.tsx | 6 ++- .../conversations/components/conversation.tsx | 7 ++- .../crypto-donate/components/crypto-icon.tsx | 43 ++++++++++++------- .../pl-fe/src/features/preferences/index.tsx | 4 +- packages/pl-fe/src/main.tsx | 3 -- .../report-modal/steps/other-actions-step.tsx | 5 ++- .../src/pages/statuses/event-discussion.tsx | 5 ++- 11 files changed, 65 insertions(+), 33 deletions(-) diff --git a/packages/pl-fe/src/components/status-reply-mentions.tsx b/packages/pl-fe/src/components/status-reply-mentions.tsx index 941e69ef4..cc45be317 100644 --- a/packages/pl-fe/src/components/status-reply-mentions.tsx +++ b/packages/pl-fe/src/components/status-reply-mentions.tsx @@ -84,7 +84,19 @@ const StatusReplyMentions: React.FC = ({ status, hoverable if (to.length > 2) { accounts.push( - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleOpenMentionsModal(e as any); + } + }} + tabIndex={0} + > , ); diff --git a/packages/pl-fe/src/components/status.tsx b/packages/pl-fe/src/components/status.tsx index 4cc55f9a7..e89dc2659 100644 --- a/packages/pl-fe/src/components/status.tsx +++ b/packages/pl-fe/src/components/status.tsx @@ -68,7 +68,10 @@ interface IStatusFollowedTagInfo { const StatusFollowedTagInfo: React.FC = ({ status, avatarSize }) => { 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) { return null; diff --git a/packages/pl-fe/src/components/translate-button.tsx b/packages/pl-fe/src/components/translate-button.tsx index 27ab29d05..bbcee51f9 100644 --- a/packages/pl-fe/src/components/translate-button.tsx +++ b/packages/pl-fe/src/components/translate-button.tsx @@ -88,7 +88,7 @@ const TranslateButton: React.FC = ({ status }) => { localTranslationAvailability(status, intl.locale).then((availability) => { setLocalTranslate(availability === 'unavailable' ? false : availability); if (availability) setLanguageModelAvailability(status.language!, intl.locale, availability); - }).catch(() => {}); + }).catch(() => setLocalTranslate(false)); }, [status.language, intl.locale]); const handleTranslate: React.MouseEventHandler = (e) => { diff --git a/packages/pl-fe/src/features/compose/components/upload-form.tsx b/packages/pl-fe/src/features/compose/components/upload-form.tsx index 34431024a..5f006caf0 100644 --- a/packages/pl-fe/src/features/compose/components/upload-form.tsx +++ b/packages/pl-fe/src/features/compose/components/upload-form.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { changeMediaOrder } from '@/actions/compose'; import HStack from '@/components/ui/hstack'; @@ -19,7 +19,7 @@ const UploadForm: React.FC = ({ composeId, onSubmit }) => { const { isUploading, mediaAttachments } = useCompose(composeId); - const mediaIds = mediaAttachments.map((item) => item.id); + const mediaIds = useMemo(() => mediaAttachments.map((item) => item.id), [mediaAttachments]); const dragItem = useRef(); const dragOverItem = useRef(); diff --git a/packages/pl-fe/src/features/compose/components/upload.tsx b/packages/pl-fe/src/features/compose/components/upload.tsx index d8db1717d..9bea51887 100644 --- a/packages/pl-fe/src/features/compose/components/upload.tsx +++ b/packages/pl-fe/src/features/compose/components/upload.tsx @@ -19,9 +19,10 @@ const UploadCompose: React.FC = ({ composeId, id, onSubmit, onDr const dispatch = useAppDispatch(); 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]) => { + if (!media) return; return dispatch(changeUploadCompose(composeId, media.id, { description, 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 = ({ composeId, id, onSubmit, onDr }; const handleDelete = () => { + if (!media) return; dispatch(undoUploadCompose(composeId, media.id)); }; @@ -40,6 +42,8 @@ const UploadCompose: React.FC = ({ composeId, id, onSubmit, onDr onDragEnter(id); }, [onDragEnter, id]); + if (!media) return null; + return ( = ({ conversationId, onMoveUp, onMov const navigate = useNavigate(); const conversation = useAppSelector((state) => - state.conversations.items.find(x => x.id === conversationId)!, + state.conversations.items.find(x => x.id === conversationId), ); 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 lastStatusId = conversation.last_status; diff --git a/packages/pl-fe/src/features/crypto-donate/components/crypto-icon.tsx b/packages/pl-fe/src/features/crypto-donate/components/crypto-icon.tsx index 2c7e79b20..0bd628cb2 100644 --- a/packages/pl-fe/src/features/crypto-donate/components/crypto-icon.tsx +++ b/packages/pl-fe/src/features/crypto-donate/components/crypto-icon.tsx @@ -1,12 +1,11 @@ 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 getIcon = (ticker: string): string => { - const modules: Record = 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 modules: Record Promise> = import.meta.glob( + '../../../../node_modules/cryptocurrency-icons/svg/color/*.svg', +); + +const iconCache: Record = {}; interface ICryptoIcon { ticker: string; @@ -15,14 +14,26 @@ interface ICryptoIcon { imgClassName?: string; } -const CryptoIcon: React.FC = ({ ticker, title, className, imgClassName }): JSX.Element => ( -
- {title -
-); +const CryptoIcon: React.FC = ({ ticker, title, className, imgClassName }) => { + const [icon, setIcon] = useState(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 ( +
+ {title +
+ ); +}; export { CryptoIcon as default }; diff --git a/packages/pl-fe/src/features/preferences/index.tsx b/packages/pl-fe/src/features/preferences/index.tsx index 7f868faec..ae0de81c6 100644 --- a/packages/pl-fe/src/features/preferences/index.tsx +++ b/packages/pl-fe/src/features/preferences/index.tsx @@ -331,8 +331,8 @@ const Preferences = () => { {features.emojiReacts && ( - } > - + } > + )} diff --git a/packages/pl-fe/src/main.tsx b/packages/pl-fe/src/main.tsx index fc9463d09..532c3d7d6 100644 --- a/packages/pl-fe/src/main.tsx +++ b/packages/pl-fe/src/main.tsx @@ -10,8 +10,6 @@ import * as BuildConfig from '@/build-config'; import PlFe from '@/init/pl-fe'; import { printConsoleWarning } from '@/utils/console'; -import '@fontsource/inter/200.css'; -import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; import '@fontsource/inter/500.css'; import '@fontsource/inter/600.css'; @@ -19,7 +17,6 @@ import '@fontsource/inter/700.css'; import '@fontsource/inter/900.css'; import '@fontsource/roboto-mono/400.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/application.scss'; diff --git a/packages/pl-fe/src/modals/report-modal/steps/other-actions-step.tsx b/packages/pl-fe/src/modals/report-modal/steps/other-actions-step.tsx index fdc491810..a7a705854 100644 --- a/packages/pl-fe/src/modals/report-modal/steps/other-actions-step.tsx +++ b/packages/pl-fe/src/modals/report-modal/steps/other-actions-step.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import Button from '@/components/ui/button'; @@ -46,7 +46,8 @@ const OtherActionsStep = ({ const features = useFeatures(); 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 isForward = forward; const canForward = !account.local && features.federating; diff --git a/packages/pl-fe/src/pages/statuses/event-discussion.tsx b/packages/pl-fe/src/pages/statuses/event-discussion.tsx index 0c2cbbcde..9949c330c 100644 --- a/packages/pl-fe/src/pages/statuses/event-discussion.tsx +++ b/packages/pl-fe/src/pages/statuses/event-discussion.tsx @@ -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 { eventDiscussionCompose } from '@/actions/compose'; @@ -32,7 +32,8 @@ const EventDiscussionPage: React.FC = () => { 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(!!status);