Merge branch 'chats' into 'develop'

ChatMessages MVP

See merge request soapbox-pub/soapbox-fe!185
This commit is contained in:
Alex Gleason 2020-08-28 22:03:34 +00:00
commit a419c5da34
42 changed files with 1404 additions and 72 deletions

View file

@ -0,0 +1,152 @@
import api from '../api';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { v4 as uuidv4 } from 'uuid';
import { Map as ImmutableMap } from 'immutable';
export const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST';
export const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS';
export const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL';
export const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST';
export const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS';
export const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL';
export const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST';
export const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS';
export const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL';
export const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST';
export const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS';
export const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL';
export const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST';
export const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS';
export const CHAT_READ_FAIL = 'CHAT_READ_FAIL';
export function fetchChats() {
return (dispatch, getState) => {
dispatch({ type: CHATS_FETCH_REQUEST });
return api(getState).get('/api/v1/pleroma/chats').then(({ data }) => {
dispatch({ type: CHATS_FETCH_SUCCESS, chats: data });
}).catch(error => {
dispatch({ type: CHATS_FETCH_FAIL, error });
});
};
}
export function fetchChatMessages(chatId) {
return (dispatch, getState) => {
dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`).then(({ data }) => {
dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, chatMessages: data });
}).catch(error => {
dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, error });
});
};
}
export function sendChatMessage(chatId, params) {
return (dispatch, getState) => {
const uuid = uuidv4();
const me = getState().get('me');
dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me });
return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then(({ data }) => {
dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid });
}).catch(error => {
dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid });
});
};
}
export function openChat(chatId) {
return (dispatch, getState) => {
const state = getState();
const panes = getSettings(state).getIn(['chats', 'panes']);
const idx = panes.findIndex(pane => pane.get('chat_id') === chatId);
dispatch(markChatRead(chatId));
if (idx > -1) {
return dispatch(changeSetting(['chats', 'panes', idx, 'state'], 'open'));
} else {
const newPane = ImmutableMap({ chat_id: chatId, state: 'open' });
return dispatch(changeSetting(['chats', 'panes'], panes.push(newPane)));
}
};
}
export function closeChat(chatId) {
return (dispatch, getState) => {
const panes = getSettings(getState()).getIn(['chats', 'panes']);
const idx = panes.findIndex(pane => pane.get('chat_id') === chatId);
if (idx > -1) {
return dispatch(changeSetting(['chats', 'panes'], panes.delete(idx)));
} else {
return false;
}
};
}
export function toggleChat(chatId) {
return (dispatch, getState) => {
const panes = getSettings(getState()).getIn(['chats', 'panes']);
const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId);
if (idx > -1) {
const state = pane.get('state') === 'minimized' ? 'open' : 'minimized';
if (state === 'open') dispatch(markChatRead(chatId));
return dispatch(changeSetting(['chats', 'panes', idx, 'state'], state));
} else {
return false;
}
};
}
export function toggleMainWindow() {
return (dispatch, getState) => {
const main = getSettings(getState()).getIn(['chats', 'mainWindow']);
const state = main === 'minimized' ? 'open' : 'minimized';
return dispatch(changeSetting(['chats', 'mainWindow'], state));
};
}
export function fetchChat(chatId) {
return (dispatch, getState) => {
dispatch({ type: CHAT_FETCH_REQUEST, chatId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then(({ data }) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
}).catch(error => {
dispatch({ type: CHAT_FETCH_FAIL, chatId, error });
});
};
}
export function startChat(accountId) {
return (dispatch, getState) => {
dispatch({ type: CHAT_FETCH_REQUEST, accountId });
return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then(({ data }) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
return data;
}).catch(error => {
dispatch({ type: CHAT_FETCH_FAIL, accountId, error });
});
};
}
export function markChatRead(chatId, lastReadId) {
return (dispatch, getState) => {
const chat = getState().getIn(['chats', chatId]);
if (!lastReadId) lastReadId = chat.get('last_message');
if (chat.get('unread') < 1) return;
if (!lastReadId) return;
dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId });
api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then(({ data }) => {
dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId });
}).catch(error => {
dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId });
});
};
}

View file

@ -1,5 +1,9 @@
import { getSettings } from '../settings';
import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
import {
normalizeAccount,
normalizeStatus,
normalizePoll,
} from './normalizer';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';

View file

@ -80,3 +80,13 @@ export function normalizePoll(poll) {
return normalPoll;
}
export function normalizeChat(chat, normalOldChat) {
const normalChat = { ...chat };
const { account, last_message: lastMessage } = chat;
if (account) normalChat.account = account.id;
if (lastMessage) normalChat.last_message = lastMessage.id;
return normalChat;
}

View file

@ -13,7 +13,6 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors';
import { fetchMarkers } from './markers';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -71,6 +70,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) {
return (dispatch, getState) => {
if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat
const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]);
const filters = getFilters(getState(), { contextType: 'notifications' });
const playSound = getSettings(getState()).getIn(['notifications', 'sounds', notification.type]);
@ -173,7 +174,6 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
params.since_id = notifications.getIn(['items', 0, 'id']);
}
dispatch(fetchMarkers(['notifications']));
dispatch(expandNotificationsRequest(isLoadingMore));
api(getState).get('/api/v1/notifications', { params }).then(response => {

View file

@ -1,7 +1,7 @@
import { debounce } from 'lodash';
import { showAlertForError } from './alerts';
import { patchMe } from 'soapbox/actions/me';
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
@ -29,6 +29,11 @@ const defaultSettings = ImmutableMap({
dyslexicFont: false,
demetricator: false,
chats: ImmutableMap({
panes: ImmutableList(),
mainWindow: 'minimized',
}),
home: ImmutableMap({
shows: ImmutableMap({
reblog: true,

View file

@ -12,6 +12,8 @@ import { fetchFilters } from './filters';
import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages';
export const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
const validLocale = locale => Object.keys(messages).includes(locale);
const getLocale = state => {
@ -52,6 +54,9 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a
case 'filters_changed':
dispatch(fetchFilters());
break;
case 'pleroma:chat_update':
dispatch({ type: STREAMING_CHAT_UPDATE, chat: JSON.parse(data.payload), me: getState().get('me') });
break;
}
},
};

View file

@ -21,7 +21,6 @@ const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' },
profile: { id: 'account.profile', defaultMessage: 'Profile' },
messages: { id: 'navigation_bar.messages', defaultMessage: 'Messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
@ -132,10 +131,6 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='user' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.profile)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to={'/messages'} onClick={onClose}>
<Icon id='envelope' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.messages)}</span>
</NavLink>
{donateUrl ?
<a className='sidebar-menu-item' href={donateUrl} onClick={onClose}>
<Icon id='dollar' />

View file

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
import Button from 'soapbox/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { isStaff } from 'soapbox/utils/accounts';
@ -291,13 +292,10 @@ class Header extends ImmutablePureComponent {
me &&
<div className='account__header__extra__buttons'>
<ActionButton account={account} />
{account.get('id') !== me &&
<Button className='button button-alternative-2' onClick={this.props.onDirect}>
<FormattedMessage
id='account.message' defaultMessage='Message' values={{
name: account.get('acct'),
}}
/>
{account.get('id') !== me && account.getIn(['pleroma', 'accepts_chat_messages'], false) === true &&
<Button className='button-alternative-2' onClick={this.props.onChat}>
<Icon id='comment' />
<FormattedMessage id='account.message' defaultMessage='Message' />
</Button>
}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />

View file

@ -19,7 +19,7 @@ export default class Header extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
// onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
username: PropTypes.string,
};
@ -72,6 +72,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onUnblockDomain(domain);
}
handleChat = () => {
this.props.onChat(this.props.account, this.context.router.history);
}
// handleEndorseToggle = () => {
// this.props.onEndorseToggle(this.props.account);
// }
@ -95,6 +99,7 @@ export default class Header extends ImmutablePureComponent {
onBlock={this.handleBlock}
onMention={this.handleMention}
onDirect={this.handleDirect}
onChat={this.handleChat}
onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}

View file

@ -22,6 +22,8 @@ import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { getSettings } from 'soapbox/actions/settings';
import { startChat, openChat } from 'soapbox/actions/chats';
import { isMobile } from 'soapbox/is_mobile';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@ -127,12 +129,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(unblockDomain(domain));
},
onAddToList(account){
onAddToList(account) {
dispatch(openModal('LIST_ADDER', {
accountId: account.get('id'),
}));
},
onChat(account, router) {
// TODO make this faster
dispatch(startChat(account.get('id'))).then(chat => {
if (isMobile(window.innerWidth)) {
router.push(`/chats/${chat.id}`);
} else {
dispatch(openChat(chat.id));
}
}).catch(() => {});
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

View file

@ -0,0 +1,74 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from 'soapbox/components/avatar';
import { acctFull } from 'soapbox/utils/accounts';
import { fetchChat } from 'soapbox/actions/chats';
import ChatBox from './components/chat_box';
import Column from 'soapbox/components/column';
import ColumnBackButton from 'soapbox/components/column_back_button';
import { makeGetChat } from 'soapbox/selectors';
const mapStateToProps = (state, { params }) => {
const getChat = makeGetChat();
return {
me: state.get('me'),
chat: getChat(state, { id: params.chatId }),
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatRoom extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
chat: ImmutablePropTypes.map,
me: PropTypes.node,
}
handleInputRef = (el) => {
this.inputElem = el;
this.focusInput();
};
focusInput = () => {
if (!this.inputElem) return;
this.inputElem.focus();
}
componentDidMount() {
const { dispatch, params } = this.props;
dispatch(fetchChat(params.chatId));
}
render() {
const { chat } = this.props;
if (!chat) return null;
const account = chat.get('account');
return (
<Column>
<div className='chatroom__back'>
<ColumnBackButton />
<div className='chatroom__header'>
<Avatar account={account} size={18} />
<div className='chatroom__title'>
@{acctFull(account)}
</div>
</div>
</div>
<ChatBox
chatId={chat.get('id')}
onSetInputRef={this.handleInputRef}
/>
</Column>
);
}
}

View file

@ -0,0 +1,42 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { shortNumberFormat } from 'soapbox/utils/numbers';
export default class Chat extends ImmutablePureComponent {
static propTypes = {
chat: ImmutablePropTypes.map.isRequired,
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');
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} />
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,108 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import {
fetchChatMessages,
sendChatMessage,
markChatRead,
} from 'soapbox/actions/chats';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import ChatMessageList from './chat_message_list';
const messages = defineMessages({
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' },
});
const mapStateToProps = (state, { chatId }) => ({
me: state.get('me'),
chat: state.getIn(['chats', chatId]),
chatMessageIds: state.getIn(['chat_message_lists', chatId], ImmutableOrderedSet()),
});
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.map,
onSetInputRef: PropTypes.func,
me: PropTypes.node,
}
state = {
content: '',
}
handleKeyDown = (e) => {
const { chatId } = this.props;
if (e.key === 'Enter') {
this.props.dispatch(sendChatMessage(chatId, this.state));
this.setState({ content: '' });
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);
};
componentDidMount() {
const { dispatch, chatId } = this.props;
dispatch(fetchChatMessages(chatId));
}
componentDidUpdate(prevProps) {
const markReadConditions = [
() => this.props.chat !== undefined,
() => document.activeElement === this.inputElem,
() => this.props.chat.get('unread') > 0,
];
if (markReadConditions.every(c => c() === true))
this.markRead();
}
render() {
const { chatMessageIds, intl } = this.props;
if (!chatMessageIds) return null;
return (
<div className='chat-box' onMouseOver={this.handleHover}>
<ChatMessageList chatMessageIds={chatMessageIds} />
<div className='chat-box__actions simple_form'>
<textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={this.handleKeyDown}
onChange={this.handleContentChange}
value={this.state.content}
ref={this.setInputRef}
/>
</div>
</div>
);
}
}

View file

@ -0,0 +1,71 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { fetchChats } from 'soapbox/actions/chats';
import Chat from './chat';
import { makeGetChat } from 'soapbox/selectors';
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 mapStateToProps = state => {
const getChat = makeGetChat();
const chats = state.get('chats')
.map(chat => getChat(state, chat.toJS()))
.toList()
.sort(chatDateComparator);
return {
chats,
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatList extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onClickChat: PropTypes.func,
emptyMessage: PropTypes.node,
};
componentDidMount() {
this.props.dispatch(fetchChats());
}
render() {
const { chats, emptyMessage } = this.props;
return (
<div className='chat-list'>
<div className='chat-list__content'>
{chats.count() === 0 &&
<div className='empty-column-indicator'>{emptyMessage}</div>
}
{chats.map(chat => (
<div key={chat.get('id')} className='chat-list-item'>
<Chat
chat={chat}
onClick={this.props.onClickChat}
/>
</div>
))}
</div>
</div>
);
}
}

View file

@ -0,0 +1,89 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { List as ImmutableList } from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import classNames from 'classnames';
const mapStateToProps = (state, { chatMessageIds }) => ({
me: state.get('me'),
chatMessages: chatMessageIds.reduce((acc, curr) => {
const chatMessage = state.getIn(['chat_messages', curr]);
return chatMessage ? acc.push(chatMessage) : acc;
}, ImmutableList()).sort(),
});
export default @connect(mapStateToProps)
@injectIntl
class ChatMessageList extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
chatMessages: ImmutablePropTypes.list,
chatMessageIds: ImmutablePropTypes.orderedSet,
me: PropTypes.node,
}
static defaultProps = {
chatMessages: ImmutableList(),
}
scrollToBottom = () => {
if (!this.messagesEnd) return;
this.messagesEnd.scrollIntoView();
}
setMessageEndRef = (el) => {
this.messagesEnd = el;
this.scrollToBottom();
};
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',
}
);
};
componentDidUpdate(prevProps) {
if (prevProps.chatMessages !== this.props.chatMessages)
this.scrollToBottom();
}
render() {
const { chatMessages, me } = this.props;
return (
<div className='chat-messages'>
{chatMessages.map(chatMessage => (
<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')}
>
<span
title={this.getFormattedTimestamp(chatMessage)}
className='chat-message__bubble'
dangerouslySetInnerHTML={{ __html: emojify(chatMessage.get('content')) }}
/>
</div>
))}
<div style={{ float: 'left', clear: 'both' }} ref={this.setMessageEndRef} />
</div>
);
}
}

View file

@ -0,0 +1,85 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { getSettings } from 'soapbox/actions/settings';
import ChatList from './chat_list';
import { FormattedMessage } from 'react-intl';
import { makeGetChat } from 'soapbox/selectors';
import { openChat, toggleMainWindow } from 'soapbox/actions/chats';
import ChatWindow from './chat_window';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const addChatsToPanes = (state, panesData) => {
const getChat = makeGetChat();
const newPanes = panesData.get('panes').map(pane => {
const chat = getChat(state, { id: pane.get('chat_id') });
return pane.set('chat', chat);
});
return panesData.set('panes', newPanes);
};
const mapStateToProps = state => {
const panesData = getSettings(state).get('chats');
return {
panesData: addChatsToPanes(state, panesData),
unreadCount: state.get('chats').reduce((acc, curr) => acc + curr.get('unread'), 0),
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatPanes extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
panesData: ImmutablePropTypes.map,
}
handleClickChat = (chat) => {
this.props.dispatch(openChat(chat.get('id')));
}
handleMainWindowToggle = () => {
this.props.dispatch(toggleMainWindow());
}
render() {
const { panesData, unreadCount } = this.props;
const panes = panesData.get('panes');
const mainWindow = panesData.get('mainWindow');
const mainWindowPane = (
<div className={`pane pane--main pane--${mainWindow}`}>
<div className='pane__header'>
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
<button className='pane__title' onClick={this.handleMainWindowToggle}>
<FormattedMessage id='chat_panels.main_window.title' defaultMessage='Chats' />
</button>
</div>
<div className='pane__content'>
<ChatList
onClickChat={this.handleClickChat}
emptyMessage={<FormattedMessage id='chat_panels.main_window.empty' defaultMessage="No chats found. To start a chat, visit a user's profile." />}
/>
</div>
</div>
);
return (
<div className='chat-panes'>
{mainWindowPane}
{panes.map((pane, i) =>
<ChatWindow idx={i} pane={pane} key={pane.get('chat_id')} />
)}
</div>
);
}
}

View file

@ -0,0 +1,105 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from 'soapbox/components/avatar';
import { acctFull } from 'soapbox/utils/accounts';
import IconButton from 'soapbox/components/icon_button';
import {
closeChat,
toggleChat,
} from 'soapbox/actions/chats';
import ChatBox from './chat_box';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const mapStateToProps = (state, { pane }) => ({
me: state.get('me'),
chat: state.getIn(['chats', pane.get('chat_id')]),
});
export default @connect(mapStateToProps)
@injectIntl
class ChatWindow extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
pane: ImmutablePropTypes.map.isRequired,
idx: PropTypes.number,
chat: ImmutablePropTypes.map,
me: PropTypes.node,
}
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.pane.get('state');
const newState = this.props.pane.get('state');
if (oldState !== newState && newState === 'open')
this.focusInput();
}
render() {
const { pane, idx, chat } = this.props;
const account = pane.getIn(['chat', 'account']);
if (!chat || !account) return null;
const right = (285 * (idx + 1)) + 20;
const unreadCount = chat.get('unread');
return (
<div className={`pane pane--${pane.get('state')}`} style={{ right: `${right}px` }}>
<div className='pane__header'>
{unreadCount > 0
? <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>
: <Avatar account={account} size={18} />
}
<button className='pane__title' onClick={this.handleChatToggle(chat.get('id'))}>
@{acctFull(account)}
</button>
<div className='pane__close'>
<IconButton icon='close' 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>
);
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ChatList from './components/chat_list';
const messages = defineMessages({
title: { id: 'column.chats', defaultMessage: 'Chats' },
});
export default @injectIntl
class ChatIndex extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
};
static contextTypes = {
router: PropTypes.object,
};
handleClickChat = (chat) => {
this.context.router.history.push(`/chats/${chat.get('id')}`);
}
render() {
const { intl } = this.props;
return (
<Column label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='comment'
title={intl.formatMessage(messages.title)}
/>
<ChatList
onClickChat={this.handleClickChat}
emptyMessage={<FormattedMessage id='chat_panels.main_window.empty' defaultMessage="No chats found. To start a chat, visit a user's profile." />}
/>
</Column>
);
}
}

View file

@ -69,7 +69,6 @@ class ActionBar extends React.PureComponent {
let menu = [];
menu.push({ text: intl.formatMessage(messages.profile), to: `/@${meUsername}` });
menu.push({ text: intl.formatMessage(messages.messages), to: '/messages' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push(null);

View file

@ -142,6 +142,33 @@ class Notification extends ImmutablePureComponent {
);
}
renderChatMention(notification, link) {
const { intl } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-chat-mention focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.chat_mention', defaultMessage: '{name} sent you a message' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='comment' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.chat_mention' defaultMessage='{name} sent you a message' values={{ name: link }} />
</span>
</div>
</div>
<div className='chat-message'>
<span
className='chat-message__bubble'
dangerouslySetInnerHTML={{ __html: emojify(notification.getIn(['chat_message', 'content'])) }}
/>
</div>
</HotKeys>
);
}
renderEmojiReact(notification, link) {
const { intl } = this.props;
@ -289,6 +316,8 @@ class Notification extends ImmutablePureComponent {
return this.renderPoll(notification);
case 'pleroma:emoji_reaction':
return this.renderEmojiReact(notification, link);
case 'pleroma:chat_mention':
return this.renderChatMention(notification, link);
}
return null;

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import IconWithBadge from 'soapbox/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.get('chats').reduce((acc, curr) => acc + curr.get('unread'), 0),
id: 'comment',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -6,7 +6,6 @@ import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit Profile' },
messages: { id: 'navigation_bar.messages', defaultMessage: 'Messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
@ -35,13 +34,6 @@ class FeaturesPanel extends React.PureComponent {
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/messages'>
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
{intl.formatMessage(messages.messages)}
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/bookmarks'>
<Icon id='bookmark' className='promo-panel-item__icon' fixedWidth />

View file

@ -7,6 +7,7 @@ import { connect } from 'react-redux';
import classNames from 'classnames';
import NotificationsCounterIcon from './notifications_counter_icon';
import ReportsCounterIcon from './reports_counter_icon';
import ChatsCounterIcon from './chats_counter_icon';
import SearchContainer from 'soapbox/features/compose/containers/search_container';
import Avatar from '../../../components/avatar';
import ActionBar from 'soapbox/features/compose/components/action_bar';
@ -68,6 +69,14 @@ class TabsBar extends React.PureComponent {
<span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span>
</NavLink>);
}
if (account) {
links.push(
<NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'>
<Icon id='comment' />
<ChatsCounterIcon />
<span><FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' /></span>
</NavLink>);
}
if (account && isStaff(account)) {
links.push(
<a key='reports' className='tabs-bar__link' href='/pleroma/admin/#/reports/index' target='_blank' data-preview-title-id='tabs_bar.reports'>

View file

@ -36,6 +36,7 @@ import { connectUserStream } from '../../actions/streaming';
import { Redirect } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import { isStaff } from 'soapbox/utils/accounts';
import ChatPanes from 'soapbox/features/chats/components/chat_panes';
import {
Status,
@ -78,6 +79,8 @@ import {
PasswordReset,
SecurityForm,
MfaForm,
ChatIndex,
ChatRoom,
} from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -155,8 +158,6 @@ const LAYOUT = {
},
};
const shouldHideFAB = path => path.match(/^\/posts\/|^\/search|^\/getting-started/);
class SwitchingColumnsArea extends React.PureComponent {
static propTypes = {
@ -236,6 +237,9 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/search' publicRoute page={SearchPage} component={Search} content={children} />
<WrappedRoute path='/chats' exact layout={LAYOUT.DEFAULT} component={ChatIndex} content={children} />
<WrappedRoute path='/chats/:chatId' layout={LAYOUT.DEFAULT} component={ChatRoom} content={children} />
<WrappedRoute path='/follow_requests' layout={LAYOUT.DEFAULT} component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' layout={LAYOUT.DEFAULT} component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' layout={LAYOUT.DEFAULT} component={DomainBlocks} content={children} />
@ -291,6 +295,7 @@ class UI extends React.PureComponent {
state = {
draggingOver: false,
mobile: isMobile(window.innerWidth),
};
handleBeforeUnload = (e) => {
@ -399,10 +404,17 @@ class UI extends React.PureComponent {
}
}
handleResize = debounce(() => {
this.setState({ mobile: isMobile(window.innerWidth) });
}, 500, {
trailing: true,
});
componentDidMount() {
const { account } = this.props;
if (!account) return;
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
window.addEventListener('resize', this.handleResize, { passive: true });
document.addEventListener('dragenter', this.handleDragEnter, false);
document.addEventListener('dragover', this.handleDragOver, false);
@ -436,6 +448,7 @@ class UI extends React.PureComponent {
componentWillUnmount() {
window.removeEventListener('beforeunload', this.handleBeforeUnload);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
@ -562,9 +575,19 @@ class UI extends React.PureComponent {
this.props.dispatch(openModal('COMPOSE'));
}
shouldHideFAB = () => {
const path = this.context.router.history.location.pathname;
return path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/);
}
isChatRoomLocation = () => {
const path = this.context.router.history.location.pathname;
return path.match(/^\/chats\/(.*)/);
}
render() {
const { streamingUrl } = this.props;
const { draggingOver } = this.state;
const { draggingOver, mobile } = this.state;
const { intl, children, isComposing, location, dropdownMenuIsOpen, me } = this.props;
if (me === null || !streamingUrl) return null;
@ -587,11 +610,31 @@ class UI extends React.PureComponent {
goToRequests: this.handleHotkeyGoToRequests,
} : {};
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <button key='floating-action-button' onClick={this.handleOpenComposeModal} className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' fixedWidth /></button>;
const fabElem = (
<button
key='floating-action-button'
onClick={this.handleOpenComposeModal}
className='floating-action-button'
aria-label={intl.formatMessage(messages.publish)}
>
<Icon id='pencil' fixedWidth />
</button>
);
const floatingActionButton = this.shouldHideFAB() ? null : fabElem;
const classnames = classNames('ui', {
'is-composing': isComposing,
'ui--chatroom': this.isChatRoomLocation(),
});
const style = {
pointerEvents: dropdownMenuIsOpen ? 'none' : null,
};
return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
<div className={classnames} ref={this.setRef} style={style}>
<TabsBar />
<SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}>
{children}
@ -604,6 +647,7 @@ class UI extends React.PureComponent {
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
{me && <SidebarMenu />}
{me && !mobile && <ChatPanes />}
</div>
</HotKeys>
);

View file

@ -197,3 +197,11 @@ export function SecurityForm() {
export function MfaForm() {
return import(/* webpackChunkName: "features/security/mfa_form" */'../../security/mfa_form');
}
export function ChatIndex() {
return import(/* webpackChunkName: "features/chats" */'../../chats');
}
export function ChatRoom() {
return import(/* webpackChunkName: "features/chats/chat_room" */'../../chats/chat_room');
}

View file

@ -1,10 +1,8 @@
import * as actions from 'soapbox/actions/notifications';
import reducer from '../notifications';
import notifications from 'soapbox/__fixtures__/notifications.json';
import markers from 'soapbox/__fixtures__/markers.json';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { take } from 'lodash';
import { SAVE_MARKERS_SUCCESS } from 'soapbox/actions/markers';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'soapbox/actions/accounts';
import notification from 'soapbox/__fixtures__/notification.json';
import intlMessages from 'soapbox/__fixtures__/intlMessages.json';
@ -42,6 +40,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -51,6 +50,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -60,6 +60,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -73,21 +74,6 @@ describe('notifications reducer', () => {
}));
});
it('should handle SAVE_MARKERS_SUCCESS', () => {
const state = ImmutableMap({
unread: 1,
lastRead: '35098811',
});
const action = {
type: SAVE_MARKERS_SUCCESS,
markers: markers,
};
expect(reducer(state, action)).toEqual(ImmutableMap({
unread: 3,
lastRead: '35098814',
}));
});
it('should handle NOTIFICATIONS_EXPAND_REQUEST', () => {
const state = ImmutableMap({
isLoading: false,
@ -122,6 +108,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -131,6 +118,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -140,6 +128,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -213,6 +202,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -270,6 +260,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
]),
@ -291,6 +282,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -300,6 +292,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -309,6 +302,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -318,6 +312,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
]),
@ -348,6 +343,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -357,6 +353,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -366,6 +363,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -385,6 +383,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -394,6 +393,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -403,6 +403,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -420,6 +421,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -429,6 +431,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -445,6 +448,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -454,6 +458,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -463,6 +468,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -480,6 +486,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -489,6 +496,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -533,6 +541,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -542,6 +551,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -551,6 +561,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -574,6 +585,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -583,6 +595,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -592,6 +605,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),
@ -610,6 +624,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: undefined,
is_seen: false,
}),
ImmutableMap({
@ -619,6 +634,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
ImmutableMap({
@ -628,6 +644,7 @@ describe('notifications reducer', () => {
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: undefined,
chat_message: undefined,
is_seen: true,
}),
]),

View file

@ -3,6 +3,9 @@ import {
ACCOUNTS_IMPORT,
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
} from '../actions/importer';
import { CHATS_FETCH_SUCCESS, CHAT_FETCH_SUCCESS } from 'soapbox/actions/chats';
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { normalizeAccount as normalizeAccount2 } from 'soapbox/actions/importer/normalizer';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap();
@ -25,6 +28,14 @@ const normalizeAccounts = (state, accounts) => {
return state;
};
const importAccountFromChat = (state, chat) =>
// TODO: Fix this monstrosity
normalizeAccount(state, normalizeAccount2(chat.account));
const importAccountsFromChats = (state, chats) =>
state.withMutations(mutable =>
chats.forEach(chat => importAccountFromChat(mutable, chat)));
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_IMPORT:
@ -35,6 +46,11 @@ export default function accounts(state = initialState, action) {
return state.set(-1, ImmutableMap({
username: action.username,
}));
case CHATS_FETCH_SUCCESS:
return importAccountsFromChats(state, action.chats);
case CHAT_FETCH_SUCCESS:
case STREAMING_CHAT_UPDATE:
return importAccountsFromChats(state, [action.chat]);
default:
return state;
}

View file

@ -0,0 +1,53 @@
import {
CHATS_FETCH_SUCCESS,
CHAT_MESSAGES_FETCH_SUCCESS,
CHAT_MESSAGE_SEND_REQUEST,
CHAT_MESSAGE_SEND_SUCCESS,
} from 'soapbox/actions/chats';
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
const initialState = ImmutableMap();
const updateList = (state, chatId, messageIds) => {
const ids = state.get(chatId, ImmutableOrderedSet());
const newIds = ids.union(messageIds);
return state.set(chatId, newIds);
};
const importMessage = (state, chatMessage) => {
return updateList(state, chatMessage.chat_id, [chatMessage.id]);
};
const importMessages = (state, chatMessages) => (
state.withMutations(map =>
chatMessages.forEach(chatMessage =>
importMessage(map, chatMessage)))
);
const importLastMessages = (state, chats) =>
state.withMutations(mutable =>
chats.forEach(chat => {
if (chat.last_message) importMessage(mutable, chat.last_message);
}));
export default function chatMessageLists(state = initialState, action) {
switch(action.type) {
case CHAT_MESSAGE_SEND_REQUEST:
return updateList(state, action.chatId, [action.uuid]).sort();
case CHATS_FETCH_SUCCESS:
return importLastMessages(state, action.chats).sort();
case STREAMING_CHAT_UPDATE:
if (action.chat.last_message &&
action.chat.last_message.account_id !== action.me)
return importMessages(state, [action.chat.last_message]).sort();
else
return state;
case CHAT_MESSAGES_FETCH_SUCCESS:
return updateList(state, action.chatId, action.chatMessages.map(chat => chat.id).reverse()).sort();
case CHAT_MESSAGE_SEND_SUCCESS:
return updateList(state, action.chatId, [action.chatMessage.id]).sort();
default:
return state;
}
};

View file

@ -0,0 +1,49 @@
import {
CHATS_FETCH_SUCCESS,
CHAT_MESSAGES_FETCH_SUCCESS,
CHAT_MESSAGE_SEND_REQUEST,
CHAT_MESSAGE_SEND_SUCCESS,
} from 'soapbox/actions/chats';
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap();
const importMessage = (state, message) => {
return state.set(message.get('id'), message);
};
const importMessages = (state, messages) =>
state.withMutations(mutable =>
messages.forEach(message => importMessage(mutable, message)));
const importLastMessages = (state, chats) =>
state.withMutations(mutable =>
chats.forEach(chat => {
if (chat.get('last_message'))
importMessage(mutable, chat.get('last_message'));
}));
export default function chatMessages(state = initialState, action) {
switch(action.type) {
case CHAT_MESSAGE_SEND_REQUEST:
return importMessage(state, fromJS({
id: action.uuid, // Make fake message to get overriden later
chat_id: action.chatId,
account_id: action.me,
content: action.params.content,
created_at: (new Date()).toISOString(),
pending: true,
}));
case CHATS_FETCH_SUCCESS:
return importLastMessages(state, fromJS(action.chats));
case CHAT_MESSAGES_FETCH_SUCCESS:
return importMessages(state, fromJS(action.chatMessages));
case CHAT_MESSAGE_SEND_SUCCESS:
return importMessage(state, fromJS(action.chatMessage)).delete(action.uuid);
case STREAMING_CHAT_UPDATE:
return importLastMessages(state, fromJS([action.chat]));
default:
return state;
}
};

View file

@ -0,0 +1,33 @@
import {
CHATS_FETCH_SUCCESS,
CHAT_FETCH_SUCCESS,
CHAT_READ_SUCCESS,
CHAT_READ_REQUEST,
} from 'soapbox/actions/chats';
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { normalizeChat } from 'soapbox/actions/importer/normalizer';
import { Map as ImmutableMap, fromJS } from 'immutable';
const importChat = (state, chat) => state.set(chat.id, fromJS(normalizeChat(chat)));
const importChats = (state, chats) =>
state.withMutations(mutable => chats.forEach(chat => importChat(mutable, chat)));
const initialState = ImmutableMap();
export default function chats(state = initialState, action) {
switch(action.type) {
case CHATS_FETCH_SUCCESS:
return importChats(state, action.chats);
case STREAMING_CHAT_UPDATE:
return importChats(state, [action.chat]);
case CHAT_FETCH_SUCCESS:
return importChats(state, [action.chat]);
case CHAT_READ_REQUEST:
return state.setIn([action.chatId, 'unread'], 0);
case CHAT_READ_SUCCESS:
return importChats(state, [action.chat]);
default:
return state;
}
};

View file

@ -43,6 +43,9 @@ import instance from './instance';
import me from './me';
import auth from './auth';
import admin from './admin';
import chats from './chats';
import chat_messages from './chat_messages';
import chat_message_lists from './chat_message_lists';
const reducers = {
dropdown_menu,
@ -89,6 +92,9 @@ const reducers = {
me,
auth,
admin,
chats,
chat_messages,
chat_message_lists,
};
export default combineReducers(reducers);

View file

@ -15,14 +15,9 @@ import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import {
FETCH_MARKERS_SUCCESS,
SAVE_MARKERS_SUCCESS,
} from '../actions/markers';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
import { fromJS } from 'immutable';
import { get } from 'lodash';
const initialState = ImmutableMap({
@ -43,6 +38,7 @@ const notificationToMap = notification => ImmutableMap({
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
emoji: notification.emoji,
chat_message: notification.chat_message,
is_seen: get(notification, ['pleroma', 'is_seen'], true),
});
@ -136,13 +132,6 @@ const updateNotificationsQueue = (state, notification, intlMessages, intlLocale)
export default function notifications(state = initialState, action) {
switch(action.type) {
case FETCH_MARKERS_SUCCESS:
case SAVE_MARKERS_SUCCESS:
const prevRead = state.get('lastRead');
const marker = fromJS(action.markers);
const unread = marker.getIn(['notifications', 'pleroma', 'unread_count'], state.get('unread', 0));
const lastRead = marker.getIn(['notifications', 'last_read_id'], prevRead);
return state.merge({ unread, lastRead });
case NOTIFICATIONS_EXPAND_REQUEST:
return state.set('isLoading', true);
case NOTIFICATIONS_EXPAND_FAIL:

View file

@ -3,7 +3,7 @@ import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications';
import { STORE_HYDRATE } from '../actions/store';
import { EMOJI_USE } from '../actions/emojis';
import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
import { ME_FETCH_SUCCESS } from 'soapbox/actions/me';
import { Map as ImmutableMap, fromJS } from 'immutable';
import uuid from '../uuid';
@ -32,13 +32,8 @@ export default function settings(state = initialState, action) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('settings'));
case ME_FETCH_SUCCESS:
case ME_PATCH_SUCCESS:
const me = fromJS(action.me);
let fePrefs = me.getIn(['pleroma', 'settings_store', FE_NAME], ImmutableMap());
// Spinster migration hotfix
if (fePrefs.get('locale') === '') {
fePrefs = fePrefs.delete('locale');
}
return state.merge(fePrefs);
case NOTIFICATIONS_FILTER_SET:
case SETTING_CHANGE:

View file

@ -157,3 +157,20 @@ export const getAccountGallery = createSelector([
.map(media => media.merge({ status, account })));
}, ImmutableList());
});
export const makeGetChat = () => {
return createSelector(
[
(state, { id }) => state.getIn(['chats', id]),
(state, { id }) => state.getIn(['accounts', state.getIn(['chats', id, 'account'])]),
],
(chat, account) => {
if (!chat) return null;
return chat.withMutations(map => {
map.set('account', account);
});
}
);
};

View file

@ -327,9 +327,10 @@
.account {
padding: 10px;
position: relative;
&:not(:last-of-type) {
border-bottom: 1px solid var(--brand-color--med);
border-bottom: 1px solid var(--brand-color--med);
}
&.compact {

View file

@ -28,6 +28,7 @@
@import 'demetricator';
@import 'pro';
@import 'overflow_hacks';
@import 'chats';
// COMPONENTS
@import 'components/buttons';

View file

@ -227,5 +227,9 @@ noscript {
bottom: 0;
left: 0;
position: absolute;
z-index: 9999;
z-index: 201;
background: transparent;
border: 0;
margin: 0;
padding: 0;
}

255
app/styles/chats.scss Normal file
View file

@ -0,0 +1,255 @@
.pane {
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.3);
border-radius: 6px 6px 0 0;
position: fixed;
bottom: 0;
right: 20px;
width: 265px;
height: 265px;
max-height: calc(100vh - 70px);
display: flex;
flex-direction: column;
z-index: 999;
transition: 0.05s;
&--main {
height: calc(100vh - 70px);
.pane__header .pane__title {
font-size: 16px;
}
}
&--minimized {
height: 31px;
}
&__header {
box-sizing: border-box;
background: var(--brand-color);
color: #fff;
padding: 0 10px;
font-weight: bold;
border-radius: 6px 6px 0 0;
display: flex;
align-items: center;
height: 31px;
.account__avatar {
margin-right: 7px;
}
.pane__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
height: 100%;
background: transparent;
border: 0;
padding: 0;
color: #fff;
font-weight: bold;
text-align: left;
font-size: 14px;
}
.icon-button {
color: #fff;
> div {
height: auto !important;
width: auto !important;
margin-right: -6px;
}
}
.pane__close {
margin-left: auto;
}
.icon-with-badge__badge {
position: static;
pointer-events: none;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 7px;
}
}
&__content {
background: var(--foreground-color);
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
.chat-box {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
}
}
.chat-messages {
overflow-y: scroll;
flex: 1;
}
.chat-message {
margin: 14px 10px;
display: flex;
&__bubble {
font-size: 15px;
padding: 4px 10px;
max-width: 70%;
border-radius: 10px;
background-color: var(--background-color);
overflow: hidden;
text-overflow: ellipsis;
a {
color: var(--brand-color--hicontrast);
}
}
&--me .chat-message__bubble {
margin-left: auto;
background-color: hsla(var(--brand-color_hsl), 0.2);
}
&--pending .chat-message__bubble {
opacity: 0.5;
}
}
.chat-list {
overflow-y: auto;
flex: 1;
&__content {
height: 100%;
}
.empty-column-indicator {
height: 100%;
box-sizing: border-box;
background: transparent;
align-items: start;
}
.account__display-name {
position: relative;
}
.icon-with-badge__badge {
top: 0;
right: 0;
left: auto;
bottom: auto;
}
}
.chat-box {
&__actions {
background: var(--foreground-color);
margin-top: auto;
padding: 6px;
textarea {
width: 100%;
height: 100%;
margin: 0;
box-sizing: border-box;
padding: 6px;
background: var(--background-color);
border: 0;
border-radius: 6px;
color: var(--primary-text-color);
font-size: 15px;
}
}
}
.ui--chatroom {
padding-bottom: 0;
.columns-area__panels__main .columns-area {
height: calc(100vh - 100px);
box-sizing: border-box;
overflow: hidden;
@media(max-width: 630px) {
height: calc(100vh - 50px);
}
}
.page {
.chat-box {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
border-radius: 0 0 10px 10px;
&__actions textarea {
padding: 10px;
}
}
}
}
@media(max-width: 630px) {
.chat-panes {
display: none;
}
}
@media(min-width: 630px) {
.tabs-bar .tabs-bar__link--chats {
display: none;
}
}
.chatroom__header {
display: flex;
margin-left: auto;
padding-right: 15px;
overflow: hidden;
.account__avatar {
margin-right: 7px;
}
.chatroom__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
height: 100%;
background: transparent;
border: 0;
padding: 0;
color: #fff;
font-weight: bold;
text-align: left;
font-size: 14px;
}
}
.chatroom__back {
display: flex;
align-items: center;
background: var(--accent-color--faint);
border-radius: 10px 10px 0 0;
.column-back-button {
background: transparent;
}
}

View file

@ -678,7 +678,6 @@
align-items: center;
justify-content: center;
min-height: 160px;
border-radius: 0 0 10px 10px;
@supports(display: grid) { // hack to fix Chrome <57
contain: strict;

View file

@ -14,7 +14,7 @@
transition-property: opacity;
transition-duration: 0.2s;
width: 320px;
z-index: 998;
z-index: 200;
left: -10px;
padding: 20px;
margin-bottom: 10px;

View file

@ -369,7 +369,7 @@
justify-content: center;
transition: 0.2s;
@media screen and (max-width: 895px) {
@media screen and (max-width: 630px) {
display: flex;
}

View file

@ -29,11 +29,13 @@ const makeProxyConfig = () => {
proxyConfig['/api/patron'] = {
target: patronUrl,
secure: secureProxy,
changeOrigin: true,
};
backendEndpoints.map(endpoint => {
proxyConfig[endpoint] = {
target: backendUrl,
secure: secureProxy,
changeOrigin: true,
};
});
return proxyConfig;