Add channel joining UI, closes #37
This commit is contained in:
parent
f25594e962
commit
24b26aa85f
20 changed files with 1131 additions and 177 deletions
|
@ -19,6 +19,7 @@ const App = ({
|
|||
select,
|
||||
push,
|
||||
hideMenu,
|
||||
openModal,
|
||||
newVersionAvailable,
|
||||
hasOpenModals
|
||||
}) => {
|
||||
|
@ -59,10 +60,10 @@ const App = ({
|
|||
showTabList={showTabList}
|
||||
select={select}
|
||||
push={push}
|
||||
openModal={openModal}
|
||||
/>
|
||||
<div className={mainClass}>
|
||||
<Suspense fallback={<div className="suspense-fallback">...</div>}>
|
||||
{renderModals && <Modals />}
|
||||
<Route name="chat">
|
||||
<Chat />
|
||||
</Route>
|
||||
|
@ -73,6 +74,11 @@ const App = ({
|
|||
<Settings />
|
||||
</Route>
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={<div className="suspense-modal-fallback">...</div>}
|
||||
>
|
||||
{renderModals && <Modals />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,7 +11,14 @@ export default class TabList extends PureComponent {
|
|||
handleSettingsClick = () => this.props.push('/settings');
|
||||
|
||||
render() {
|
||||
const { tab, channels, servers, privateChats, showTabList } = this.props;
|
||||
const {
|
||||
tab,
|
||||
channels,
|
||||
servers,
|
||||
privateChats,
|
||||
showTabList,
|
||||
openModal
|
||||
} = this.props;
|
||||
const tabs = [];
|
||||
|
||||
const className = classnames('tablist', {
|
||||
|
@ -32,6 +39,17 @@ export default class TabList extends PureComponent {
|
|||
/>
|
||||
);
|
||||
|
||||
tabs.push(
|
||||
<div
|
||||
key={`${address}-chans}`}
|
||||
className="tab-label"
|
||||
onClick={() => openModal('channel', { server: address })}
|
||||
>
|
||||
<span>CHANNELS ({server.channels.length})</span>
|
||||
<Button>+</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
server.channels.forEach(name =>
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
|
@ -48,7 +66,8 @@ export default class TabList extends PureComponent {
|
|||
if (privateChats[address] && privateChats[address].length > 0) {
|
||||
tabs.push(
|
||||
<div key={`${address}-pm}`} className="tab-label">
|
||||
Private messages
|
||||
<span>DIRECT MESSAGES ({privateChats[address].length})</span>
|
||||
{/*<Button>+</Button>*/}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
153
client/js/components/modals/AddChannel.js
Normal file
153
client/js/components/modals/AddChannel.js
Normal file
|
@ -0,0 +1,153 @@
|
|||
import React, { memo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import withModal from 'components/modals/withModal';
|
||||
import Button from 'components/ui/Button';
|
||||
import { join } from 'state/channels';
|
||||
import { select } from 'state/tab';
|
||||
import { searchChannels } from 'state/channelSearch';
|
||||
|
||||
const Channel = memo(({ server, name, topic, userCount, joined, ...props }) => {
|
||||
const handleJoinClick = useCallback(() => props.join([name], server), []);
|
||||
|
||||
return (
|
||||
<div className="modal-channel-result">
|
||||
<div className="modal-channel-result-header">
|
||||
<h2 className="modal-channel-name" onClick={handleJoinClick}>
|
||||
{name}
|
||||
</h2>
|
||||
<span className="modal-channel-users">
|
||||
<i className="icon-user" />
|
||||
{userCount}
|
||||
</span>
|
||||
{joined ? (
|
||||
<span style={{ color: '#6bb758' }}>Joined</span>
|
||||
) : (
|
||||
<Button
|
||||
className="modal-channel-button-join"
|
||||
category="normal"
|
||||
onClick={handleJoinClick}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="modal-channel-topic">{topic}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const AddChannel = ({ search, payload: { server }, onClose, ...props }) => {
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const inputEl = useRef();
|
||||
const resultsEl = useRef();
|
||||
const prevSearch = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
inputEl.current.focus();
|
||||
props.searchChannels(server, '');
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
e => {
|
||||
let nextQ = e.target.value.trim().toLowerCase();
|
||||
setQ(nextQ);
|
||||
|
||||
if (nextQ !== q) {
|
||||
resultsEl.current.scrollTop = 0;
|
||||
|
||||
while (nextQ.charAt(0) === '#') {
|
||||
nextQ = nextQ.slice(1);
|
||||
}
|
||||
|
||||
if (nextQ !== prevSearch.current) {
|
||||
prevSearch.current = nextQ;
|
||||
props.searchChannels(server, nextQ);
|
||||
}
|
||||
}
|
||||
},
|
||||
[q]
|
||||
);
|
||||
|
||||
const handleKey = useCallback(e => {
|
||||
if (e.key === 'Enter') {
|
||||
let channel = e.target.value.trim();
|
||||
|
||||
if (channel !== '') {
|
||||
onClose(false);
|
||||
|
||||
if (channel.charAt(0) !== '#') {
|
||||
channel = `#${channel}`;
|
||||
}
|
||||
|
||||
props.join([channel], server);
|
||||
props.select(server, channel);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
() => props.searchChannels(server, q, search.results.length),
|
||||
[q, search.results.length]
|
||||
);
|
||||
|
||||
let hasMore = !search.end;
|
||||
if (hasMore) {
|
||||
if (search.results.length < 10) {
|
||||
hasMore = false;
|
||||
} else if (
|
||||
search.results.length > 10 &&
|
||||
(search.results.length - 10) % 50 !== 0
|
||||
) {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-channel-input-wrap">
|
||||
<input
|
||||
ref={inputEl}
|
||||
type="text"
|
||||
value={q}
|
||||
placeholder="Enter channel name"
|
||||
onKeyDown={handleKey}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<i className="icon-cancel modal-channel-close" onClick={onClose} />
|
||||
</div>
|
||||
<div ref={resultsEl} className="modal-channel-results">
|
||||
{search.results.map(channel => (
|
||||
<Channel
|
||||
key={`${server} ${channel.name}`}
|
||||
server={server}
|
||||
join={props.join}
|
||||
joined={get(
|
||||
props.channels,
|
||||
[server, channel.name, 'joined'],
|
||||
false
|
||||
)}
|
||||
{...channel}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<Button
|
||||
className="modal-channel-button-more"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal({
|
||||
name: 'channel',
|
||||
state: {
|
||||
channels: state => state.channels,
|
||||
search: state => state.channelSearch
|
||||
},
|
||||
actions: { searchChannels, join, select }
|
||||
})(AddChannel);
|
|
@ -1,8 +1,10 @@
|
|||
import React, { memo } from 'react';
|
||||
import AddChannel from 'components/modals/AddChannel';
|
||||
import Confirm from 'components/modals/Confirm';
|
||||
|
||||
const Modals = () => (
|
||||
<>
|
||||
<AddChannel />
|
||||
<Confirm />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { createSelector } from 'reselect';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import { getModals, closeModal } from 'state/modals';
|
||||
import connect from 'utils/connect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
Modal.setAppElement('#root');
|
||||
|
||||
|
@ -47,15 +49,23 @@ export default function withModal({ name, ...modalProps }) {
|
|||
);
|
||||
};
|
||||
|
||||
const mapState = createSelector(
|
||||
getModals,
|
||||
modals => modals[name] || { payload: {} }
|
||||
);
|
||||
|
||||
const mapDispatch = dispatch => ({
|
||||
onRequestClose: () => dispatch(closeModal(name))
|
||||
const mapState = createStructuredSelector({
|
||||
isOpen: state => get(getModals(state), [name, 'isOpen'], false),
|
||||
payload: state => get(getModals(state), [name, 'payload'], {}),
|
||||
...modalProps.state
|
||||
});
|
||||
|
||||
const mapDispatch = dispatch => {
|
||||
let actions = { onRequestClose: () => dispatch(closeModal(name)) };
|
||||
if (modalProps.actions) {
|
||||
return {
|
||||
...actions,
|
||||
...bindActionCreators(modalProps.actions, dispatch)
|
||||
};
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
return connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
const Button = ({ children, category, ...props }) => (
|
||||
<button className={`button-${category}`} type="button" {...props}>
|
||||
const Button = ({ children, category, className, ...props }) => (
|
||||
<button
|
||||
className={cn(`button-${category}`, className)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { createStructuredSelector } from 'reselect';
|
|||
import App from 'components/App';
|
||||
import { getConnected } from 'state/app';
|
||||
import { getSortedChannels } from 'state/channels';
|
||||
import { getHasOpenModals } from 'state/modals';
|
||||
import { openModal, getHasOpenModals } from 'state/modals';
|
||||
import { getPrivateChats } from 'state/privateChats';
|
||||
import { getServers } from 'state/servers';
|
||||
import { getSelectedTab, select } from 'state/tab';
|
||||
|
@ -21,7 +21,7 @@ const mapState = createStructuredSelector({
|
|||
hasOpenModals: getHasOpenModals
|
||||
});
|
||||
|
||||
const mapDispatch = { push, select, hideMenu };
|
||||
const mapDispatch = { push, select, hideMenu, openModal };
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
|
|
|
@ -6,6 +6,8 @@ export const KICK = 'KICK';
|
|||
export const PART = 'PART';
|
||||
export const SET_TOPIC = 'SET_TOPIC';
|
||||
|
||||
export const CHANNEL_SEARCH = 'CHANNEL_SEARCH';
|
||||
|
||||
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
|
||||
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
|
||||
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
|
||||
|
@ -64,6 +66,7 @@ export const socket = createSocketActions([
|
|||
'cert_fail',
|
||||
'cert_success',
|
||||
'channels',
|
||||
'channel_search',
|
||||
'connection_update',
|
||||
'join',
|
||||
'message',
|
||||
|
|
41
client/js/state/channelSearch.js
Normal file
41
client/js/state/channelSearch.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from 'state/actions';
|
||||
|
||||
const initialState = {
|
||||
results: [],
|
||||
end: false
|
||||
};
|
||||
|
||||
export default createReducer(initialState, {
|
||||
[actions.socket.CHANNEL_SEARCH](state, { results, start }) {
|
||||
if (results) {
|
||||
state.end = false;
|
||||
|
||||
if (start > 0) {
|
||||
state.results.push(...results);
|
||||
} else {
|
||||
state.results = results;
|
||||
}
|
||||
} else {
|
||||
state.end = true;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.OPEN_MODAL](state, { name }) {
|
||||
if (name === 'channel') {
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function searchChannels(server, q, start) {
|
||||
return {
|
||||
type: actions.CHANNEL_SEARCH,
|
||||
server,
|
||||
q,
|
||||
socket: {
|
||||
type: 'channel_search',
|
||||
data: { server, q, start }
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import app from './app';
|
||||
import channels from './channels';
|
||||
import channelSearch from './channelSearch';
|
||||
import input from './input';
|
||||
import messages from './messages';
|
||||
import modals from './modals';
|
||||
|
@ -19,6 +20,7 @@ export default function createReducer(router) {
|
|||
router,
|
||||
app,
|
||||
channels,
|
||||
channelSearch,
|
||||
input,
|
||||
messages,
|
||||
modals,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue