Merge branch 'chats-tsx' into 'develop'

Convert Chats to TypeScript

See merge request soapbox-pub/soapbox-fe!1542
This commit is contained in:
Alex Gleason 2022-06-18 19:57:47 +00:00
commit 3bd9467cc7
20 changed files with 776 additions and 43 deletions

Binary file not shown.

View file

@ -15,7 +15,7 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
interface IHoverRefWrapper {
accountId: string,
inline: boolean,
inline?: boolean,
className?: string,
}

View file

@ -0,0 +1,42 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional_motion';
interface IUploadProgress {
/** Number between 0 and 1 to represent the percentage complete. */
progress: number,
}
/** Displays a progress bar for uploading files. */
const UploadProgress: React.FC<IUploadProgress> = ({ progress }) => {
return (
<HStack alignItems='center' space={2}>
<Icon
src={require('@tabler/icons/icons/cloud-upload.svg')}
className='w-7 h-7 text-gray-500'
/>
<Stack space={1}>
<Text theme='muted'>
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
</Text>
<div className='w-full h-1.5 rounded-lg bg-gray-200 relative'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
(<div
className='absolute left-0 top-0 h-1.5 bg-primary-600 rounded-lg'
style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
</Stack>
</HStack>
);
};
export default UploadProgress;

View file

@ -0,0 +1,68 @@
import { Map as ImmutableMap } from 'immutable';
import React, { useEffect, useRef } from 'react';
import { fetchChat, markChatRead } from 'soapbox/actions/chats';
import { Column } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors';
import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn as getDisplayFqn } from 'soapbox/utils/state';
import ChatBox from './components/chat-box';
const getChat = makeGetChat();
interface IChatRoom {
params: {
chatId: string,
}
}
/** Fullscreen chat UI. */
const ChatRoom: React.FC<IChatRoom> = ({ params }) => {
const dispatch = useAppDispatch();
const displayFqn = useAppSelector(getDisplayFqn);
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const chat = useAppSelector(state => {
const chat = state.chats.items.get(params.chatId, ImmutableMap()).toJS() as any;
return getChat(state, chat);
});
const focusInput = () => {
inputElem.current?.focus();
};
const handleInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
focusInput();
};
const markRead = () => {
if (!chat) return;
dispatch(markChatRead(chat.id));
};
useEffect(() => {
dispatch(fetchChat(params.chatId));
markRead();
}, [params.chatId]);
// If this component is loaded at all, we can instantly mark new messages as read.
useEffect(() => {
markRead();
}, [chat?.unread]);
if (!chat) return null;
return (
<Column label={`@${getAcct(chat.account as any, displayFqn)}`}>
<ChatBox
chatId={chat.id}
onSetInputRef={handleInputRef}
/>
</Column>
);
};
export default ChatRoom;

View file

