pl-fe: implement shoutbox, except sending

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-01 17:22:54 +02:00
parent a1cd5d204b
commit 767a23b19f
12 changed files with 425 additions and 7 deletions

View File

@ -17,7 +17,6 @@ import { truncateFilename } from 'pl-fe/utils/media';
import { isIOS } from '../is-mobile';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
import type { MediaAttachment } from 'pl-api';
const ATTACHMENT_LIMIT = 4;

View File

@ -150,7 +150,6 @@ function parseContent({
}
}
const fallback = (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a

View File

@ -172,7 +172,6 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
</div>
);
/** Render a single item. */
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
if (showPlaceholder) {

View File

@ -270,4 +270,4 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
);
};
export { ChatMessageList as default };
export { ChatMessageList as default, List as ChatMessageListList, Scroller as ChatMessageListScroller };

View File

@ -7,6 +7,7 @@ import Stack from 'pl-fe/components/ui/stack';
import ChatPageMain from './components/chat-page-main';
import ChatPageNew from './components/chat-page-new';
import ChatPageSettings from './components/chat-page-settings';
import ChatPageShoutbox from './components/chat-page-shoutbox';
import ChatPageSidebar from './components/chat-page-sidebar';
interface IChatPage {
@ -18,7 +19,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
const path = history.location.pathname;
const isSidebarHidden = matchPath(path, {
path: ['/chats/settings', '/chats/new', '/chats/:chatId'],
path: ['/chats/settings', '/chats/new', '/chats/:chatId', '/chats/shoutbox'],
exact: true,
});
@ -81,6 +82,9 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
<Route path='/chats/settings'>
<ChatPageSettings />
</Route>
<Route path='/chats/shoutbox'>
<ChatPageShoutbox />
</Route>
<Route>
<ChatPageMain />
</Route>

View File

@ -0,0 +1,49 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import Avatar from 'pl-fe/components/ui/avatar';
import HStack from 'pl-fe/components/ui/hstack';
import IconButton from 'pl-fe/components/ui/icon-button';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import Shoutbox from '../../shoutbox';
const ChatPageShoutbox = () => {
const history = useHistory();
const { logo } = usePlFeConfig();
return (
<Stack className='h-full overflow-hidden'>
<HStack alignItems='center' justifyContent='between' space={2} className='w-full p-4'>
<HStack alignItems='center' space={2} className='overflow-hidden'>
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/outline/arrow-left.svg')}
className='mr-2 size-7 sm:mr-0 sm:hidden rtl:rotate-180'
onClick={() => history.push('/chats')}
/>
<Avatar src={logo} alt='' size={40} className='flex-none' />
</HStack>
<Stack alignItems='start' className='h-11 overflow-hidden'>
<div className='flex w-full grow items-center space-x-1'>
<Text weight='bold' size='sm' align='left' truncate>
<FormattedMessage id='chat_list_item_shoutbox' defaultMessage='Instance shoutbox' />
</Text>
</div>
</Stack>
</HStack>
</HStack>
<div className='h-full overflow-hidden'>
<Shoutbox className='h-full' />
</div>
</Stack>
);
};
export { ChatPageShoutbox as default };

View File

@ -184,4 +184,4 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
);
};
export { Chat as default };
export { Chat as default, clearNativeInputValue };

View File

@ -0,0 +1,125 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Combobox, { ComboboxInput } from 'pl-fe/components/ui/combobox';
import HStack from 'pl-fe/components/ui/hstack';
import IconButton from 'pl-fe/components/ui/icon-button';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useInstance } from 'pl-fe/hooks/use-instance';
import ChatTextarea from './chat-textarea';
const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
retry: { id: 'chat.retry', defaultMessage: 'Retry?' },
});
interface IShoutboxComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onKeyDown' | 'onChange' | 'onPaste' | 'disabled'> {
value: string;
onSubmit: () => void;
errorMessage: string | undefined;
resetContentKey: number | null;
}
const ShoutboxComposer = React.forwardRef<HTMLTextAreaElement | null, IShoutboxComposer>(({
onKeyDown,
onChange,
value,
onSubmit,
errorMessage = false,
disabled = false,
resetContentKey,
onPaste,
}, ref) => {
const intl = useIntl();
const maxCharacterCount = useInstance().configuration.chats.max_characters;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isOverCharacterLimit || value.length === 0;
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
const onSelectComboboxOption = (selection: string) => {
const event = { target: { value: selection } } as React.ChangeEvent<HTMLTextAreaElement>;
if (onChange) {
onChange(event);
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onChange) {
onChange(event);
}
};
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
if (onKeyDown) {
onKeyDown(event);
}
};
return (
<div className='mt-auto px-4 shadow-3xl'>
{/* Spacer */}
<div className='h-5' />
<HStack alignItems='stretch' justifyContent='between' space={4}>
<Stack grow>
<Combobox onSelect={onSelectComboboxOption}>
<ComboboxInput
key={resetContentKey}
as={ChatTextarea}
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
value={value}
onChange={handleChange}
onPaste={onPaste}
isResizeable={false}
autoGrow
maxRows={5}
disabled={disabled}
/>
</Combobox>
</Stack>
<Stack space={2} justifyContent='end' alignItems='center' className='mb-1.5 w-10'>
{isOverCharacterLimit ? (
<Text size='sm' theme='danger'>{overLimitText}</Text>
) : null}
<IconButton
src={require('@tabler/icons/outline/send.svg')}
iconClassName='h-5 w-5'
className='text-primary-500'
disabled={isSubmitDisabled}
onClick={onSubmit}
/>
</Stack>
</HStack>
<HStack alignItems='center' className='h-5' space={1}>
{errorMessage && (
<>
<Text theme='danger' size='xs'>
{errorMessage}
</Text>
<button onClick={onSubmit} className='flex hover:underline'>
<Text theme='primary' size='xs' tag='span'>
{intl.formatMessage(messages.retry)}
</Text>
</button>
</>
)}
</HStack>
</div>
);
});
export { ShoutboxComposer as default };

View File

@ -0,0 +1,145 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
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';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import Emojify from 'pl-fe/features/emoji/emojify';
import PlaceholderChatMessage from 'pl-fe/features/placeholder/components/placeholder-chat-message';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { ChatMessageListList, ChatMessageListScroller } from './chat-message-list';
const START_INDEX = 10000;
/** Scrollable list of shoutbox messages. */
const ShoutboxMessageList: React.FC = () => {
const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const me = useAppSelector(state => state.me);
const { isLoading, messages: shoutboxMessages } = useAppSelector(state => state.shoutbox);
const lastShoutboxMessage = shoutboxMessages?.at(-1) || null;
useEffect(() => {
if (!shoutboxMessages) {
return;
}
const nextFirstItemIndex = START_INDEX - shoutboxMessages.length;
setFirstItemIndex(nextFirstItemIndex);
}, [lastShoutboxMessage]);
const initialScrollPositionProps = useMemo(() => {
if (process.env.NODE_ENV === 'test') {
return {};
}
return {
initialTopMostItemIndex: shoutboxMessages.length - 1,
firstItemIndex: Math.max(0, firstItemIndex),
};
}, [shoutboxMessages.length, firstItemIndex]);
if (isLoading) {
return (
<div className='flex grow flex-col justify-end pb-4'>
<div className='px-4'>
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
</div>
</div>
);
}
return (
<div className='flex h-full grow flex-col space-y-6'>
<div className='flex grow flex-col justify-end'>
<Virtuoso
ref={node}
alignToBottom
{...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,
})}
>
<HoverAccountWrapper accountId={shoutboxMessage.author.id} element='span'>
<Link 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,
})
}
// ref={setBubbleRef}
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>
);
}}
components={{
List: ChatMessageListList,
Scroller: ChatMessageListScroller,
}}
/>
</div>
</div>
);
};
export { ShoutboxMessageList as default };

View File

@ -0,0 +1,95 @@
import clsx from 'clsx';
import React, { MutableRefObject, useEffect, useState } from 'react';
import Stack from 'pl-fe/components/ui/stack';
import { clearNativeInputValue } from './chat';
import ShoutboxComposer from './shoutbox-composer';
import ShoutboxMessageList from './shoutbox-message-list';
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
interface ChatInterface {
inputRef?: MutableRefObject<HTMLTextAreaElement | null>;
className?: string;
}
const Shoutbox: React.FC<ChatInterface> = ({ inputRef, className }) => {
const [content, setContent] = useState<string>('');
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
const [errorMessage] = useState<string>();
const isSubmitDisabled = content.length === 0;
const submitMessage = () => {
// dispatch(shoutboxmess)
// createChatMessage.mutate({ chatId: chat.id, content, mediaId: attachment?.id }, {
// onSuccess: () => {
// setErrorMessage(undefined);
// },
// onError: (error: { response: PlfeResponse }, _variables, context) => {
// const message = error.response?.json?.error;
// setErrorMessage(message || intl.formatMessage(messages.failedToSend));
// setContent(context.prevContent as string);
// },
// });
clearState();
};
const clearState = () => {
if (inputRef?.current) {
clearNativeInputValue(inputRef.current);
}
setContent('');
setResetContentKey(fileKeyGen());
};
const sendMessage = () => {
if (!isSubmitDisabled) {
submitMessage();
}
};
const insertLine = () => setContent(content + '\n');
const handleKeyDown: React.KeyboardEventHandler = (event) => {
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault();
insertLine();
} else if (event.key === 'Enter') {
event.preventDefault();
sendMessage();
}
};
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
setContent(event.target.value);
};
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, [inputRef?.current]);
return (
<Stack className={clsx('flex grow overflow-hidden', className)}>
<div className='flex h-full grow justify-center overflow-hidden'>
<ShoutboxMessageList />
</div>
<ShoutboxComposer
ref={inputRef}
onKeyDown={handleKeyDown}
value={content}
onChange={handleContentChange}
onSubmit={sendMessage}
errorMessage={errorMessage}
resetContentKey={resetContentKey}
/>
</Stack>
);
};
export { Shoutbox as default };

View File

@ -252,6 +252,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ chil
{features.chats && <WrappedRoute path='/chats' exact layout={ChatsLayout} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/new' layout={ChatsLayout} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/settings' layout={ChatsLayout} component={ChatIndex} content={children} />}
{features.shoutbox && <WrappedRoute path='/chats/shoutbox' layout={ChatsLayout} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/:chatId' layout={ChatsLayout} component={ChatIndex} content={children} />}
<WrappedRoute path='/follow_requests' layout={DefaultLayout} component={FollowRequests} content={children} />

View File

@ -4,11 +4,13 @@ import type { PlApiClient, ShoutMessage } from 'pl-api';
interface State {
socket: ReturnType<(InstanceType<typeof PlApiClient>)['shoutbox']['connect']> | null;
isLoading: boolean;
messages: Array<ShoutMessage>;
}
const initialState: State = {
socket: null,
isLoading: true,
messages: [],
};
@ -17,7 +19,7 @@ const shoutboxReducer = (state = initialState, action: ShoutboxAction) => {
case SHOUTBOX_CONNECT:
return { ...state, socket: action.socket };
case SHOUTBOX_MESSAGES_IMPORT:
return { ...state, messages: action.messages };
return { ...state, messages: action.messages, isLoading: false };
case SHOUTBOX_MESSAGE_IMPORT:
return { ...state, messages: [...state.messages, action.message] };
default: