pl-fe: more attempts on optimization

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-01-07 19:45:17 +01:00
parent 03e7227a12
commit 1cc92937a7
18 changed files with 398 additions and 406 deletions

View File

@ -4,7 +4,6 @@ import { getClient } from '../api';
import { importEntities } from './importer';
import type { Status } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST' as const;
@ -14,26 +13,26 @@ const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST' as const;
const noOp = () => () => new Promise(f => f(undefined));
const emojiReact = (status: Pick<Status, 'id'>, emoji: string, custom?: string) =>
const emojiReact = (statusId: string, emoji: string, custom?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(emojiReactRequest(status.id, emoji, custom));
dispatch(emojiReactRequest(statusId, emoji, custom));
return getClient(getState).statuses.createStatusReaction(status.id, emoji).then((response) => {
return getClient(getState).statuses.createStatusReaction(statusId, emoji).then((response) => {
dispatch(importEntities({ statuses: [response] }));
}).catch((error) => {
dispatch(emojiReactFail(status.id, emoji, error));
dispatch(emojiReactFail(statusId, emoji, error));
});
};
const unEmojiReact = (status: Pick<Status, 'id'>, emoji: string) =>
const unEmojiReact = (statusId: string, emoji: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(unEmojiReactRequest(status.id, emoji));
dispatch(unEmojiReactRequest(statusId, emoji));
return getClient(getState).statuses.deleteStatusReaction(status.id, emoji).then(response => {
return getClient(getState).statuses.deleteStatusReaction(statusId, emoji).then(response => {
dispatch(importEntities({ statuses: [response] }));
});
};

View File

@ -19,7 +19,7 @@ interface IHoverAccountWrapper {
}
/** Makes a profile hover card appear when the wrapped element is hovered. */
const HoverAccountWrapper: React.FC<IHoverAccountWrapper> = ({ accountId, children, element: Elem = 'div', className }) => {
const HoverAccountWrapper: React.FC<IHoverAccountWrapper> = React.memo(({ accountId, children, element: Elem = 'div', className }) => {
const dispatch = useAppDispatch();
const { openAccountHoverCard, closeAccountHoverCard } = useAccountHoverCardStore();
@ -54,6 +54,6 @@ const HoverAccountWrapper: React.FC<IHoverAccountWrapper> = ({ accountId, childr
{children}
</Elem>
);
};
});
export { HoverAccountWrapper as default, showAccountHoverCard };

View File

@ -25,7 +25,7 @@ interface ISidebarNavigationLink {
}
/** Desktop sidebar navigation link. */
const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
const SidebarNavigationLink = React.memo(React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
const { icon, activeIcon, text, to = '', count, countMax, onClick } = props;
const isActive = location.pathname === to;
@ -72,6 +72,6 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
<Text weight='semibold' theme='inherit'>{text}</Text>
</NavLink>
);
});
}), (prevProps, nextProps) => prevProps.count === nextProps.count);
export { SidebarNavigationLink as default };

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Icon from 'pl-fe/components/ui/icon';
@ -57,7 +57,7 @@ const SidebarNavigation = React.memo(() => {
const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated;
const makeMenu = (): Menu => {
const menu = useMemo((): Menu => {
const menu: Menu = [];
if (account) {
@ -155,9 +155,7 @@ const SidebarNavigation = React.memo(() => {
}
return menu;
};
const menu = makeMenu();
}, [!!account, features, isDeveloper, followRequestsCount, interactionRequestsCount, scheduledStatusCount, draftCount]);
return (
<Stack space={4}>

View File

@ -1,5 +1,5 @@
import { type CustomEmoji, GroupRoles } from 'pl-api';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useRouteMatch } from 'react-router-dom';
@ -484,15 +484,15 @@ const WrenchButton: React.FC<IActionButton> = ({
const handleWrenchClick: React.EventHandler<React.MouseEvent> = (e) => {
if (wrenches?.me) {
dispatch(unEmojiReact(status, '🔧'));
dispatch(unEmojiReact(status.id, '🔧'));
} else {
dispatch(emojiReact(status, '🔧'));
dispatch(emojiReact(status.id, '🔧'));
}
};
const handleWrenchLongPress = () => {
if (features.customEmojiReacts && hasLongerWrench) {
dispatch(emojiReact(status, hasLongerWrench.shortcode, hasLongerWrench.url));
dispatch(emojiReact(status.id, hasLongerWrench.shortcode, hasLongerWrench.url));
} else if (wrenches?.count) {
openModal('REACTIONS', { statusId: status.id, reaction: wrenches.name });
}
@ -524,7 +524,7 @@ const EmojiPickerButton: React.FC<Omit<IActionButton, 'onOpenUnauthorizedModal'>
const features = useFeatures();
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
};
return me && !withLabels && features.emojiReacts && (
@ -613,178 +613,177 @@ const MenuButton: React.FC<IMenuButton> = ({
const isStaff = account ? account.is_admin || account.is_moderator : false;
const isAdmin = account ? account.is_admin : false;
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status));
};
const handleBookmarkFolderClick = () => {
openModal('SELECT_BOOKMARK_FOLDER', {
statusId: status.id,
});
};
const doDeleteStatus = (withRedraft = false) => {
if (!deleteModal) {
dispatch(deleteStatus(status.id, withRedraft));
} else {
openModal('CONFIRM', {
heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading),
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)),
});
}
};
const handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
doDeleteStatus();
};
const handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
doDeleteStatus(true);
};
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
if (status.event) history.push(`/@${status.account.acct}/events/${status.id}/edit`);
else dispatch(editStatus(status.id));
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(togglePin(status));
};
const handleReblogClick: React.EventHandler<React.MouseEvent> = (e) => {
const modalReblog = () => dispatch(toggleReblog(status));
if ((e && e.shiftKey) || !boostModal) {
modalReblog();
} else {
openModal('BOOST', { statusId: status.id, onReblog: modalReblog });
}
};
const handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(mentionCompose(status.account));
};
const handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(directCompose(status.account));
};
const handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.account;
getOrCreateChatByAccountId(account.id)
.then((chat) => history.push(`/chats/${chat.id}`))
.catch(() => {});
};
const handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
openModal('MUTE', { accountId: status.account.id });
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.account;
openModal('CONFIRM', {
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.id)),
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
},
});
};
const handleEmbed = () => {
openModal('EMBED', {
url: status.url,
onError: (error: any) => toast.showAlertForError(error),
});
};
const handleOpenReactionsModal = () => {
openModal('REACTIONS', { statusId: status.id });
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(ReportableEntities.STATUS, status.account, { status }));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleMuteStatus(status));
};
const handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
const { uri } = status;
copy(uri);
};
const onModerate: React.MouseEventHandler = (e) => {
const account = status.account;
openModal('ACCOUNT_MODERATION', { accountId: account.id });
};
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(deleteStatusModal(intl, status.id));
};
const handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
};
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account;
openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: <strong className='break-words'>{account.username}</strong> }),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
deleteGroupStatus.mutate(status.id, {
onSuccess() {
dispatch(deleteFromTimelines(status.id));
},
});
},
});
};
const handleBlockFromGroup = () => {
openModal('CONFIRM', {
heading: intl.formatMessage(messages.groupBlockFromGroupHeading),
message: intl.formatMessage(messages.groupBlockFromGroupMessage, { name: status.account.username }),
confirm: intl.formatMessage(messages.groupBlockConfirm),
onConfirm: () => {
blockGroupMember(undefined, {
onSuccess: () => {
toast.success(intl.formatMessage(messages.blocked, { name: account?.acct }));
},
});
},
});
};
const handleIgnoreLanguage = () => {
dispatch(changeSetting(['autoTranslate'], [...knownLanguages, status.language], { showAlert: true }));
};
const handleTranslate = () => {
if (targetLanguage) {
hideTranslation(status.id);
} else {
fetchTranslation(status.id, intl.locale);
}
};
const _makeMenu = (publicStatus: boolean) => {
const menu = useMemo(() => {
const mutingConversation = status.muted;
const ownAccount = status.account_id === me;
const username = status.account.username;
const account = status.account;
const { username, local: localAccount } = status.account;
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status));
};
const handleBookmarkFolderClick = () => {
openModal('SELECT_BOOKMARK_FOLDER', {
statusId: status.id,
});
};
const doDeleteStatus = (withRedraft = false) => {
if (!deleteModal) {
dispatch(deleteStatus(status.id, withRedraft));
} else {
openModal('CONFIRM', {
heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading),
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)),
});
}
};
const handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
doDeleteStatus();
};
const handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
doDeleteStatus(true);
};
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
if (status.event) history.push(`/@${status.account.acct}/events/${status.id}/edit`);
else dispatch(editStatus(status.id));
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(togglePin(status));
};
const handleReblogClick: React.EventHandler<React.MouseEvent> = (e) => {
const modalReblog = () => dispatch(toggleReblog(status));
if ((e && e.shiftKey) || !boostModal) {
modalReblog();
} else {
openModal('BOOST', { statusId: status.id, onReblog: modalReblog });
}
};
const handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(mentionCompose(status.account));
};
const handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(directCompose(status.account));
};
const handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.account;
getOrCreateChatByAccountId(account.id)
.then((chat) => history.push(`/chats/${chat.id}`))
.catch(() => {});
};
const handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
openModal('MUTE', { accountId: status.account.id });
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.account;
openModal('CONFIRM', {
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.id)),
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
},
});
};
const handleEmbed = () => {
openModal('EMBED', {
url: status.url,
onError: (error: any) => toast.showAlertForError(error),
});
};
const handleOpenReactionsModal = () => {
openModal('REACTIONS', { statusId: status.id });
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(ReportableEntities.STATUS, status.account, { status }));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleMuteStatus(status));
};
const handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
const { uri } = status;
copy(uri);
};
const onModerate: React.MouseEventHandler = (e) => {
const account = status.account;
openModal('ACCOUNT_MODERATION', { accountId: account.id });
};
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(deleteStatusModal(intl, status.id));
};
const handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
};
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account;
openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: <strong className='break-words'>{account.username}</strong> }),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
deleteGroupStatus.mutate(status.id, {
onSuccess() {
dispatch(deleteFromTimelines(status.id));
},
});
},
});
};
const handleBlockFromGroup = () => {
openModal('CONFIRM', {
heading: intl.formatMessage(messages.groupBlockFromGroupHeading),
message: intl.formatMessage(messages.groupBlockFromGroupMessage, { name: status.account.username }),
confirm: intl.formatMessage(messages.groupBlockConfirm),
onConfirm: () => {
blockGroupMember(undefined, {
onSuccess: () => {
toast.success(intl.formatMessage(messages.blocked, { name: account?.acct }));
},
});
},
});
};
const handleIgnoreLanguage = () => {
dispatch(changeSetting(['autoTranslate'], [...knownLanguages, status.language], { showAlert: true }));
};
const handleTranslate = () => {
if (targetLanguage) {
hideTranslation(status.id);
} else {
fetchTranslation(status.id, intl.locale);
}
};
const menu: Menu = [];
@ -803,7 +802,7 @@ const MenuButton: React.FC<IMenuButton> = ({
icon: require('@tabler/icons/outline/clipboard-copy.svg'),
});
if (features.embeds && account.local) {
if (features.embeds && localAccount) {
menu.push({
text: intl.formatMessage(messages.embed),
action: handleEmbed,
@ -842,7 +841,7 @@ const MenuButton: React.FC<IMenuButton> = ({
});
}
if (features.federating && !account.local) {
if (features.federating && !localAccount) {
const { hostname: domain } = new URL(status.uri);
menu.push({
text: intl.formatMessage(messages.external, { domain }),
@ -1026,11 +1025,9 @@ const MenuButton: React.FC<IMenuButton> = ({
}
return menu;
};
}, [me, targetLanguage, status.muted, status.emoji_reactions.length > 0, status.pinned, status.reblogged]);
const menu = _makeMenu(publicStatus);
return (
return useMemo(() => (
<DropdownMenu items={menu}>
<StatusActionButton
title={intl.formatMessage(messages.more)}
@ -1038,7 +1035,7 @@ const MenuButton: React.FC<IMenuButton> = ({
theme={statusActionButtonTheme}
/>
</DropdownMenu>
);
), [menu, statusActionButtonTheme]);
};
interface IStatusActionBar {
@ -1065,18 +1062,20 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const me = useAppSelector(state => state.me);
if (!status) {
return null;
}
const publicStatus = useMemo(() => status ? ['public', 'unlisted', 'group'].includes(status.visibility) : false, [status.visibility]);
const onOpenUnauthorizedModal = (action?: UnauthorizedModalAction) => {
const onContainerClick: React.MouseEventHandler<HTMLDivElement> = useCallback((e) => e.stopPropagation(), []);
const onOpenUnauthorizedModal = useCallback((action?: UnauthorizedModalAction) => {
openModal('UNAUTHORIZED', {
action,
ap_id: status.url,
});
};
}, []);
const publicStatus = useMemo(() => ['public', 'unlisted', 'group'].includes(status.visibility), [status.visibility]);
if (!status) {
return null;
}
const spacing: {
[key: string]: React.ComponentProps<typeof HStack>['space'];
@ -1087,79 +1086,77 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
return (
<HStack data-testid='status-action-bar'>
<HStack
justifyContent={space === 'lg' ? 'between' : undefined}
space={spacing[space]}
grow={space === 'lg'}
onClick={e => e.stopPropagation()}
alignItems='center'
>
<ReplyButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
rebloggedBy={rebloggedBy}
/>
<HStack
justifyContent={space === 'lg' ? 'between' : undefined}
space={spacing[space]}
grow={space === 'lg'}
onClick={onContainerClick}
alignItems='center'
>
<ReplyButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
rebloggedBy={rebloggedBy}
/>
<ReblogButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
publicStatus={publicStatus}
/>
<ReblogButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
publicStatus={publicStatus}
/>
<FavouriteButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<FavouriteButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<DislikeButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<DislikeButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<WrenchButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<WrenchButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
/>
<EmojiPickerButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
/>
<EmojiPickerButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
/>
<ShareButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
/>
<ShareButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
/>
<MenuButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
expandable={expandable}
fromBookmarks={fromBookmarks}
publicStatus={publicStatus}
/>
</HStack>
<MenuButton
status={status}
statusActionButtonTheme={statusActionButtonTheme}
withLabels={withLabels}
me={me}
onOpenUnauthorizedModal={onOpenUnauthorizedModal}
expandable={expandable}
fromBookmarks={fromBookmarks}
publicStatus={publicStatus}
/>
</HStack>
);
};

View File

@ -23,7 +23,7 @@ interface IStatusActionCounter {
}
/** Action button numerical counter, eg "5" likes. */
const StatusActionCounter: React.FC<IStatusActionCounter> = ({ count = 0 }): JSX.Element => {
const StatusActionCounter: React.FC<IStatusActionCounter> = React.memo(({ count = 0 }): JSX.Element => {
const { demetricator } = useSettings();
return (
@ -31,7 +31,7 @@ const StatusActionCounter: React.FC<IStatusActionCounter> = ({ count = 0 }): JSX
<AnimatedNumber value={count} obfuscate={demetricator} short />
</Text>
);
};
});
interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
iconClassName?: string;

View File

@ -20,7 +20,7 @@ interface IStatusLanguagePicker {
showLabel?: boolean;
}
const StatusLanguagePicker: React.FC<IStatusLanguagePicker> = ({ status, showLabel }) => {
const StatusLanguagePicker: React.FC<IStatusLanguagePicker> = React.memo(({ status, showLabel }) => {
const intl = useIntl();
const { statuses, setStatusLanguage } = useStatusMetaStore();
@ -55,7 +55,7 @@ const StatusLanguagePicker: React.FC<IStatusLanguagePicker> = ({ status, showLab
</DropdownMenu>
</>
);
};
});
export {
StatusLanguagePicker as default,

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import LoadGap from 'pl-fe/components/load-gap';
@ -128,45 +128,45 @@ const StatusList: React.FC<IStatusList> = ({
);
};
const renderFeaturedStatuses = (): React.ReactNode[] => {
if (!featuredStatusIds) return [];
return featuredStatusIds.map(statusId => (
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'default'}
/>
));
};
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.length > 0) {
return statusIds.reduce((acc, statusId, index) => {
if (statusId === null) {
const gap = renderLoadGap(index);
if (gap) {
acc.push(gap);
const scrollableContent = useMemo(() => {
const renderFeaturedStatuses = (): React.ReactNode[] => {
if (!featuredStatusIds) return [];
return featuredStatusIds.map(statusId => (
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'default'}
/>
));
};
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.length > 0) {
return statusIds.reduce((acc, statusId, index) => {
if (statusId === null) {
const gap = renderLoadGap(index);
if (gap) {
acc.push(gap);
}
} else if (statusId.startsWith('末pending-')) {
acc.push(renderPendingStatus(statusId));
} else {
acc.push(renderStatus(statusId));
}
} else if (statusId.startsWith('末pending-')) {
acc.push(renderPendingStatus(statusId));
} else {
acc.push(renderStatus(statusId));
}
return acc;
}, [] as React.ReactNode[]);
} else {
return [];
}
};
return acc;
}, [] as React.ReactNode[]);
} else {
return [];
}
};
const renderScrollableContent = () => {
const featuredStatuses = renderFeaturedStatuses();
const statuses = renderStatuses();
@ -175,7 +175,7 @@ const StatusList: React.FC<IStatusList> = ({
} else {
return statuses;
}
};
}, [featuredStatusIds, statusIds, isLoading, timelineId, showGroup, divideType]);
if (isPartial) {
return (
@ -209,7 +209,7 @@ const StatusList: React.FC<IStatusList> = ({
})}
{...other}
>
{renderScrollableContent()}
{scrollableContent}
</ScrollableList>
);
};

View File

@ -33,13 +33,13 @@ interface IStatusReactionsBar {
}
interface IStatusReaction {
status: Pick<SelectedStatus, 'id'>;
statusId: string;
reaction: EmojiReaction;
obfuscate?: boolean;
unauthenticated?: boolean;
}
const StatusReaction: React.FC<IStatusReaction> = ({ reaction, status, obfuscate, unauthenticated }) => {
const StatusReaction: React.FC<IStatusReaction> = ({ reaction, statusId, obfuscate, unauthenticated }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
@ -51,7 +51,7 @@ const StatusReaction: React.FC<IStatusReaction> = ({ reaction, status, obfuscate
e.stopPropagation();
if ('vibrate' in navigator) navigator.vibrate(1);
openModal('REACTIONS', { statusId: status.id, reaction: reaction.name });
openModal('REACTIONS', { statusId: statusId, reaction: reaction.name });
});
if (!reaction.count) return null;
@ -61,11 +61,11 @@ const StatusReaction: React.FC<IStatusReaction> = ({ reaction, status, obfuscate
if (unauthenticated) {
if (!features.emojiReactsList) return;
openModal('REACTIONS', { statusId: status.id, reaction: reaction.name });
openModal('REACTIONS', { statusId, reaction: reaction.name });
} else if (reaction.me) {
dispatch(unEmojiReact(status, reaction.name));
dispatch(unEmojiReact(statusId, reaction.name));
} else {
dispatch(emojiReact(status, reaction.name, reaction.url));
dispatch(emojiReact(statusId, reaction.name, reaction.url));
}
};
@ -112,7 +112,7 @@ const StatusReactionsBar: React.FC<IStatusReactionsBar> = ({ status, collapsed }
const features = useFeatures();
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
};
if ((demetricator || status.emoji_reactions.length === 0) && collapsed) return null;
@ -125,7 +125,7 @@ const StatusReactionsBar: React.FC<IStatusReactionsBar> = ({ status, collapsed }
{sortedReactions.map((reaction) => reaction.count ? (
<StatusReaction
key={reaction.name}
status={status}
statusId={status.id}
reaction={reaction}
obfuscate={demetricator}
unauthenticated={!me}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
@ -83,7 +83,7 @@ const Status: React.FC<IStatus> = (props) => {
const didShowCard = useRef(false);
const node = useRef<HTMLDivElement>(null);
const getStatus = useCallback(makeGetStatus(), []);
const getStatus = useMemo(makeGetStatus, []);
const actualStatus = useAppSelector(state => status.reblog_id && getStatus(state, { id: status.reblog_id }) || status)!;
const isReblog = status.reblog_id;
@ -185,7 +185,7 @@ const Status: React.FC<IStatus> = (props) => {
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.length ? status.id : actualStatus.id));
const renderStatusInfo = () => {
const statusInfo = useMemo(() => {
if (isReblog && showGroup && group) {
return (
<StatusInfo
@ -294,7 +294,7 @@ const Status: React.FC<IStatus> = (props) => {
/>
);
}
};
}, [status.accounts, group?.id]);
if (!status) return null;
@ -366,7 +366,7 @@ const Status: React.FC<IStatus> = (props) => {
})}
data-id={status.id}
>
{renderStatusInfo()}
{statusInfo}
<AccountContainer
key={actualStatus.account_id}
@ -382,7 +382,7 @@ const Status: React.FC<IStatus> = (props) => {
avatarSize={avatarSize}
items={(
<>
<StatusTypeIcon status={actualStatus} />
<StatusTypeIcon visibility={actualStatus.visibility} />
<StatusLanguagePicker status={actualStatus} />
</>
)}

View File

@ -24,7 +24,7 @@ const messages = defineMessages({
sidebar: { id: 'navigation.sidebar', defaultMessage: 'Open sidebar' },
});
const ThumbNavigation: React.FC = (): JSX.Element => {
const ThumbNavigation: React.FC = React.memo((): JSX.Element => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
@ -132,6 +132,6 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
)}
</div>
);
};
});
export { ThumbNavigation as default };

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import Status, { IStatus } from 'pl-fe/components/status';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
@ -16,13 +16,13 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
* @deprecated Use the Status component directly.
*/
const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, contextType, ...rest } = props;
const { id, contextType } = props;
const getStatus = useCallback(makeGetStatus(), []);
const getStatus = useMemo(makeGetStatus, []);
const status = useAppSelector(state => getStatus(state, { id, contextType }));
if (status) {
return <Status status={status} {...rest} />;
return <Status {...props} status={status} />;
} else {
return null;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
@ -31,38 +31,33 @@ const HomeTimeline: React.FC = () => {
const isPartial = useAppSelector(state => state.timelines.home?.isPartial === true);
const handleLoadMore = (maxId: string) => {
dispatch(fetchHomeTimeline(true));
};
// Mastodon generates the feed in Redis, and can return a partial timeline
// (HTTP 206) for new users. Poll until we get a full page of results.
const checkIfReloadNeeded = () => {
const checkIfReloadNeeded = useCallback((isPartial: boolean) => {
if (isPartial) {
polling.current = setInterval(() => {
dispatch(fetchHomeTimeline());
}, 3000);
} else {
stopPolling();
if (polling.current) {
clearInterval(polling.current);
polling.current = null;
}
}
};
const stopPolling = () => {
if (polling.current) {
clearInterval(polling.current);
polling.current = null;
}
};
const handleRefresh = () => dispatch(fetchHomeTimeline(true));
useEffect(() => {
checkIfReloadNeeded();
return () => {
stopPolling();
if (polling.current) {
clearInterval(polling.current);
polling.current = null;
}
};
}, [isPartial]);
}, []);
const handleLoadMore = useCallback(() => dispatch(fetchHomeTimeline(true)), []);
const handleRefresh = useCallback(() => dispatch(fetchHomeTimeline(false)), []);
useEffect(() => checkIfReloadNeeded(isPartial), [isPartial]);
return (
<Column className='py-0' label={intl.formatMessage(messages.title)} transparent={!isMobile} withHeader={false}>

View File

@ -151,7 +151,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
</Text>
</span>
<StatusTypeIcon status={actualStatus} />
<StatusTypeIcon visibility={actualStatus.visibility} />
<StatusLanguagePicker status={actualStatus} showLabel />
</HStack>

View File

@ -7,7 +7,7 @@ import Text from 'pl-fe/components/ui/text';
import type { Status } from 'pl-fe/normalizers/status';
interface IStatusTypeIcon {
status: Pick<Status, 'visibility'>;
visibility: Status['visibility'];
}
const messages: Record<string, MessageDescriptor> = defineMessages({
@ -29,11 +29,11 @@ const STATUS_TYPE_ICONS: Record<string, string> = {
subscribers: require('@tabler/icons/outline/coin.svg'),
};
const StatusTypeIcon: React.FC<IStatusTypeIcon> = ({ status }) => {
const StatusTypeIcon: React.FC<IStatusTypeIcon> = React.memo(({ visibility }) => {
const intl = useIntl();
const icon = STATUS_TYPE_ICONS[status.visibility];
const message = messages[status.visibility];
const icon = STATUS_TYPE_ICONS[visibility];
const message = messages[visibility];
if (!icon) return null;
@ -44,6 +44,6 @@ const StatusTypeIcon: React.FC<IStatusTypeIcon> = ({ status }) => {
<Icon title={message ? intl.formatMessage(message) : undefined} className='size-4 text-gray-700 dark:text-gray-600' src={icon} />
</>
);
};
});
export { StatusTypeIcon as default };

View File

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { defineMessages } from 'react-intl';
import { dequeueTimeline, scrollTopTimeline } from 'pl-fe/actions/timelines';
@ -31,7 +31,7 @@ const Timeline: React.FC<ITimeline> = ({
...rest
}) => {
const dispatch = useAppDispatch();
const getStatusIds = useCallback(makeGetStatusIds(), []);
const getStatusIds = useMemo(makeGetStatusIds, []);
const statusIds = useAppSelector(state => getStatusIds(state, { type: timelineId, prefix }));
const lastStatusId = statusIds.at(-1);

View File

@ -152,7 +152,7 @@ interface ISwitchingColumnsArea {
children: React.ReactNode;
}
const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) => {
const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ children }) => {
const instance = useInstance();
const features = useFeatures();
const { search } = useLocation();
@ -349,13 +349,13 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute layout={EmptyLayout} component={GenericNotFound} content={children} />
</Switch>
);
};
});
interface IUI {
children?: React.ReactNode;
}
const UI: React.FC<IUI> = ({ children }) => {
const UI: React.FC<IUI> = React.memo(({ children }) => {
const history = useHistory();
const dispatch = useAppDispatch();
const node = useRef<HTMLDivElement | null>(null);
@ -507,6 +507,6 @@ const UI: React.FC<IUI> = ({ children }) => {
</div>
</GlobalHotkeys>
);
};
});
export { UI as default };

View File

@ -121,49 +121,52 @@ const checkFiltered = (index: string, filters: Array<Filter>) =>
type APIStatus = { id: string; username?: string };
const makeGetStatus = () => createSelector(
[
(state: RootState, { id }: APIStatus) => state.statuses[id],
(state: RootState, { id }: APIStatus) => state.statuses[state.statuses[id]?.reblog_id || ''] || null,
(state: RootState, { id }: APIStatus) => state.statuses[state.statuses[id]?.quote_id || ''] || null,
(state: RootState, { id }: APIStatus) => {
const group = state.statuses[id]?.group_id;
if (group) return state.entities[Entities.GROUPS]?.store[group] as Group;
return undefined;
},
(state: RootState, { id }: APIStatus) => state.polls[id] || null,
(_state: RootState, { username }: APIStatus) => username,
getFilters,
(state: RootState) => state.me,
(state: RootState) => state.auth.client.features,
],
const makeGetStatus = () => {
console.log('making get status');
return createSelector(
[
(state: RootState, { id }: APIStatus) => state.statuses[id],
(state: RootState, { id }: APIStatus) => state.statuses[state.statuses[id]?.reblog_id || ''] || null,
(state: RootState, { id }: APIStatus) => state.statuses[state.statuses[id]?.quote_id || ''] || null,
(state: RootState, { id }: APIStatus) => {
const group = state.statuses[id]?.group_id;
if (group) return state.entities[Entities.GROUPS]?.store[group] as Group;
return undefined;
},
(state: RootState, { id }: APIStatus) => state.polls[id] || null,
(_state: RootState, { username }: APIStatus) => username,
getFilters,
(state: RootState) => state.me,
(state: RootState) => state.auth.client.features,
],
(statusBase, statusReblog, statusQuote, statusGroup, poll, username, filters, me, features) => {
(statusBase, statusReblog, statusQuote, statusGroup, poll, username, filters, me, features) => {
// const locale = getLocale('en');
if (!statusBase) return null;
const { account } = statusBase;
const accountUsername = account.acct;
if (!statusBase) return null;
const { account } = statusBase;
const accountUsername = account.acct;
// Must be owner of status if username exists.
if (accountUsername !== username && username !== undefined) {
return null;
}
// Must be owner of status if username exists.
if (accountUsername !== username && username !== undefined) {
return null;
}
const filtered = features.filtersV2
? statusBase.filtered
: features.filters && account.id !== me && checkFiltered(statusReblog?.search_index || statusBase.search_index || '', filters) || [];
const filtered = features.filtersV2
? statusBase.filtered
: features.filters && account.id !== me && checkFiltered(statusReblog?.search_index || statusBase.search_index || '', filters) || [];
return {
...statusBase,
reblog: statusReblog || null,
quote: statusQuote || null,
group: statusGroup || null,
poll,
filtered,
};
},
);
return {
...statusBase,
reblog: statusReblog || null,
quote: statusQuote || null,
group: statusGroup || null,
poll,
filtered,
};
},
);
};
type SelectedStatus = Exclude<ReturnType<ReturnType<typeof makeGetStatus>>, null>;