@ -0,0 +1,192 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { useRef, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import {
sendChatMessage,
markChatRead,
} from 'soapbox/actions/chats';
import { uploadMedia } from 'soapbox/actions/media';
import IconButton from 'soapbox/components/icon_button';
import UploadProgress from 'soapbox/components/upload-progress';
import UploadButton from 'soapbox/features/compose/components/upload_button';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { truncateFilename } from 'soapbox/utils/media';
import ChatMessageList from './chat-message-list';
const messages = defineMessages({
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' },
send: { id: 'chat_box.actions.send', defaultMessage: 'Send' },
});
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
interface IChatBox {
chatId: string,
onSetInputRef: (el: HTMLTextAreaElement) => void,
}
/**
* Chat UI with just the messages and textarea.
* Reused between floating desktop chats and fullscreen/mobile chats.
*/
const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chatId, ImmutableOrderedSet<string>()));
const [content, setContent] = useState('');
const [attachment, setAttachment] = useState<any>(undefined);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const clearState = () => {
setContent('');
setAttachment(undefined);
setIsUploading(false);
setUploadProgress(0);
setResetFileKey(fileKeyGen());
};
const getParams = () => {
return {
content,
media_id: attachment && attachment.id,
};
};
const canSubmit = () => {
const conds = [
content.length > 0,
attachment,
];
return conds.some(c => c);
};
const sendMessage = () => {
if (canSubmit() && !isUploading) {
const params = getParams();
dispatch(sendChatMessage(chatId, params));
clearState();
}
};
const insertLine = () => {
setContent(content + '\n');
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
markRead();
if (e.key === 'Enter' && e.shiftKey) {
insertLine();
e.preventDefault();
} else if (e.key === 'Enter') {
sendMessage();
e.preventDefault();
}
};
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
setContent(e.target.value);
};
const markRead = () => {
dispatch(markChatRead(chatId));
};
const handleHover = () => {
markRead();
};
const setInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
onSetInputRef(el);
};
const handleRemoveFile = () => {
setAttachment(undefined);
setResetFileKey(fileKeyGen());
};
const onUploadProgress = (e: ProgressEvent) => {
const { loaded, total } = e;
setUploadProgress(loaded / total);
};
const handleFiles = (files: FileList) => {
setIsUploading(true);
const data = new FormData();
data.append('file', files[0]);
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
setAttachment(response.data);
setIsUploading(false);
}).catch(() => {
setIsUploading(false);
});
};
const renderAttachment = () => {
if (!attachment) return null;
return (
<div className='chat-box__attachment'>
<div className='chat-box__filename'>
{truncateFilename(attachment.preview_url, 20)}
</div>
<div className='chat-box__remove-attachment'>
<IconButton
src={require('@tabler/icons/icons/x.svg')}
onClick={handleRemoveFile}
/>
</div>
</div>
);
};
const renderActionButton = () => {
return canSubmit() ? (
<IconButton
src={require('@tabler/icons/icons/send.svg')}
title={intl.formatMessage(messages.send)}
onClick={sendMessage}
/>
) : (
<UploadButton onSelectFile={handleFiles} resetFileKey={resetFileKey} />
);
};
if (!chatMessageIds) return null;
return (
<div className='chat-box' onMouseOver={handleHover}>
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} />
{renderAttachment()}
{isUploading && (
<UploadProgress progress={uploadProgress * 100} />
)}
<div className='chat-box__actions simple_form'>
<div className='chat-box__send'>
{renderActionButton()}
</div>
<textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
onChange={handleContentChange}
value={content}
ref={setInputRef}
/>
</div>
</div>
);
};
export default ChatBox;

View file

