diff --git a/packages/pl-fe/src/components/media-gallery.tsx b/packages/pl-fe/src/components/media-gallery.tsx index b6ac2a098..5168aebe2 100644 --- a/packages/pl-fe/src/components/media-gallery.tsx +++ b/packages/pl-fe/src/components/media-gallery.tsx @@ -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; diff --git a/packages/pl-fe/src/components/parsed-content.tsx b/packages/pl-fe/src/components/parsed-content.tsx index 67ef789d3..3fcffde26 100644 --- a/packages/pl-fe/src/components/parsed-content.tsx +++ b/packages/pl-fe/src/components/parsed-content.tsx @@ -150,7 +150,6 @@ function parseContent({ } } - const fallback = ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions (({ ); - /** Render a single item. */ const renderItem = (_i: number, element: JSX.Element): JSX.Element => { if (showPlaceholder) { diff --git a/packages/pl-fe/src/features/chats/components/chat-message-list.tsx b/packages/pl-fe/src/features/chats/components/chat-message-list.tsx index ae1f93a2a..84779fe6c 100644 --- a/packages/pl-fe/src/features/chats/components/chat-message-list.tsx +++ b/packages/pl-fe/src/features/chats/components/chat-message-list.tsx @@ -270,4 +270,4 @@ const ChatMessageList: React.FC = ({ chat }) => { ); }; -export { ChatMessageList as default }; +export { ChatMessageList as default, List as ChatMessageListList, Scroller as ChatMessageListScroller }; diff --git a/packages/pl-fe/src/features/chats/components/chat-page/chat-page.tsx b/packages/pl-fe/src/features/chats/components/chat-page/chat-page.tsx index 2c720cf8d..44817cc69 100644 --- a/packages/pl-fe/src/features/chats/components/chat-page/chat-page.tsx +++ b/packages/pl-fe/src/features/chats/components/chat-page/chat-page.tsx @@ -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 = ({ 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 = ({ chatId }) => { + + + diff --git a/packages/pl-fe/src/features/chats/components/chat-page/components/chat-page-shoutbox.tsx b/packages/pl-fe/src/features/chats/components/chat-page/components/chat-page-shoutbox.tsx new file mode 100644 index 000000000..281532458 --- /dev/null +++ b/packages/pl-fe/src/features/chats/components/chat-page/components/chat-page-shoutbox.tsx @@ -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 ( + + + + + history.push('/chats')} + /> + + + + + +
+ + + +
+
+
+
+ +
+ +
+
+ ); +}; + +export { ChatPageShoutbox as default }; diff --git a/packages/pl-fe/src/features/chats/components/chat.tsx b/packages/pl-fe/src/features/chats/components/chat.tsx index 57e49e522..84c9b8c31 100644 --- a/packages/pl-fe/src/features/chats/components/chat.tsx +++ b/packages/pl-fe/src/features/chats/components/chat.tsx @@ -184,4 +184,4 @@ const Chat: React.FC = ({ chat, inputRef, className }) => { ); }; -export { Chat as default }; +export { Chat as default, clearNativeInputValue }; diff --git a/packages/pl-fe/src/features/chats/components/shoutbox-composer.tsx b/packages/pl-fe/src/features/chats/components/shoutbox-composer.tsx new file mode 100644 index 000000000..6cad3586e --- /dev/null +++ b/packages/pl-fe/src/features/chats/components/shoutbox-composer.tsx @@ -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, 'onKeyDown' | 'onChange' | 'onPaste' | 'disabled'> { + value: string; + onSubmit: () => void; + errorMessage: string | undefined; + resetContentKey: number | null; +} + +const ShoutboxComposer = React.forwardRef(({ + 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; + + if (onChange) { + onChange(event); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + if (onChange) { + onChange(event); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if (onKeyDown) { + onKeyDown(event); + } + }; + + return ( +
+ {/* Spacer */} +
+ + + + + + + + + + {isOverCharacterLimit ? ( + {overLimitText} + ) : null} + + + + + + + {errorMessage && ( + <> + + {errorMessage} + + + + + )} + +
+ ); +}); + +export { ShoutboxComposer as default }; diff --git a/packages/pl-fe/src/features/chats/components/shoutbox-message-list.tsx b/packages/pl-fe/src/features/chats/components/shoutbox-message-list.tsx new file mode 100644 index 000000000..3f11c84e7 --- /dev/null +++ b/packages/pl-fe/src/features/chats/components/shoutbox-message-list.tsx @@ -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(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 ( +
+
+ + + + + +
+
+ ); + } + + return ( +
+
+ { + const isMyMessage = shoutboxMessage.author.id === me; + + return ( +
+ + + + + + + + + +
+ + + +
+
+ {!isMyMessage && ( + + + + )} +
+
+
+ ); + }} + components={{ + List: ChatMessageListList, + Scroller: ChatMessageListScroller, + }} + /> +
+
+ ); +}; + +export { ShoutboxMessageList as default }; diff --git a/packages/pl-fe/src/features/chats/components/shoutbox.tsx b/packages/pl-fe/src/features/chats/components/shoutbox.tsx new file mode 100644 index 000000000..bd405c2ca --- /dev/null +++ b/packages/pl-fe/src/features/chats/components/shoutbox.tsx @@ -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; + className?: string; +} + +const Shoutbox: React.FC = ({ inputRef, className }) => { + const [content, setContent] = useState(''); + const [resetContentKey, setResetContentKey] = useState(fileKeyGen()); + const [errorMessage] = useState(); + + 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 = (event) => { + setContent(event.target.value); + }; + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.focus(); + } + }, [inputRef?.current]); + + return ( + +
+ +
+ + +
+ ); +}; + +export { Shoutbox as default }; diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index aed4d58e9..4417efe25 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -252,6 +252,7 @@ const SwitchingColumnsArea: React.FC = React.memo(({ chil {features.chats && } {features.chats && } {features.chats && } + {features.shoutbox && } {features.chats && } diff --git a/packages/pl-fe/src/reducers/shoutbox.ts b/packages/pl-fe/src/reducers/shoutbox.ts index 16f1d1983..254910785 100644 --- a/packages/pl-fe/src/reducers/shoutbox.ts +++ b/packages/pl-fe/src/reducers/shoutbox.ts @@ -4,11 +4,13 @@ import type { PlApiClient, ShoutMessage } from 'pl-api'; interface State { socket: ReturnType<(InstanceType)['shoutbox']['connect']> | null; + isLoading: boolean; messages: Array; } 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: