@@ -1,61 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import { changeSetting, getSettings } from 'soapbox/actions/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' },
|
||||
switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
checked: getSettings(state).getIn(['chats', 'sound'], false),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleAudio(setting) {
|
||||
dispatch(changeSetting(['chats', 'sound'], setting));
|
||||
},
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class AudioToggle extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
toggleAudio: PropTypes.func,
|
||||
showLabel: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleToggleAudio = () => {
|
||||
this.props.toggleAudio(!this.props.checked);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, checked, showLabel } = this.props;
|
||||
const id = 'chats-audio-toggle';
|
||||
const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn);
|
||||
|
||||
return (
|
||||
<div className='audio-toggle react-toggle--mini'>
|
||||
<div className='setting-toggle' aria-label={label}>
|
||||
<Toggle
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={this.handleToggleAudio}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
{showLabel && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
46
app/soapbox/features/chats/components/audio_toggle.tsx
Normal file
46
app/soapbox/features/chats/components/audio_toggle.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import { changeSetting, getSettings } from 'soapbox/actions/settings';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' },
|
||||
switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' },
|
||||
});
|
||||
|
||||
interface IAudioToggle {
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const AudioToggle: React.FC<IAudioToggle> = ({ showLabel }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound']));
|
||||
|
||||
const handleToggleAudio = () => {
|
||||
dispatch(changeSetting(['chats', 'sound'], !checked));
|
||||
};
|
||||
|
||||
const id = 'chats-audio-toggle';
|
||||
const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn);
|
||||
|
||||
return (
|
||||
<div className='audio-toggle react-toggle--mini'>
|
||||
<div className='setting-toggle' aria-label={label}>
|
||||
<Toggle
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={handleToggleAudio}
|
||||
// onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
{showLabel && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioToggle;
|
||||
@@ -1,87 +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 { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getChat = makeGetChat();
|
||||
|
||||
const mapStateToProps = (state, { chatId }) => {
|
||||
const chat = state.getIn(['chats', 'items', chatId]);
|
||||
|
||||
return {
|
||||
chat: chat ? getChat(state, chat.toJS()) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
class Chat extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
chatId: PropTypes.string.isRequired,
|
||||
chat: ImmutablePropTypes.map,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onClick(this.props.chat);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chat } = this.props;
|
||||
if (!chat) return null;
|
||||
const account = chat.get('account');
|
||||
const unreadCount = chat.get('unread');
|
||||
const content = chat.getIn(['last_message', 'content']);
|
||||
const attachment = chat.getIn(['last_message', 'attachment']);
|
||||
const image = attachment && attachment.getIn(['pleroma', 'mime_type'], '').startsWith('image/');
|
||||
const parsedContent = content ? emojify(content) : '';
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<button className='floating-link' onClick={this.handleClick} />
|
||||
<div className='account__wrapper'>
|
||||
<div key={account.get('id')} className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
<DisplayName account={account} />
|
||||
{attachment && (
|
||||
<Icon
|
||||
className='chat__attachment-icon'
|
||||
src={image ? require('@tabler/icons/icons/photo.svg') : require('@tabler/icons/icons/paperclip.svg')}
|
||||
/>
|
||||
)}
|
||||
{content ? (
|
||||
<span
|
||||
className='chat__last-message'
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
/>
|
||||
) : attachment && (
|
||||
<span
|
||||
className='chat__last-message attachment'
|
||||
>
|
||||
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
69
app/soapbox/features/chats/components/chat.tsx
Normal file
69
app/soapbox/features/chats/components/chat.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getChat = makeGetChat();
|
||||
|
||||
interface IChat {
|
||||
chatId: string,
|
||||
onClick: (chat: any) => void,
|
||||
}
|
||||
|
||||
const Chat: React.FC<IChat> = ({ chatId, onClick }) => {
|
||||
const chat = useAppSelector((state) => {
|
||||
const chat = state.chats.getIn(['items', chatId]);
|
||||
return chat ? getChat(state, (chat as any).toJS()) : undefined;
|
||||
}) as ChatEntity;
|
||||
|
||||
const account = chat.account as AccountEntity;
|
||||
if (!chat || !account) return null;
|
||||
const unreadCount = chat.unread;
|
||||
const content = chat.getIn(['last_message', 'content']);
|
||||
const attachment = chat.getIn(['last_message', 'attachment']);
|
||||
const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/');
|
||||
const parsedContent = content ? emojify(content) : '';
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<button className='floating-link' onClick={() => onClick(chat)} />
|
||||
<div className='account__wrapper'>
|
||||
<div key={account.id} className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
<DisplayName account={account} />
|
||||
{attachment && (
|
||||
<Icon
|
||||
className='chat__attachment-icon'
|
||||
src={image ? require('@tabler/icons/icons/photo.svg') : require('@tabler/icons/icons/paperclip.svg')}
|
||||
/>
|
||||
)}
|
||||
{content ? (
|
||||
<span
|
||||
className='chat__last-message'
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
/>
|
||||
) : attachment && (
|
||||
<span
|
||||
className='chat__last-message attachment'
|
||||
>
|
||||
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { expandChats } from 'soapbox/actions/chats';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat';
|
||||
|
||||
import Chat from './chat';
|
||||
|
||||
const messages = defineMessages({
|
||||
emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' },
|
||||
});
|
||||
|
||||
const getSortedChatIds = chats => (
|
||||
chats
|
||||
.toList()
|
||||
.sort(chatDateComparator)
|
||||
.map(chat => chat.get('id'))
|
||||
);
|
||||
|
||||
const chatDateComparator = (chatA, chatB) => {
|
||||
// Sort most recently updated chats at the top
|
||||
const a = new Date(chatA.get('updated_at'));
|
||||
const b = new Date(chatB.get('updated_at'));
|
||||
|
||||
if (a === b) return 0;
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedChatIdsSelector = createSelector(
|
||||
[getSortedChatIds],
|
||||
chats => chats,
|
||||
);
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const mapStateToProps = state => ({
|
||||
chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])),
|
||||
hasMore: !!state.getIn(['chats', 'next']),
|
||||
isLoading: state.getIn(['chats', 'loading']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
class ChatList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
chatIds: ImmutablePropTypes.list,
|
||||
onClickChat: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
hasMore: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandChats());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, chatIds, hasMore, isLoading } = this.props;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
className='chat-list'
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && chatIds.size === 0}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
onRefresh={this.props.onRefresh}
|
||||
placeholderComponent={PlaceholderChat}
|
||||
placeholderCount={20}
|
||||
>
|
||||
{chatIds.map(chatId => (
|
||||
<div key={chatId} className='chat-list-item'>
|
||||
<Chat
|
||||
chatId={chatId}
|
||||
onClick={this.props.onClickChat}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
84
app/soapbox/features/chats/components/chat_list.tsx
Normal file
84
app/soapbox/features/chats/components/chat_list.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { expandChats } from 'soapbox/actions/chats';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Chat from './chat';
|
||||
|
||||
const messages = defineMessages({
|
||||
emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandChats());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const getSortedChatIds = (chats: ImmutableMap<string, any>) => (
|
||||
chats
|
||||
.toList()
|
||||
.sort(chatDateComparator)
|
||||
.map(chat => chat.id)
|
||||
);
|
||||
|
||||
const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => {
|
||||
// Sort most recently updated chats at the top
|
||||
const a = new Date(chatA.updated_at);
|
||||
const b = new Date(chatB.updated_at);
|
||||
|
||||
if (a === b) return 0;
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedChatIdsSelector = createSelector(
|
||||
[getSortedChatIds],
|
||||
chats => chats,
|
||||
);
|
||||
|
||||
interface IChatList {
|
||||
onClickChat: (chat: any) => void,
|
||||
onRefresh: () => void,
|
||||
}
|
||||
|
||||
const ChatList: React.FC<IChatList> = ({ onClickChat, onRefresh }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.get('items')));
|
||||
const hasMore = useAppSelector(state => !!state.chats.get('next'));
|
||||
const isLoading = useAppSelector(state => state.chats.get('isLoading'));
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
className='chat-list'
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && chatIds.size === 0}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
onRefresh={onRefresh}
|
||||
placeholderComponent={PlaceholderChat}
|
||||
placeholderCount={20}
|
||||
>
|
||||
{chatIds.map((chatId: string) => (
|
||||
<div key={chatId} className='chat-list-item'>
|
||||
<Chat
|
||||
chatId={chatId}
|
||||
onClick={onClickChat}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatList;
|
||||
@@ -1,66 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { fetchChats, launchChat } from 'soapbox/actions/chats';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
|
||||
|
||||
import { Column } from '../../components/ui';
|
||||
|
||||
import ChatList from './components/chat_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
@withRouter
|
||||
class ChatIndex extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
handleSuggestion = accountId => {
|
||||
this.props.dispatch(launchChat(accountId, this.props.history, true));
|
||||
}
|
||||
|
||||
handleClickChat = (chat) => {
|
||||
this.props.history.push(`/chats/${chat.get('id')}`);
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
const { dispatch } = this.props;
|
||||
return dispatch(fetchChats());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<div className='column__switch'>
|
||||
<AudioToggle />
|
||||
</div>
|
||||
|
||||
<AccountSearch
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onSelected={this.handleSuggestion}
|
||||
/>
|
||||
|
||||
<ChatList
|
||||
onClickChat={this.handleClickChat}
|
||||
onRefresh={this.handleRefresh}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
55
app/soapbox/features/chats/index.tsx
Normal file
55
app/soapbox/features/chats/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { fetchChats, launchChat } from 'soapbox/actions/chats';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
|
||||
|
||||
import { Column } from '../../components/ui';
|
||||
|
||||
import ChatList from './components/chat_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
});
|
||||
|
||||
const ChatIndex: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSuggestion = (accountId: string) => {
|
||||
dispatch(launchChat(accountId, history, true));
|
||||
};
|
||||
|
||||
const handleClickChat = (chat: { id: string }) => {
|
||||
history.push(`/chats/${chat.id}`);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(fetchChats());
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<div className='column__switch'>
|
||||
<AudioToggle />
|
||||
</div>
|
||||
|
||||
<AccountSearch
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onSelected={handleSuggestion}
|
||||
/>
|
||||
|
||||
<ChatList
|
||||
onClickChat={handleClickChat}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatIndex;
|
||||
@@ -26,7 +26,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
},
|
||||
|
||||
onOpenModal: media => {
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0, onClose: console.log }));
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
|
||||
},
|
||||
|
||||
onSubmit(router) {
|
||||
|
||||
Reference in New Issue
Block a user