@ -0,0 +1,333 @@
import classNames from 'classnames';
import {
Map as ImmutableMap,
List as ImmutableList,
OrderedSet as ImmutableOrderedSet,
} from 'immutable';
import escape from 'lodash/escape';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useRef, useLayoutEffect, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { createSelector } from 'reselect';
import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
import { openModal } from 'soapbox/actions/modals';
import { initReportById } from 'soapbox/actions/reports';
import { Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import emojify from 'soapbox/features/emoji/emoji';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch, useRefEventHandler } from 'soapbox/hooks';
import { onlyEmoji } from 'soapbox/utils/rich_content';
import type { Menu } from 'soapbox/components/dropdown_menu';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const BIG_EMOJI_LIMIT = 1;
const messages = defineMessages({
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
more: { id: 'chats.actions.more', defaultMessage: 'More' },
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete message' },
report: { id: 'chats.actions.report', defaultMessage: 'Report user' },
});
type TimeFormat = 'today' | 'date';
const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
const prevDate = new Date(prev.created_at).getDate();
const currDate = new Date(curr.created_at).getDate();
const nowDate = new Date().getDate();
if (prevDate !== currDate) {
return currDate === nowDate ? 'today' : 'date';
}
return null;
};
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap());
const getChatMessages = createSelector(
[(chatMessages: ImmutableMap<string, ChatMessageEntity>, chatMessageIds: ImmutableOrderedSet<string>) => (
chatMessageIds.reduce((acc, curr) => {
const chatMessage = chatMessages.get(curr);
return chatMessage ? acc.push(chatMessage) : acc;
}, ImmutableList<ChatMessageEntity>())
)],
chatMessages => chatMessages,
);
interface IChatMessageList {
/** Chat the messages are being rendered from. */
chatId: string,
/** Message IDs to render. */
chatMessageIds: ImmutableOrderedSet<string>,
}
/** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const me = useAppSelector(state => state.me);
const chatMessages = useAppSelector(state => getChatMessages(state.chat_messages, chatMessageIds));
const [initialLoad, setInitialLoad] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const node = useRef<HTMLDivElement>(null);
const messagesEnd = useRef<HTMLDivElement>(null);
const lastComputedScroll = useRef<number | undefined>(undefined);
const scrollBottom = useRef<number | undefined>(undefined);
const initialCount = useMemo(() => chatMessages.count(), []);
const scrollToBottom = () => {
messagesEnd.current?.scrollIntoView(false);
};
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
return intl.formatDate(
new Date(chatMessage.created_at), {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
},
);
};
const setBubbleRef = (c: HTMLDivElement) => {
if (!c) return;
const links = c.querySelectorAll('a[rel="ugc"]');
links.forEach(link => {
link.classList.add('chat-link');
link.setAttribute('rel', 'ugc nofollow noopener');
link.setAttribute('target', '_blank');
});
if (onlyEmoji(c, BIG_EMOJI_LIMIT, false)) {
c.classList.add('chat-message__bubble--onlyEmoji');
} else {
c.classList.remove('chat-message__bubble--onlyEmoji');
}
};
const isNearBottom = (): boolean => {
const elem = node.current;
if (!elem) return false;
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
return scrollBottom < elem.offsetHeight * 1.5;
};
const handleResize = throttle(() => {
if (isNearBottom()) {
scrollToBottom();
}
}, 150);
const restoreScrollPosition = () => {
if (node.current && scrollBottom.current) {
lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current;
node.current.scrollTop = lastComputedScroll.current;
}
};
const handleLoadMore = () => {
const maxId = chatMessages.getIn([0, 'id']) as string;
dispatch(fetchChatMessages(chatId, maxId as any));
setIsLoading(true);
};
const handleScroll = useRefEventHandler(throttle(() => {
if (node.current) {
const { scrollTop, offsetHeight } = node.current;
const computedScroll = lastComputedScroll.current === scrollTop;
const nearTop = scrollTop < offsetHeight * 2;
if (nearTop && !isLoading && !initialLoad && !computedScroll) {
handleLoadMore();
}
}
}, 150, {
trailing: true,
}));
const onOpenMedia = (media: any, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
};
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
const { attachment } = chatMessage;
if (!attachment) return null;
return (
<div className='chat-message__media'>
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
<Component
media={ImmutableList([attachment])}
height={120}
onOpenMedia={onOpenMedia}
/>
)}
</Bundle>
</div>
);
};
const parsePendingContent = (content: string) => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
};
const parseContent = (chatMessage: ChatMessageEntity) => {
const content = chatMessage.content || '';
const pending = chatMessage.pending;
const deleting = chatMessage.deleting;
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
const emojiMap = makeEmojiMap(chatMessage);
return emojify(formatted, emojiMap.toJS());
};
const renderDivider = (key: React.Key, text: string) => (
<div className='chat-messages__divider' key={key}>{text}</div>
);
const handleDeleteMessage = (chatId: string, messageId: string) => {
return () => {
dispatch(deleteChatMessage(chatId, messageId));
};
};
const handleReportUser = (userId: string) => {
return () => {
dispatch(initReportById(userId));
};
};
const renderMessage = (chatMessage: ChatMessageEntity) => {
const menu: Menu = [
{
text: intl.formatMessage(messages.delete),
action: handleDeleteMessage(chatMessage.chat_id, chatMessage.id),
icon: require('@tabler/icons/icons/trash.svg'),
destructive: true,
},
];
if (chatMessage.account_id !== me) {
menu.push({
text: intl.formatMessage(messages.report),
action: handleReportUser(chatMessage.account_id),
icon: require('@tabler/icons/icons/flag.svg'),
});
}
return (
<div
className={classNames('chat-message', {
'chat-message--me': chatMessage.account_id === me,
'chat-message--pending': chatMessage.pending,
})}
key={chatMessage.id}
>
<div
title={getFormattedTimestamp(chatMessage)}
className='chat-message__bubble'
ref={setBubbleRef}
tabIndex={0}
>
{maybeRenderMedia(chatMessage)}
<Text size='sm' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
<div className='chat-message__menu'>
<DropdownMenuContainer
items={menu}
src={require('@tabler/icons/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
</div>
);
};
useEffect(() => {
dispatch(fetchChatMessages(chatId));
node.current?.addEventListener('scroll', e => handleScroll.current(e));
window.addEventListener('resize', handleResize);
scrollToBottom();
return () => {
node.current?.removeEventListener('scroll', e => handleScroll.current(e));
window.removeEventListener('resize', handleResize);
};
}, []);
// Store the scroll position.
useLayoutEffect(() => {
if (node.current) {
const { scrollHeight, scrollTop } = node.current;
scrollBottom.current = scrollHeight - scrollTop;
}
});
// Stick scrollbar to bottom.
useEffect(() => {
if (isNearBottom()) {
scrollToBottom();
}
// First load.
if (chatMessages.count() !== initialCount) {
setInitialLoad(false);
setIsLoading(false);
scrollToBottom();
}
}, [chatMessages.count()]);
useEffect(() => {
scrollToBottom();
}, [messagesEnd.current]);
// History added.
useEffect(() => {
// Restore scroll bar position when loading old messages.
if (!initialLoad) {
restoreScrollPosition();
}
}, [chatMessageIds.first()]);
return (
<div className='chat-messages' ref={node}>
{chatMessages.reduce((acc, curr, idx) => {
const lastMessage = chatMessages.get(idx - 1);
if (lastMessage) {
const key = `${curr.id}_divider`;
switch (timeChange(lastMessage, curr)) {
case 'today':
acc.push(renderDivider(key, intl.formatMessage(messages.today)));
break;
case 'date':
acc.push(renderDivider(key, new Date(curr.created_at).toDateString()));
break;
}
}
acc.push(renderMessage(curr));
return acc;
}, [] as React.ReactNode[])}
<div style={{ float: 'left', clear: 'both' }} ref={messagesEnd} />
</div>
);
};
export default ChatMessageList;

View file

@ -8,13 +8,13 @@ import { openChat, launchChat, toggleMainWindow } from 'soapbox/actions/chats';
import { getSettings } from 'soapbox/actions/settings';
import AccountSearch from 'soapbox/components/account_search';
import { Counter } from 'soapbox/components/ui';
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import { RootState } from 'soapbox/store';
import { Chat } from 'soapbox/types/entities';
import ChatList from './chat_list';
import ChatWindow from './chat_window';
import ChatList from './chat-list';
import ChatWindow from './chat-window';
const messages = defineMessages({
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },

View file

@ -0,0 +1,113 @@
import React, { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import {
closeChat,
toggleChat,
} from 'soapbox/actions/chats';
import Avatar from 'soapbox/components/avatar';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import IconButton from 'soapbox/components/icon_button';
import { Counter } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors';
import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn as getDisplayFqn } from 'soapbox/utils/state';
import ChatBox from './chat-box';
import type { Account as AccountEntity } from 'soapbox/types/entities';
type WindowState = 'open' | 'minimized';
const getChat = makeGetChat();
interface IChatWindow {
/** Position of the chat window on the screen, where 0 is rightmost. */
idx: number,
/** ID of the chat entity. */
chatId: string,
/** Whether the window is open or minimized. */
windowState: WindowState,
}
/** Floating desktop chat window. */
const ChatWindow: React.FC<IChatWindow> = ({ idx, chatId, windowState }) => {
const dispatch = useAppDispatch();
const displayFqn = useAppSelector(getDisplayFqn);
const chat = useAppSelector(state => {
const chat = state.chats.items.get(chatId);
return chat ? getChat(state, chat.toJS() as any) : undefined;
});
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const handleChatClose = (chatId: string) => {
return () => {
dispatch(closeChat(chatId));
};
};
const handleChatToggle = (chatId: string) => {
return () => {
dispatch(toggleChat(chatId));
};
};
const handleInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
focusInput();
};
const focusInput = () => {
inputElem.current?.focus();
};
useEffect(() => {
focusInput();
}, [windowState === 'open']);
if (!chat) return null;
const account = chat.account as unknown as AccountEntity;
const right = (285 * (idx + 1)) + 20;
const unreadCount = chat.unread;
const unreadIcon = (
<div className='mr-2 flex-none'>
<Counter count={unreadCount} />
</div>
);
const avatar = (
<HoverRefWrapper accountId={account.id}>
<Link to={`/@${account.acct}`}>
<Avatar account={account} size={18} />
</Link>
</HoverRefWrapper>
);
return (
<div className={`pane pane--${windowState}`} style={{ right: `${right}px` }}>
<div className='pane__header'>
{unreadCount > 0 ? unreadIcon : avatar }
<button className='pane__title' onClick={handleChatToggle(chat.id)}>
@{getAcct(account, displayFqn)}
</button>
<div className='pane__close'>
<IconButton src={require('@tabler/icons/icons/x.svg')} title='Close chat' onClick={handleChatClose(chat.id)} />
</div>
</div>
<div className='pane__content'>
<ChatBox
chatId={chat.id}
onSetInputRef={handleInputRef}
/>
</div>
</div>
);
};
export default ChatWindow;

View file

@ -5,11 +5,11 @@ import { useHistory } from 'react-router-dom';
import { launchChat } from 'soapbox/actions/chats';
import AccountSearch from 'soapbox/components/account_search';
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
import { Column } from '../../components/ui';
import ChatList from './components/chat_list';
import ChatList from './components/chat-list';
const messages = defineMessages({
title: { id: 'column.chats', defaultMessage: 'Chats' },

View file

@ -1,13 +1,10 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppSelector } from 'soapbox/hooks';
import Motion from '../../ui/util/optional_motion';
const UploadProgress = () => {
/** File upload progress bar for post composer. */
const ComposeUploadProgress = () => {
const active = useAppSelector((state) => state.compose.get('is_uploading'));
const progress = useAppSelector((state) => state.compose.get('progress'));
@ -16,30 +13,8 @@ const UploadProgress = () => {
}
return (
<HStack alignItems='center' space={2}>
<Icon
src={require('@tabler/icons/icons/cloud-upload.svg')}
className='w-7 h-7 text-gray-500'
/>
<Stack space={1}>
<Text theme='muted'>
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
</Text>
<div className='w-full h-1.5 rounded-lg bg-gray-200 relative'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
(<div
className='absolute left-0 top-0 h-1.5 bg-primary-600 rounded-lg'
style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
</Stack>
</HStack>
<UploadProgress progress={progress} />
);
};
export default UploadProgress;
export default ComposeUploadProgress;

View file

@ -15,16 +15,16 @@ const onlyImages = (types: ImmutableList<string>) => {
};
interface IUploadButton {
disabled: boolean,
unavailable: boolean,
disabled?: boolean,
unavailable?: boolean,
onSelectFile: (files: FileList) => void,
style: React.CSSProperties,
style?: React.CSSProperties,
resetFileKey: number,
}
const UploadButton: React.FC<IUploadButton> = ({
disabled,
unavailable,
disabled = false,
unavailable = false,
onSelectFile,
resetFileKey,
}) => {

View file

@ -315,11 +315,11 @@ export function ChatIndex() {
}
export function ChatRoom() {
return import(/* webpackChunkName: "features/chats/chat_room" */'../../chats/chat_room');
return import(/* webpackChunkName: "features/chats/chat_room" */'../../chats/chat-room');
}
export function ChatPanes() {
return import(/* webpackChunkName: "features/chats/components/chat_panes" */'../../chats/components/chat_panes');
return import(/* webpackChunkName: "features/chats/components/chat_panes" */'../../chats/components/chat-panes');
}
export function ServerInfo() {

View file

@ -4,6 +4,7 @@ export { useAppSelector } from './useAppSelector';
export { useFeatures } from './useFeatures';
export { useOnScreen } from './useOnScreen';
export { useOwnAccount } from './useOwnAccount';
export { useRefEventHandler } from './useRefEventHandler';
export { useSettings } from './useSettings';
export { useSoapboxConfig } from './useSoapboxConfig';
export { useSystemTheme } from './useSystemTheme';

View file

@ -0,0 +1,9 @@
import { useRef } from 'react';
/** Hook that allows using useState values in event handlers. */
// https://stackoverflow.com/a/64770671/8811886
export const useRefEventHandler = (fn: (...params: any) => void) => {
const ref = useRef(fn);
ref.current = fn;
return ref;
};