Merge branch 'chats' into 'develop'
ChatMessages MVP See merge request soapbox-pub/soapbox-fe!185
This commit is contained in:
commit
a419c5da34
42 changed files with 1404 additions and 72 deletions
152
app/soapbox/actions/chats.js
Normal file
152
app/soapbox/actions/chats.js
Normal 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 });
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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));
|
||||
|
|
74
app/soapbox/features/chats/chat_room.js
Normal file
74
app/soapbox/features/chats/chat_room.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
42
app/soapbox/features/chats/components/chat.js
Normal file
42
app/soapbox/features/chats/components/chat.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
108
app/soapbox/features/chats/components/chat_box.js
Normal file
108
app/soapbox/features/chats/components/chat_box.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
71
app/soapbox/features/chats/components/chat_list.js
Normal file
71
app/soapbox/features/chats/components/chat_list.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
89
app/soapbox/features/chats/components/chat_message_list.js
Normal file
89
app/soapbox/features/chats/components/chat_message_list.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
85
app/soapbox/features/chats/components/chat_panes.js
Normal file
85
app/soapbox/features/chats/components/chat_panes.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
105
app/soapbox/features/chats/components/chat_window.js
Normal file
105
app/soapbox/features/chats/components/chat_window.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
45
app/soapbox/features/chats/index.js
Normal file
45
app/soapbox/features/chats/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
9
app/soapbox/features/ui/components/chats_counter_icon.js
Normal file
9
app/soapbox/features/ui/components/chats_counter_icon.js
Normal 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);
|
|
@ -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 />
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
]),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
53
app/soapbox/reducers/chat_message_lists.js
Normal file
53
app/soapbox/reducers/chat_message_lists.js
Normal 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;
|
||||
}
|
||||
};
|
49
app/soapbox/reducers/chat_messages.js
Normal file
49
app/soapbox/reducers/chat_messages.js
Normal 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;
|
||||
}
|
||||
};
|
33
app/soapbox/reducers/chats.js
Normal file
33
app/soapbox/reducers/chats.js
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
@import 'demetricator';
|
||||
@import 'pro';
|
||||
@import 'overflow_hacks';
|
||||
@import 'chats';
|
||||
|
||||
// COMPONENTS
|
||||
@import 'components/buttons';
|
||||
|
|
|
@ -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
255
app/styles/chats.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -369,7 +369,7 @@
|
|||
justify-content: center;
|
||||
transition: 0.2s;
|
||||
|
||||
@media screen and (max-width: 895px) {
|
||||
@media screen and (max-width: 630px) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue