pl-fe: implement shoutbox, except sending
Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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;
|
||||
|
||||
@ -150,7 +150,6 @@ function parseContent({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fallback = (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<a
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -270,4 +270,4 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatMessageList as default };
|
||||
export { ChatMessageList as default, List as ChatMessageListList, Scroller as ChatMessageListScroller };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
@ -184,4 +184,4 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { Chat as default };
|
||||
export { Chat as default, clearNativeInputValue };
|
||||
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
95
packages/pl-fe/src/features/chats/components/shoutbox.tsx
Normal file
95
packages/pl-fe/src/features/chats/components/shoutbox.tsx
Normal 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 };
|
||||
@ -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} />
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user