pl-fe: minify pleroma shoutbox messages

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-06-15 16:37:16 +02:00
parent 404dc6d8e2
commit 6bbf5f95de
6 changed files with 114 additions and 76 deletions

View File

@ -1109,7 +1109,6 @@ const getFeatures = (instance: Instance) => {
*/
mastodonAdminMetrics: v.software === MASTODON && gte(v.version, '3.5.0'),
/**
* Can perform moderation actions with account and reports.
* @see {@link https://docs.joinmastodon.org/methods/admin/}

View File

@ -3,7 +3,7 @@ import { Entities } from 'pl-fe/entity-store/entities';
import { normalizeGroup } from 'pl-fe/normalizers/group';
import type { Account as BaseAccount, Group as BaseGroup, Poll as BasePoll, Relationship as BaseRelationship, Status as BaseStatus } from 'pl-api';
import type { AppDispatch } from 'pl-fe/store';
import type { AppDispatch, RootState } from 'pl-fe/store';
const STATUS_IMPORT = 'STATUS_IMPORT' as const;
const STATUSES_IMPORT = 'STATUSES_IMPORT' as const;
@ -51,11 +51,17 @@ const importEntities = (entities: {
statuses?: Array<BaseStatus & { expectsCard?: boolean } | undefined | null>;
relationships?: Array<BaseRelationship | undefined | null>;
}, options: {
// Whether to replace existing entities. Set to false when working with potentially outdated data. Currently, only implemented for accounts.
override?: boolean;
withParents?: boolean;
idempotencyKey?: string;
} = {
withParents: true,
}) => (dispatch: AppDispatch) => {
}) => (dispatch: AppDispatch, getState: () => RootState) => {
const override = options.override ?? true;
const state: RootState = !override ? getState() : undefined as any;
const accounts: Record<string, BaseAccount> = {};
const groups: Record<string, BaseGroup> = {};
const polls: Record<string, BasePoll> = {};
@ -63,6 +69,8 @@ const importEntities = (entities: {
const statuses: Record<string, BaseStatus> = {};
const processAccount = (account: BaseAccount, withSelf = true) => {
if (!override && state.entities[Entities.ACCOUNTS]?.store[account.id]) return;
if (withSelf) accounts[account.id] = account;
if (account.moved) processAccount(account.moved);

View File

@ -11,7 +11,7 @@ const SHOUTBOX_MESSAGES_IMPORT = 'SHOUTBOX_MESSAGES_IMPORT' as const;
const SHOUTBOX_CONNECT = 'SHOUTBOX_CONNECT' as const;
const importShoutboxMessages = (messages: ShoutMessage[]) => (dispatch: AppDispatch): ShoutboxAction => {
dispatch(importEntities({ accounts: messages.map((message) => message.author) }));
dispatch(importEntities({ accounts: messages.map((message) => message.author) }, { override: false }));
return dispatch({
type: SHOUTBOX_MESSAGES_IMPORT,
@ -20,7 +20,7 @@ const importShoutboxMessages = (messages: ShoutMessage[]) => (dispatch: AppDispa
};
const importShoutboxMessage = (message: ShoutMessage) => (dispatch: AppDispatch): ShoutboxAction => {
dispatch(importEntities({ accounts: [message.author] }));
dispatch(importEntities({ accounts: [message.author] }, { override: false }));
return dispatch({
type: SHOUTBOX_MESSAGE_IMPORT,

View File

@ -1,6 +1,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useAccount } from 'pl-fe/api/hooks/accounts/use-account';
import { ParsedContent } from 'pl-fe/components/parsed-content';
import Avatar from 'pl-fe/components/ui/avatar';
import HStack from 'pl-fe/components/ui/hstack';
@ -28,6 +29,7 @@ const ChatListShoutbox: React.FC<IChatListShoutboxInterface> = ({ onClick }) =>
};
const lastMessage = messages.at(-1);
const { account: lastMessageAuthor } = useAccount(lastMessage?.author_id);
return (
<div
@ -59,10 +61,12 @@ const ChatListShoutbox: React.FC<IChatListShoutboxInterface> = ({ onClick }) =>
truncate
className='truncate-child pointer-events-none h-5 w-full'
>
<Text weight='bold' size='sm' align='left' theme='muted' truncate tag='span'>
{lastMessage.author.display_name || `@${lastMessage.author.username}`}:
{' '}
</Text>
{lastMessageAuthor && (
<Text weight='bold' size='sm' align='left' theme='muted' truncate tag='span'>
{lastMessageAuthor.display_name || `@${lastMessageAuthor.username}`}:
{' '}
</Text>
)}
<ParsedContent html={lastMessage.text} />
</Text>
</>

View File

@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useAccount } from 'pl-fe/api/hooks/accounts/use-account';
import HoverAccountWrapper from 'pl-fe/components/hover-account-wrapper';
import { ParsedContent } from 'pl-fe/components/parsed-content';
import Avatar from 'pl-fe/components/ui/avatar';
@ -15,8 +16,81 @@ import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { ChatMessageListList, ChatMessageListScroller } from './chat-message-list';
import type { ShoutMessage } from 'pl-fe/reducers/shoutbox';
const START_INDEX = 10000;
interface IShoutboxMessage {
message: ShoutMessage;
isMyMessage: boolean;
}
const ShoutboxMessage: React.FC<IShoutboxMessage> = ({ message, isMyMessage }) => {
const { account } = useAccount(message.author_id);
if (!account) return null;
return (
<div key={message.id} className='group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40'>
<HStack
space={2}
alignItems='bottom'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'ml-auto': isMyMessage,
})}
>
{!isMyMessage && (
<HoverAccountWrapper accountId={account.id} element='span'>
<Link className='mb-0.5' to={`/@${account.acct}`} title={account.acct}>
<Avatar
src={account.avatar}
alt={account.avatar_description}
size={32}
/>
</Link>
</HoverAccountWrapper>
)}
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'order-3': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
<HStack alignItems='bottom' className='max-w-full'>
<div
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
// '!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
tabIndex={0}
>
<Text size='sm' theme='inherit' className='break-word-nested'>
<ParsedContent html={message.text} />
</Text>
</div>
</HStack>
{!isMyMessage && (
<Text size='xs' theme='muted'>
<Emojify text={account.display_name} emojis={account.emojis} />
</Text>
)}
</Stack>
</HStack>
</div>
);
};
/** Scrollable list of shoutbox messages. */
const ShoutboxMessageList: React.FC = () => {
const node = useRef<VirtuosoHandle>(null);
@ -70,69 +144,13 @@ const ShoutboxMessageList: React.FC = () => {
{...initialScrollPositionProps}
data={shoutboxMessages}
followOutput='auto'
itemContent={(index, shoutboxMessage) => {
const isMyMessage = shoutboxMessage.author.id === me;
return (
<div key={shoutboxMessage.id} className='group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40'>
<HStack
space={2}
alignItems='bottom'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'ml-auto': isMyMessage,
})}
>
{!isMyMessage && (
<HoverAccountWrapper accountId={shoutboxMessage.author.id} element='span'>
<Link className='mb-0.5' to={`/@${shoutboxMessage.author.acct}`} title={shoutboxMessage.author.acct}>
<Avatar
src={shoutboxMessage.author.avatar}
alt={shoutboxMessage.author.avatar_description}
size={32}
/>
</Link>
</HoverAccountWrapper>
)}
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'order-3': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
<HStack alignItems='bottom' className='max-w-full'>
<div
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
// '!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
tabIndex={0}
>
<Text size='sm' theme='inherit' className='break-word-nested'>
<ParsedContent html={shoutboxMessage.text} />
</Text>
</div>
</HStack>
{!isMyMessage && (
<Text size='xs' theme='muted'>
<Emojify text={shoutboxMessage.author.display_name} emojis={shoutboxMessage.author.emojis} />
</Text>
)}
</Stack>
</HStack>
</div>
);
}}
itemContent={(index, shoutboxMessage) => (
<ShoutboxMessage
key={shoutboxMessage.id}
message={shoutboxMessage}
isMyMessage={shoutboxMessage.author_id === me}
/>
)}
components={{
List: ChatMessageListList,
Scroller: ChatMessageListScroller,

View File

@ -1,6 +1,10 @@
import { SHOUTBOX_CONNECT, SHOUTBOX_MESSAGES_IMPORT, SHOUTBOX_MESSAGE_IMPORT, type ShoutboxAction } from 'pl-fe/actions/shoutbox';
import type { PlApiClient, ShoutMessage } from 'pl-api';
import type { PlApiClient, ShoutMessage as BaseShoutMessage } from 'pl-api';
interface ShoutMessage extends Omit<BaseShoutMessage, 'author'> {
author_id: string;
}
interface State {
socket: ReturnType<(InstanceType<typeof PlApiClient>)['shoutbox']['connect']> | null;
@ -14,17 +18,22 @@ const initialState: State = {
messages: [],
};
const minifyMessage = ({ author, ...message }: BaseShoutMessage): ShoutMessage => ({
author_id: author.id,
...message,
});
const shoutboxReducer = (state = initialState, action: ShoutboxAction) => {
switch (action.type) {
case SHOUTBOX_CONNECT:
return { ...state, socket: action.socket };
case SHOUTBOX_MESSAGES_IMPORT:
return { ...state, messages: action.messages, isLoading: false };
return { ...state, messages: action.messages.map(minifyMessage), isLoading: false };
case SHOUTBOX_MESSAGE_IMPORT:
return { ...state, messages: [...state.messages, action.message] };
return { ...state, messages: [...state.messages, minifyMessage(action.message)] };
default:
return state;
}
};
export { shoutboxReducer as default };
export { shoutboxReducer as default, type ShoutMessage };