Merge branch 'chats-tsx' into 'develop'
Convert Chats to TypeScript See merge request soapbox-pub/soapbox-fe!1542
This commit is contained in:
commit
3bd9467cc7
|
@ -2,7 +2,7 @@ import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
const noOp = () => {};
|
const noOp = (e) => {};
|
||||||
|
|
||||||
export function fetchMedia(mediaId) {
|
export function fetchMedia(mediaId) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
|
|
@ -15,7 +15,7 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
||||||
|
|
||||||
interface IHoverRefWrapper {
|
interface IHoverRefWrapper {
|
||||||
accountId: string,
|
accountId: string,
|
||||||
inline: boolean,
|
inline?: boolean,
|
||||||
className?: string,
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,95 +0,0 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchChat, markChatRead } from 'soapbox/actions/chats';
|
|
||||||
import { Column } from 'soapbox/components/ui';
|
|
||||||
import { makeGetChat } from 'soapbox/selectors';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import ChatBox from './components/chat_box';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { params }) => {
|
|
||||||
const getChat = makeGetChat();
|
|
||||||
const chat = state.getIn(['chats', 'items', params.chatId], ImmutableMap()).toJS();
|
|
||||||
|
|
||||||
return {
|
|
||||||
me: state.get('me'),
|
|
||||||
chat: getChat(state, chat),
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class ChatRoom extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
chat: ImmutablePropTypes.map,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
me: PropTypes.node,
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInputRef = (el) => {
|
|
||||||
this.inputElem = el;
|
|
||||||
this.focusInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
focusInput = () => {
|
|
||||||
if (!this.inputElem) return;
|
|
||||||
this.inputElem.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
markRead = () => {
|
|
||||||
const { dispatch, chat } = this.props;
|
|
||||||
if (!chat) return;
|
|
||||||
dispatch(markChatRead(chat.get('id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { dispatch, params } = this.props;
|
|
||||||
dispatch(fetchChat(params.chatId));
|
|
||||||
this.markRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const markReadConditions = [
|
|
||||||
() => this.props.chat,
|
|
||||||
() => this.props.chat.get('unread') > 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (markReadConditions.every(c => c()))
|
|
||||||
this.markRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { chat, displayFqn } = this.props;
|
|
||||||
if (!chat) return null;
|
|
||||||
const account = chat.get('account');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column label={`@${getAcct(account, displayFqn)}`}>
|
|
||||||
{/* <div className='chatroom__back'>
|
|
||||||
<Link to={`/@${account.get('acct')}`} className='chatroom__header'>
|
|
||||||
<Avatar account={account} size={18} />
|
|
||||||
<div className='chatroom__title'>
|
|
||||||
@{getAcct(account, displayFqn)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div> */}
|
|
||||||
<ChatBox
|
|
||||||
chatId={chat.get('id')}
|
|
||||||
onSetInputRef={this.handleInputRef}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -8,13 +8,13 @@ import { openChat, launchChat, toggleMainWindow } from 'soapbox/actions/chats';
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import AccountSearch from 'soapbox/components/account_search';
|
import AccountSearch from 'soapbox/components/account_search';
|
||||||
import { Counter } from 'soapbox/components/ui';
|
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 { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||||
import { RootState } from 'soapbox/store';
|
import { RootState } from 'soapbox/store';
|
||||||
import { Chat } from 'soapbox/types/entities';
|
import { Chat } from 'soapbox/types/entities';
|
||||||
|
|
||||||
import ChatList from './chat_list';
|
import ChatList from './chat-list';
|
||||||
import ChatWindow from './chat_window';
|
import ChatWindow from './chat-window';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
|
@ -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;
|
|
@ -1,214 +0,0 @@
|
||||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
sendChatMessage,
|
|
||||||
markChatRead,
|
|
||||||
} from 'soapbox/actions/chats';
|
|
||||||
import { uploadMedia } from 'soapbox/actions/media';
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import UploadProgress from 'soapbox/features/compose/components/upload-progress';
|
|
||||||
import UploadButton from 'soapbox/features/compose/components/upload_button';
|
|
||||||
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 mapStateToProps = (state, { chatId }) => ({
|
|
||||||
me: state.get('me'),
|
|
||||||
chat: state.getIn(['chats', 'items', chatId]),
|
|
||||||
chatMessageIds: state.getIn(['chat_message_lists', chatId], ImmutableOrderedSet()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileKeyGen = () => Math.floor((Math.random() * 0x10000));
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class ChatBox extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
chatId: PropTypes.string.isRequired,
|
|
||||||
chatMessageIds: ImmutablePropTypes.orderedSet,
|
|
||||||
chat: ImmutablePropTypes.record,
|
|
||||||
onSetInputRef: PropTypes.func,
|
|
||||||
me: PropTypes.node,
|
|
||||||
}
|
|
||||||
|
|
||||||
initialState = () => ({
|
|
||||||
content: '',
|
|
||||||
attachment: undefined,
|
|
||||||
isUploading: false,
|
|
||||||
uploadProgress: 0,
|
|
||||||
resetFileKey: fileKeyGen(),
|
|
||||||
})
|
|
||||||
|
|
||||||
state = this.initialState()
|
|
||||||
|
|
||||||
clearState = () => {
|
|
||||||
this.setState(this.initialState());
|
|
||||||
}
|
|
||||||
|
|
||||||
getParams = () => {
|
|
||||||
const { content, attachment } = this.state;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
media_id: attachment && attachment.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
canSubmit = () => {
|
|
||||||
const { content, attachment } = this.state;
|
|
||||||
|
|
||||||
const conds = [
|
|
||||||
content.length > 0,
|
|
||||||
attachment,
|
|
||||||
];
|
|
||||||
|
|
||||||
return conds.some(c => c);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage = () => {
|
|
||||||
const { dispatch, chatId } = this.props;
|
|
||||||
const { isUploading } = this.state;
|
|
||||||
|
|
||||||
if (this.canSubmit() && !isUploading) {
|
|
||||||
const params = this.getParams();
|
|
||||||
|
|
||||||
dispatch(sendChatMessage(chatId, params));
|
|
||||||
this.clearState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
insertLine = () => {
|
|
||||||
const { content } = this.state;
|
|
||||||
this.setState({ content: content + '\n' });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
|
||||||
this.markRead();
|
|
||||||
if (e.key === 'Enter' && e.shiftKey) {
|
|
||||||
this.insertLine();
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
this.sendMessage();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContentChange = (e) => {
|
|
||||||
this.setState({ content: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
markRead = () => {
|
|
||||||
const { dispatch, chatId } = this.props;
|
|
||||||
dispatch(markChatRead(chatId));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHover = () => {
|
|
||||||
this.markRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputRef = (el) => {
|
|
||||||
const { onSetInputRef } = this.props;
|
|
||||||
this.inputElem = el;
|
|
||||||
onSetInputRef(el);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRemoveFile = (e) => {
|
|
||||||
this.setState({ attachment: undefined, resetFileKey: fileKeyGen() });
|
|
||||||
}
|
|
||||||
|
|
||||||
onUploadProgress = (e) => {
|
|
||||||
const { loaded, total } = e;
|
|
||||||
this.setState({ uploadProgress: loaded / total });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFiles = (files) => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
|
|
||||||
this.setState({ isUploading: true });
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('file', files[0]);
|
|
||||||
|
|
||||||
dispatch(uploadMedia(data, this.onUploadProgress)).then(response => {
|
|
||||||
this.setState({ attachment: response.data, isUploading: false });
|
|
||||||
}).catch(() => {
|
|
||||||
this.setState({ isUploading: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderAttachment = () => {
|
|
||||||
const { attachment } = this.state;
|
|
||||||
if (!attachment) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='chat-box__attachment'>
|
|
||||||
<div className='chat-box__filename'>
|
|
||||||
{truncateFilename(attachment.preview_url, 20)}
|
|
||||||
</div>
|
|
||||||
<div class='chat-box__remove-attachment'>
|
|
||||||
<IconButton
|
|
||||||
src={require('@tabler/icons/icons/x.svg')}
|
|
||||||
onClick={this.handleRemoveFile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderActionButton = () => {
|
|
||||||
const { intl } = this.props;
|
|
||||||
const { resetFileKey } = this.state;
|
|
||||||
|
|
||||||
return this.canSubmit() ? (
|
|
||||||
<IconButton
|
|
||||||
src={require('@tabler/icons/icons/send.svg')}
|
|
||||||
title={intl.formatMessage(messages.send)}
|
|
||||||
onClick={this.sendMessage}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UploadButton onSelectFile={this.handleFiles} resetFileKey={resetFileKey} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { chatMessageIds, chatId, intl } = this.props;
|
|
||||||
const { content, isUploading, uploadProgress } = this.state;
|
|
||||||
if (!chatMessageIds) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='chat-box' onMouseOver={this.handleHover}>
|
|
||||||
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} />
|
|
||||||
{this.renderAttachment()}
|
|
||||||
<UploadProgress active={isUploading} progress={uploadProgress * 100} />
|
|
||||||
<div className='chat-box__actions simple_form'>
|
|
||||||
<div className='chat-box__send'>
|
|
||||||
{this.renderActionButton()}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
rows={1}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onChange={this.handleContentChange}
|
|
||||||
value={content}
|
|
||||||
ref={this.setInputRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,340 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
|
||||||
import escape from 'lodash/escape';
|
|
||||||
import throttle from 'lodash/throttle';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
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 { onlyEmoji } from 'soapbox/utils/rich_content';
|
|
||||||
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeChange = (prev, curr) => {
|
|
||||||
const prevDate = new Date(prev.get('created_at')).getDate();
|
|
||||||
const currDate = new Date(curr.get('created_at')).getDate();
|
|
||||||
const nowDate = new Date().getDate();
|
|
||||||
|
|
||||||
if (prevDate !== currDate) {
|
|
||||||
return currDate === nowDate ? 'today' : 'date';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeEmojiMap = record => record.get('emojis', ImmutableList()).reduce((map, emoji) => {
|
|
||||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
|
||||||
}, ImmutableMap());
|
|
||||||
|
|
||||||
const makeGetChatMessages = () => {
|
|
||||||
return createSelector(
|
|
||||||
[(chatMessages, chatMessageIds) => (
|
|
||||||
chatMessageIds.reduce((acc, curr) => {
|
|
||||||
const chatMessage = chatMessages.get(curr);
|
|
||||||
return chatMessage ? acc.push(chatMessage) : acc;
|
|
||||||
}, ImmutableList())
|
|
||||||
)],
|
|
||||||
chatMessages => chatMessages,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getChatMessages = makeGetChatMessages();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { chatMessageIds }) => {
|
|
||||||
const chatMessages = state.get('chat_messages');
|
|
||||||
|
|
||||||
return {
|
|
||||||
me: state.get('me'),
|
|
||||||
chatMessages: getChatMessages(chatMessages, chatMessageIds),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class ChatMessageList extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
chatId: PropTypes.string,
|
|
||||||
chatMessages: ImmutablePropTypes.list,
|
|
||||||
chatMessageIds: ImmutablePropTypes.orderedSet,
|
|
||||||
me: PropTypes.node,
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
chatMessages: ImmutableList(),
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
initialLoad: true,
|
|
||||||
isLoading: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom = () => {
|
|
||||||
if (!this.messagesEnd) return;
|
|
||||||
this.messagesEnd.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessageEndRef = (el) => {
|
|
||||||
this.messagesEnd = el;
|
|
||||||
};
|
|
||||||
|
|
||||||
getFormattedTimestamp = (chatMessage) => {
|
|
||||||
const { intl } = this.props;
|
|
||||||
return intl.formatDate(
|
|
||||||
new Date(chatMessage.get('created_at')), {
|
|
||||||
hour12: false,
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
setBubbleRef = (c) => {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isNearBottom = () => {
|
|
||||||
const elem = this.node;
|
|
||||||
if (!elem) return false;
|
|
||||||
|
|
||||||
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
|
|
||||||
return scrollBottom < elem.offsetHeight * 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResize = throttle((e) => {
|
|
||||||
if (this.isNearBottom()) this.scrollToBottom();
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { dispatch, chatId } = this.props;
|
|
||||||
dispatch(fetchChatMessages(chatId));
|
|
||||||
|
|
||||||
this.node.addEventListener('scroll', this.handleScroll);
|
|
||||||
window.addEventListener('resize', this.handleResize);
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
getSnapshotBeforeUpdate(prevProps, prevState) {
|
|
||||||
const { scrollHeight, scrollTop } = this.node;
|
|
||||||
return scrollHeight - scrollTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreScrollPosition = (scrollBottom) => {
|
|
||||||
this.lastComputedScroll = this.node.scrollHeight - scrollBottom;
|
|
||||||
this.node.scrollTop = this.lastComputedScroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState, scrollBottom) {
|
|
||||||
const { initialLoad } = this.state;
|
|
||||||
const oldCount = prevProps.chatMessages.count();
|
|
||||||
const newCount = this.props.chatMessages.count();
|
|
||||||
const isNearBottom = this.isNearBottom();
|
|
||||||
const historyAdded = prevProps.chatMessages.getIn([0, 'id']) !== this.props.chatMessages.getIn([0, 'id']);
|
|
||||||
|
|
||||||
// Retain scroll bar position when loading old messages
|
|
||||||
this.restoreScrollPosition(scrollBottom);
|
|
||||||
|
|
||||||
if (oldCount !== newCount) {
|
|
||||||
if (isNearBottom || initialLoad) this.scrollToBottom();
|
|
||||||
if (historyAdded) this.setState({ isLoading: false, initialLoad: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.node.removeEventListener('scroll', this.handleScroll);
|
|
||||||
window.removeEventListener('resize', this.handleResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadMore = () => {
|
|
||||||
const { dispatch, chatId, chatMessages } = this.props;
|
|
||||||
const maxId = chatMessages.getIn([0, 'id']);
|
|
||||||
dispatch(fetchChatMessages(chatId, maxId));
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleScroll = throttle(() => {
|
|
||||||
const { lastComputedScroll } = this;
|
|
||||||
const { isLoading, initialLoad } = this.state;
|
|
||||||
const { scrollTop, offsetHeight } = this.node;
|
|
||||||
const computedScroll = lastComputedScroll === scrollTop;
|
|
||||||
const nearTop = scrollTop < offsetHeight * 2;
|
|
||||||
|
|
||||||
if (nearTop && !isLoading && !initialLoad && !computedScroll)
|
|
||||||
this.handleLoadMore();
|
|
||||||
}, 150, {
|
|
||||||
trailing: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenMedia = (media, index) => {
|
|
||||||
this.props.dispatch(openModal('MEDIA', { media, index }));
|
|
||||||
};
|
|
||||||
|
|
||||||
maybeRenderMedia = chatMessage => {
|
|
||||||
const attachment = chatMessage.get('attachment');
|
|
||||||
if (!attachment) return null;
|
|
||||||
return (
|
|
||||||
<div className='chat-message__media'>
|
|
||||||
<Bundle fetchComponent={MediaGallery}>
|
|
||||||
{Component => (
|
|
||||||
<Component
|
|
||||||
media={ImmutableList([attachment])}
|
|
||||||
height={120}
|
|
||||||
onOpenMedia={this.onOpenMedia}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Bundle>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
parsePendingContent = content => {
|
|
||||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
|
||||||
}
|
|
||||||
|
|
||||||
parseContent = chatMessage => {
|
|
||||||
const content = chatMessage.get('content') || '';
|
|
||||||
const pending = chatMessage.get('pending', false);
|
|
||||||
const deleting = chatMessage.get('deleting', false);
|
|
||||||
const formatted = (pending && !deleting) ? this.parsePendingContent(content) : content;
|
|
||||||
const emojiMap = makeEmojiMap(chatMessage);
|
|
||||||
return emojify(formatted, emojiMap.toJS());
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = (c) => {
|
|
||||||
this.node = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderDivider = (key, text) => (
|
|
||||||
<div className='chat-messages__divider' key={key}>{text}</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
handleDeleteMessage = (chatId, messageId) => {
|
|
||||||
return () => {
|
|
||||||
this.props.dispatch(deleteChatMessage(chatId, messageId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReportUser = (userId) => {
|
|
||||||
return () => {
|
|
||||||
this.props.dispatch(initReportById(userId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
renderMessage = (chatMessage) => {
|
|
||||||
const { me, intl } = this.props;
|
|
||||||
const menu = [
|
|
||||||
{
|
|
||||||
text: intl.formatMessage(messages.delete),
|
|
||||||
action: this.handleDeleteMessage(chatMessage.get('chat_id'), chatMessage.get('id')),
|
|
||||||
icon: require('@tabler/icons/icons/trash.svg'),
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (chatMessage.get('account_id') !== me) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.report),
|
|
||||||
action: this.handleReportUser(chatMessage.get('account_id')),
|
|
||||||
icon: require('@tabler/icons/icons/flag.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames('chat-message', {
|
|
||||||
'chat-message--me': chatMessage.get('account_id') === me,
|
|
||||||
'chat-message--pending': chatMessage.get('pending', false) === true,
|
|
||||||
})}
|
|
||||||
key={chatMessage.get('id')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
title={this.getFormattedTimestamp(chatMessage)}
|
|
||||||
className='chat-message__bubble'
|
|
||||||
ref={this.setBubbleRef}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{this.maybeRenderMedia(chatMessage)}
|
|
||||||
<Text size='sm' dangerouslySetInnerHTML={{ __html: this.parseContent(chatMessage) }} />
|
|
||||||
<div className='chat-message__menu'>
|
|
||||||
<DropdownMenuContainer
|
|
||||||
items={menu}
|
|
||||||
src={require('@tabler/icons/icons/dots.svg')}
|
|
||||||
direction='top'
|
|
||||||
title={intl.formatMessage(messages.more)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { chatMessages, intl } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='chat-messages' ref={this.setRef}>
|
|
||||||
{chatMessages.reduce((acc, curr, idx) => {
|
|
||||||
const lastMessage = chatMessages.get(idx - 1);
|
|
||||||
|
|
||||||
if (lastMessage) {
|
|
||||||
const key = `${curr.get('id')}_divider`;
|
|
||||||
switch (timeChange(lastMessage, curr)) {
|
|
||||||
case 'today':
|
|
||||||
acc.push(this.renderDivider(key, intl.formatMessage(messages.today)));
|
|
||||||
break;
|
|
||||||
case 'date':
|
|
||||||
acc.push(this.renderDivider(key, new Date(curr.get('created_at')).toDateString()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.push(this.renderMessage(curr));
|
|
||||||
return acc;
|
|
||||||
}, [])}
|
|
||||||
<div style={{ float: 'left', clear: 'both' }} ref={this.setMessageEndRef} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,135 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
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 { makeGetChat } from 'soapbox/selectors';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import ChatBox from './chat_box';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getChat = makeGetChat();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { chatId }) => {
|
|
||||||
const chat = state.getIn(['chats', 'items', chatId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
me: state.get('me'),
|
|
||||||
chat: chat ? getChat(state, chat.toJS()) : undefined,
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class ChatWindow extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
chatId: PropTypes.string.isRequired,
|
|
||||||
windowState: PropTypes.string.isRequired,
|
|
||||||
idx: PropTypes.number,
|
|
||||||
chat: ImmutablePropTypes.record,
|
|
||||||
me: PropTypes.node,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
content: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChatClose = (chatId) => {
|
|
||||||
return (e) => {
|
|
||||||
this.props.dispatch(closeChat(chatId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChatToggle = (chatId) => {
|
|
||||||
return (e) => {
|
|
||||||
this.props.dispatch(toggleChat(chatId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContentChange = (e) => {
|
|
||||||
this.setState({ content: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInputRef = (el) => {
|
|
||||||
this.inputElem = el;
|
|
||||||
this.focusInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
focusInput = () => {
|
|
||||||
if (!this.inputElem) return;
|
|
||||||
this.inputElem.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const oldState = prevProps.windowState;
|
|
||||||
const newState = this.props.windowState;
|
|
||||||
|
|
||||||
if (oldState !== newState && newState === 'open')
|
|
||||||
this.focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { windowState, idx, chat, displayFqn } = this.props;
|
|
||||||
if (!chat) return null;
|
|
||||||
const account = chat.get('account');
|
|
||||||
|
|
||||||
const right = (285 * (idx + 1)) + 20;
|
|
||||||
const unreadCount = chat.get('unread');
|
|
||||||
|
|
||||||
const unreadIcon = (
|
|
||||||
<div className='mr-2 flex-none'>
|
|
||||||
<Counter count={unreadCount} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const avatar = (
|
|
||||||
<HoverRefWrapper accountId={account.get('id')}>
|
|
||||||
<Link to={`/@${account.get('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={this.handleChatToggle(chat.get('id'))}>
|
|
||||||
@{getAcct(account, displayFqn)}
|
|
||||||
</button>
|
|
||||||
<div className='pane__close'>
|
|
||||||
<IconButton src={require('@tabler/icons/icons/x.svg')} title='Close chat' onClick={this.handleChatClose(chat.get('id'))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='pane__content'>
|
|
||||||
<ChatBox
|
|
||||||
chatId={chat.get('id')}
|
|
||||||
onSetInputRef={this.handleInputRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -5,11 +5,11 @@ import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { launchChat } from 'soapbox/actions/chats';
|
import { launchChat } from 'soapbox/actions/chats';
|
||||||
import AccountSearch from 'soapbox/components/account_search';
|
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 { Column } from '../../components/ui';
|
||||||
|
|
||||||
import ChatList from './components/chat_list';
|
import ChatList from './components/chat-list';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import React from 'react';
|
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 { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import Motion from '../../ui/util/optional_motion';
|
/** File upload progress bar for post composer. */
|
||||||
|
const ComposeUploadProgress = () => {
|
||||||
const UploadProgress = () => {
|
|
||||||
const active = useAppSelector((state) => state.compose.get('is_uploading'));
|
const active = useAppSelector((state) => state.compose.get('is_uploading'));
|
||||||
const progress = useAppSelector((state) => state.compose.get('progress'));
|
const progress = useAppSelector((state) => state.compose.get('progress'));
|
||||||
|
|
||||||
|
@ -16,30 +13,8 @@ const UploadProgress = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack alignItems='center' space={2}>
|
<UploadProgress progress={progress} />
|
||||||
<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;
|
export default ComposeUploadProgress;
|
||||||
|
|
|
@ -15,16 +15,16 @@ const onlyImages = (types: ImmutableList<string>) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IUploadButton {
|
interface IUploadButton {
|
||||||
disabled: boolean,
|
disabled?: boolean,
|
||||||
unavailable: boolean,
|
unavailable?: boolean,
|
||||||
onSelectFile: (files: FileList) => void,
|
onSelectFile: (files: FileList) => void,
|
||||||
style: React.CSSProperties,
|
style?: React.CSSProperties,
|
||||||
resetFileKey: number,
|
resetFileKey: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadButton: React.FC<IUploadButton> = ({
|
const UploadButton: React.FC<IUploadButton> = ({
|
||||||
disabled,
|
disabled = false,
|
||||||
unavailable,
|
unavailable = false,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
resetFileKey,
|
resetFileKey,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
@ -315,11 +315,11 @@ export function ChatIndex() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatRoom() {
|
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() {
|
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() {
|
export function ServerInfo() {
|
||||||
|
|
|
@ -4,6 +4,7 @@ export { useAppSelector } from './useAppSelector';
|
||||||
export { useFeatures } from './useFeatures';
|
export { useFeatures } from './useFeatures';
|
||||||
export { useOnScreen } from './useOnScreen';
|
export { useOnScreen } from './useOnScreen';
|
||||||
export { useOwnAccount } from './useOwnAccount';
|
export { useOwnAccount } from './useOwnAccount';
|
||||||
|
export { useRefEventHandler } from './useRefEventHandler';
|
||||||
export { useSettings } from './useSettings';
|
export { useSettings } from './useSettings';
|
||||||
export { useSoapboxConfig } from './useSoapboxConfig';
|
export { useSoapboxConfig } from './useSoapboxConfig';
|
||||||
export { useSystemTheme } from './useSystemTheme';
|
export { useSystemTheme } from './useSystemTheme';
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
Loading…
Reference in New Issue